feature/collections #1
BIN
0271785-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg
Normal file
BIN
0271785-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -1,8 +1,6 @@
|
|||||||
package com.storycove.controller;
|
package com.storycove.controller;
|
||||||
|
|
||||||
import com.storycove.dto.AuthorDto;
|
import com.storycove.dto.*;
|
||||||
import com.storycove.dto.AuthorSearchDto;
|
|
||||||
import com.storycove.dto.SearchResultDto;
|
|
||||||
import com.storycove.entity.Author;
|
import com.storycove.entity.Author;
|
||||||
import com.storycove.service.AuthorService;
|
import com.storycove.service.AuthorService;
|
||||||
import com.storycove.service.ImageService;
|
import com.storycove.service.ImageService;
|
||||||
@@ -43,7 +41,7 @@ public class AuthorController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<Page<AuthorDto>> getAllAuthors(
|
public ResponseEntity<Page<AuthorSummaryDto>> getAllAuthors(
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size,
|
@RequestParam(defaultValue = "20") int size,
|
||||||
@RequestParam(defaultValue = "name") String sortBy,
|
@RequestParam(defaultValue = "name") String sortBy,
|
||||||
@@ -54,7 +52,7 @@ public class AuthorController {
|
|||||||
|
|
||||||
Pageable pageable = PageRequest.of(page, size, sort);
|
Pageable pageable = PageRequest.of(page, size, sort);
|
||||||
Page<Author> authors = authorService.findAll(pageable);
|
Page<Author> authors = authorService.findAll(pageable);
|
||||||
Page<AuthorDto> authorDtos = authors.map(this::convertToDto);
|
Page<AuthorSummaryDto> authorDtos = authors.map(this::convertToSummaryDto);
|
||||||
|
|
||||||
return ResponseEntity.ok(authorDtos);
|
return ResponseEntity.ok(authorDtos);
|
||||||
}
|
}
|
||||||
@@ -255,14 +253,14 @@ public class AuthorController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
public ResponseEntity<Page<AuthorDto>> searchAuthors(
|
public ResponseEntity<Page<AuthorSummaryDto>> searchAuthors(
|
||||||
@RequestParam String query,
|
@RequestParam String query,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size) {
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
|
||||||
Pageable pageable = PageRequest.of(page, size);
|
Pageable pageable = PageRequest.of(page, size);
|
||||||
Page<Author> authors = authorService.searchByName(query, pageable);
|
Page<Author> authors = authorService.searchByName(query, pageable);
|
||||||
Page<AuthorDto> authorDtos = authors.map(this::convertToDto);
|
Page<AuthorSummaryDto> authorDtos = authors.map(this::convertToSummaryDto);
|
||||||
|
|
||||||
return ResponseEntity.ok(authorDtos);
|
return ResponseEntity.ok(authorDtos);
|
||||||
}
|
}
|
||||||
@@ -353,10 +351,10 @@ public class AuthorController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/top-rated")
|
@GetMapping("/top-rated")
|
||||||
public ResponseEntity<List<AuthorDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
|
public ResponseEntity<List<AuthorSummaryDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
|
||||||
Pageable pageable = PageRequest.of(0, limit);
|
Pageable pageable = PageRequest.of(0, limit);
|
||||||
List<Author> authors = authorService.findTopRated(pageable);
|
List<Author> authors = authorService.findTopRated(pageable);
|
||||||
List<AuthorDto> authorDtos = authors.stream().map(this::convertToDto).collect(Collectors.toList());
|
List<AuthorSummaryDto> authorDtos = authors.stream().map(this::convertToSummaryDto).collect(Collectors.toList());
|
||||||
|
|
||||||
return ResponseEntity.ok(authorDtos);
|
return ResponseEntity.ok(authorDtos);
|
||||||
}
|
}
|
||||||
@@ -422,6 +420,24 @@ public class AuthorController {
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AuthorSummaryDto convertToSummaryDto(Author author) {
|
||||||
|
AuthorSummaryDto dto = new AuthorSummaryDto();
|
||||||
|
dto.setId(author.getId());
|
||||||
|
dto.setName(author.getName());
|
||||||
|
dto.setNotes(author.getNotes());
|
||||||
|
dto.setAvatarImagePath(author.getAvatarImagePath());
|
||||||
|
dto.setAuthorRating(author.getAuthorRating());
|
||||||
|
dto.setUrls(author.getUrls());
|
||||||
|
dto.setStoryCount(author.getStories() != null ? author.getStories().size() : 0);
|
||||||
|
dto.setCreatedAt(author.getCreatedAt());
|
||||||
|
dto.setUpdatedAt(author.getUpdatedAt());
|
||||||
|
|
||||||
|
// Calculate and set average story rating without loading all stories
|
||||||
|
dto.setAverageStoryRating(authorService.calculateAverageStoryRating(author.getId()));
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
private AuthorDto convertSearchDtoToDto(AuthorSearchDto searchDto) {
|
private AuthorDto convertSearchDtoToDto(AuthorSearchDto searchDto) {
|
||||||
AuthorDto dto = new AuthorDto();
|
AuthorDto dto = new AuthorDto();
|
||||||
dto.setId(searchDto.getId());
|
dto.setId(searchDto.getId());
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package com.storycove.controller;
|
||||||
|
|
||||||
|
import com.storycove.dto.*;
|
||||||
|
import com.storycove.entity.Collection;
|
||||||
|
import com.storycove.entity.CollectionStory;
|
||||||
|
import com.storycove.entity.Story;
|
||||||
|
import com.storycove.entity.Tag;
|
||||||
|
import com.storycove.service.CollectionService;
|
||||||
|
import com.storycove.service.ImageService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/collections")
|
||||||
|
public class CollectionController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(CollectionController.class);
|
||||||
|
|
||||||
|
private final CollectionService collectionService;
|
||||||
|
private final ImageService imageService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public CollectionController(CollectionService collectionService,
|
||||||
|
ImageService imageService) {
|
||||||
|
this.collectionService = collectionService;
|
||||||
|
this.imageService = imageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/collections - Search and list collections with pagination
|
||||||
|
* IMPORTANT: Uses Typesense for all search/filter operations
|
||||||
|
*/
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<SearchResultDto<CollectionDto>> getCollections(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int limit,
|
||||||
|
@RequestParam(required = false) String search,
|
||||||
|
@RequestParam(required = false) List<String> tags,
|
||||||
|
@RequestParam(defaultValue = "false") boolean archived) {
|
||||||
|
|
||||||
|
logger.info("COLLECTIONS: Search request - search='{}', tags={}, archived={}, page={}, limit={}",
|
||||||
|
search, tags, archived, page, limit);
|
||||||
|
|
||||||
|
// MANDATORY: Use Typesense for all search/filter operations
|
||||||
|
SearchResultDto<Collection> results = collectionService.searchCollections(search, tags, archived, page, limit);
|
||||||
|
|
||||||
|
// Convert to lightweight DTOs
|
||||||
|
SearchResultDto<CollectionDto> optimizedResults = new SearchResultDto<>();
|
||||||
|
optimizedResults.setQuery(results.getQuery());
|
||||||
|
optimizedResults.setPage(results.getPage());
|
||||||
|
optimizedResults.setPerPage(results.getPerPage());
|
||||||
|
optimizedResults.setTotalHits(results.getTotalHits());
|
||||||
|
optimizedResults.setSearchTimeMs(results.getSearchTimeMs());
|
||||||
|
|
||||||
|
if (results.getResults() != null) {
|
||||||
|
optimizedResults.setResults(results.getResults().stream()
|
||||||
|
.map(this::mapToCollectionDto)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(optimizedResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/collections/{id} - Get collection with lightweight details (no story content)
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<CollectionDto> getCollectionById(@PathVariable UUID id) {
|
||||||
|
Collection collection = collectionService.findById(id);
|
||||||
|
CollectionDto dto = mapToCollectionDto(collection);
|
||||||
|
return ResponseEntity.ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/collections - Create new collection
|
||||||
|
*/
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<Collection> createCollection(@Valid @RequestBody CreateCollectionRequest request) {
|
||||||
|
Collection collection = collectionService.createCollection(
|
||||||
|
request.getName(),
|
||||||
|
request.getDescription(),
|
||||||
|
request.getTagNames(),
|
||||||
|
request.getStoryIds()
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/collections (multipart) - Create new collection with cover image
|
||||||
|
*/
|
||||||
|
@PostMapping(consumes = "multipart/form-data")
|
||||||
|
public ResponseEntity<Collection> createCollectionWithImage(
|
||||||
|
@RequestParam String name,
|
||||||
|
@RequestParam(required = false) String description,
|
||||||
|
@RequestParam(required = false) List<String> tags,
|
||||||
|
@RequestParam(required = false) List<UUID> storyIds,
|
||||||
|
@RequestParam(required = false, name = "coverImage") MultipartFile coverImage) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create collection first
|
||||||
|
Collection collection = collectionService.createCollection(name, description, tags, storyIds);
|
||||||
|
|
||||||
|
// Upload cover image if provided
|
||||||
|
if (coverImage != null && !coverImage.isEmpty()) {
|
||||||
|
String imagePath = imageService.uploadImage(coverImage, ImageService.ImageType.COVER);
|
||||||
|
collection.setCoverImagePath(imagePath);
|
||||||
|
collection = collectionService.updateCollection(
|
||||||
|
collection.getId(), null, null, null, null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(collection);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to create collection with image", e);
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/collections/{id} - Update collection metadata
|
||||||
|
*/
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<Collection> updateCollection(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@Valid @RequestBody UpdateCollectionRequest request) {
|
||||||
|
|
||||||
|
Collection collection = collectionService.updateCollection(
|
||||||
|
id,
|
||||||
|
request.getName(),
|
||||||
|
request.getDescription(),
|
||||||
|
request.getTagNames(),
|
||||||
|
request.getRating()
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/collections/{id} - Delete collection
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Map<String, String>> deleteCollection(@PathVariable UUID id) {
|
||||||
|
collectionService.deleteCollection(id);
|
||||||
|
return ResponseEntity.ok(Map.of("message", "Collection deleted successfully"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/collections/{id}/archive - Archive/unarchive collection
|
||||||
|
*/
|
||||||
|
@PutMapping("/{id}/archive")
|
||||||
|
public ResponseEntity<Collection> archiveCollection(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@RequestBody ArchiveRequest request) {
|
||||||
|
|
||||||
|
Collection collection = collectionService.archiveCollection(id, request.getArchived());
|
||||||
|
return ResponseEntity.ok(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/collections/{id}/stories - Add stories to collection
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/stories")
|
||||||
|
public ResponseEntity<Map<String, Object>> addStoriesToCollection(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@RequestBody AddStoriesRequest request) {
|
||||||
|
|
||||||
|
Map<String, Object> result = collectionService.addStoriesToCollection(
|
||||||
|
id,
|
||||||
|
request.getStoryIds(),
|
||||||
|
request.getPosition()
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/collections/{id}/stories/{storyId} - Remove story from collection
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{id}/stories/{storyId}")
|
||||||
|
public ResponseEntity<Map<String, String>> removeStoryFromCollection(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@PathVariable UUID storyId) {
|
||||||
|
|
||||||
|
collectionService.removeStoryFromCollection(id, storyId);
|
||||||
|
return ResponseEntity.ok(Map.of("message", "Story removed from collection"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/collections/{id}/stories/order - Reorder stories in collection
|
||||||
|
*/
|
||||||
|
@PutMapping("/{id}/stories/order")
|
||||||
|
public ResponseEntity<Map<String, String>> reorderStories(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@RequestBody ReorderStoriesRequest request) {
|
||||||
|
|
||||||
|
collectionService.reorderStories(id, request.getStoryOrders());
|
||||||
|
return ResponseEntity.ok(Map.of("message", "Stories reordered successfully"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/collections/{id}/read/{storyId} - Get story with collection context
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/read/{storyId}")
|
||||||
|
public ResponseEntity<Map<String, Object>> getStoryWithCollectionContext(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@PathVariable UUID storyId) {
|
||||||
|
|
||||||
|
Map<String, Object> result = collectionService.getStoryWithCollectionContext(id, storyId);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/collections/{id}/stats - Get collection statistics
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/stats")
|
||||||
|
public ResponseEntity<Map<String, Object>> getCollectionStatistics(@PathVariable UUID id) {
|
||||||
|
Map<String, Object> stats = collectionService.getCollectionStatistics(id);
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/collections/{id}/cover - Upload cover image
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/cover")
|
||||||
|
public ResponseEntity<Map<String, Object>> uploadCoverImage(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@RequestParam("file") MultipartFile file) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
String imagePath = imageService.uploadImage(file, ImageService.ImageType.COVER);
|
||||||
|
|
||||||
|
// Update collection with new cover path
|
||||||
|
collectionService.updateCollection(id, null, null, null, null);
|
||||||
|
Collection collection = collectionService.findByIdBasic(id);
|
||||||
|
collection.setCoverImagePath(imagePath);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"message", "Cover uploaded successfully",
|
||||||
|
"coverPath", imagePath,
|
||||||
|
"coverUrl", "/api/files/images/" + imagePath
|
||||||
|
));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to upload collection cover", e);
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/collections/{id}/cover - Remove cover image
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{id}/cover")
|
||||||
|
public ResponseEntity<Map<String, String>> removeCoverImage(@PathVariable UUID id) {
|
||||||
|
Collection collection = collectionService.findByIdBasic(id);
|
||||||
|
collection.setCoverImagePath(null);
|
||||||
|
collectionService.updateCollection(id, null, null, null, null);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of("message", "Cover removed successfully"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapper methods
|
||||||
|
|
||||||
|
private CollectionDto mapToCollectionDto(Collection collection) {
|
||||||
|
CollectionDto dto = new CollectionDto();
|
||||||
|
dto.setId(collection.getId());
|
||||||
|
dto.setName(collection.getName());
|
||||||
|
dto.setDescription(collection.getDescription());
|
||||||
|
dto.setRating(collection.getRating());
|
||||||
|
dto.setCoverImagePath(collection.getCoverImagePath());
|
||||||
|
dto.setIsArchived(collection.getIsArchived());
|
||||||
|
dto.setCreatedAt(collection.getCreatedAt());
|
||||||
|
dto.setUpdatedAt(collection.getUpdatedAt());
|
||||||
|
|
||||||
|
// Map tags
|
||||||
|
if (collection.getTags() != null) {
|
||||||
|
dto.setTags(collection.getTags().stream()
|
||||||
|
.map(this::mapToTagDto)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map collection stories (lightweight)
|
||||||
|
if (collection.getCollectionStories() != null) {
|
||||||
|
dto.setCollectionStories(collection.getCollectionStories().stream()
|
||||||
|
.map(this::mapToCollectionStoryDto)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set calculated properties
|
||||||
|
dto.setStoryCount(collection.getStoryCount());
|
||||||
|
dto.setTotalWordCount(collection.getTotalWordCount());
|
||||||
|
dto.setEstimatedReadingTime(collection.getEstimatedReadingTime());
|
||||||
|
dto.setAverageStoryRating(collection.getAverageStoryRating());
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CollectionStoryDto mapToCollectionStoryDto(CollectionStory collectionStory) {
|
||||||
|
CollectionStoryDto dto = new CollectionStoryDto();
|
||||||
|
dto.setPosition(collectionStory.getPosition());
|
||||||
|
dto.setAddedAt(collectionStory.getAddedAt());
|
||||||
|
dto.setStory(mapToStorySummaryDto(collectionStory.getStory()));
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private StorySummaryDto mapToStorySummaryDto(Story story) {
|
||||||
|
StorySummaryDto dto = new StorySummaryDto();
|
||||||
|
dto.setId(story.getId());
|
||||||
|
dto.setTitle(story.getTitle());
|
||||||
|
dto.setSummary(story.getSummary());
|
||||||
|
dto.setDescription(story.getDescription());
|
||||||
|
dto.setSourceUrl(story.getSourceUrl());
|
||||||
|
dto.setCoverPath(story.getCoverPath());
|
||||||
|
dto.setWordCount(story.getWordCount());
|
||||||
|
dto.setRating(story.getRating());
|
||||||
|
dto.setVolume(story.getVolume());
|
||||||
|
dto.setCreatedAt(story.getCreatedAt());
|
||||||
|
dto.setUpdatedAt(story.getUpdatedAt());
|
||||||
|
dto.setPartOfSeries(story.isPartOfSeries());
|
||||||
|
|
||||||
|
// Map author info
|
||||||
|
if (story.getAuthor() != null) {
|
||||||
|
dto.setAuthorId(story.getAuthor().getId());
|
||||||
|
dto.setAuthorName(story.getAuthor().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map series info
|
||||||
|
if (story.getSeries() != null) {
|
||||||
|
dto.setSeriesId(story.getSeries().getId());
|
||||||
|
dto.setSeriesName(story.getSeries().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map tags
|
||||||
|
if (story.getTags() != null) {
|
||||||
|
dto.setTags(story.getTags().stream()
|
||||||
|
.map(this::mapToTagDto)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TagDto mapToTagDto(Tag tag) {
|
||||||
|
TagDto dto = new TagDto();
|
||||||
|
dto.setId(tag.getId());
|
||||||
|
dto.setName(tag.getName());
|
||||||
|
dto.setCreatedAt(tag.getCreatedAt());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request DTOs
|
||||||
|
|
||||||
|
public static class CreateCollectionRequest {
|
||||||
|
private String name;
|
||||||
|
private String description;
|
||||||
|
private List<String> tagNames;
|
||||||
|
private List<UUID> storyIds;
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
public List<String> getTagNames() { return tagNames; }
|
||||||
|
public void setTagNames(List<String> tagNames) { this.tagNames = tagNames; }
|
||||||
|
public List<UUID> getStoryIds() { return storyIds; }
|
||||||
|
public void setStoryIds(List<UUID> storyIds) { this.storyIds = storyIds; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UpdateCollectionRequest {
|
||||||
|
private String name;
|
||||||
|
private String description;
|
||||||
|
private List<String> tagNames;
|
||||||
|
private Integer rating;
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
public List<String> getTagNames() { return tagNames; }
|
||||||
|
public void setTagNames(List<String> tagNames) { this.tagNames = tagNames; }
|
||||||
|
public Integer getRating() { return rating; }
|
||||||
|
public void setRating(Integer rating) { this.rating = rating; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ArchiveRequest {
|
||||||
|
private Boolean archived;
|
||||||
|
|
||||||
|
public Boolean getArchived() { return archived; }
|
||||||
|
public void setArchived(Boolean archived) { this.archived = archived; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AddStoriesRequest {
|
||||||
|
private List<UUID> storyIds;
|
||||||
|
private Integer position;
|
||||||
|
|
||||||
|
public List<UUID> getStoryIds() { return storyIds; }
|
||||||
|
public void setStoryIds(List<UUID> storyIds) { this.storyIds = storyIds; }
|
||||||
|
public Integer getPosition() { return position; }
|
||||||
|
public void setPosition(Integer position) { this.position = position; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ReorderStoriesRequest {
|
||||||
|
private List<Map<String, Object>> storyOrders;
|
||||||
|
|
||||||
|
public List<Map<String, Object>> getStoryOrders() { return storyOrders; }
|
||||||
|
public void setStoryOrders(List<Map<String, Object>> storyOrders) { this.storyOrders = storyOrders; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.storycove.controller;
|
package com.storycove.controller;
|
||||||
|
|
||||||
import com.storycove.dto.StoryDto;
|
import com.storycove.dto.*;
|
||||||
import com.storycove.dto.TagDto;
|
|
||||||
import com.storycove.entity.Author;
|
import com.storycove.entity.Author;
|
||||||
|
import com.storycove.entity.Collection;
|
||||||
import com.storycove.entity.Series;
|
import com.storycove.entity.Series;
|
||||||
import com.storycove.entity.Story;
|
import com.storycove.entity.Story;
|
||||||
import com.storycove.entity.Tag;
|
import com.storycove.entity.Tag;
|
||||||
@@ -40,23 +40,26 @@ public class StoryController {
|
|||||||
private final HtmlSanitizationService sanitizationService;
|
private final HtmlSanitizationService sanitizationService;
|
||||||
private final ImageService imageService;
|
private final ImageService imageService;
|
||||||
private final TypesenseService typesenseService;
|
private final TypesenseService typesenseService;
|
||||||
|
private final CollectionService collectionService;
|
||||||
|
|
||||||
public StoryController(StoryService storyService,
|
public StoryController(StoryService storyService,
|
||||||
AuthorService authorService,
|
AuthorService authorService,
|
||||||
SeriesService seriesService,
|
SeriesService seriesService,
|
||||||
HtmlSanitizationService sanitizationService,
|
HtmlSanitizationService sanitizationService,
|
||||||
ImageService imageService,
|
ImageService imageService,
|
||||||
|
CollectionService collectionService,
|
||||||
@Autowired(required = false) TypesenseService typesenseService) {
|
@Autowired(required = false) TypesenseService typesenseService) {
|
||||||
this.storyService = storyService;
|
this.storyService = storyService;
|
||||||
this.authorService = authorService;
|
this.authorService = authorService;
|
||||||
this.seriesService = seriesService;
|
this.seriesService = seriesService;
|
||||||
this.sanitizationService = sanitizationService;
|
this.sanitizationService = sanitizationService;
|
||||||
this.imageService = imageService;
|
this.imageService = imageService;
|
||||||
|
this.collectionService = collectionService;
|
||||||
this.typesenseService = typesenseService;
|
this.typesenseService = typesenseService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<Page<StoryDto>> getAllStories(
|
public ResponseEntity<Page<StorySummaryDto>> getAllStories(
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size,
|
@RequestParam(defaultValue = "20") int size,
|
||||||
@RequestParam(defaultValue = "createdAt") String sortBy,
|
@RequestParam(defaultValue = "createdAt") String sortBy,
|
||||||
@@ -67,7 +70,7 @@ public class StoryController {
|
|||||||
|
|
||||||
Pageable pageable = PageRequest.of(page, size, sort);
|
Pageable pageable = PageRequest.of(page, size, sort);
|
||||||
Page<Story> stories = storyService.findAll(pageable);
|
Page<Story> stories = storyService.findAll(pageable);
|
||||||
Page<StoryDto> storyDtos = stories.map(this::convertToDto);
|
Page<StorySummaryDto> storyDtos = stories.map(this::convertToSummaryDto);
|
||||||
|
|
||||||
return ResponseEntity.ok(storyDtos);
|
return ResponseEntity.ok(storyDtos);
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
106
backend/src/main/java/com/storycove/dto/AuthorSummaryDto.java
Normal file
106
backend/src/main/java/com/storycove/dto/AuthorSummaryDto.java
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight Author DTO for listings.
|
||||||
|
* Excludes story collections to reduce payload size.
|
||||||
|
*/
|
||||||
|
public class AuthorSummaryDto {
|
||||||
|
|
||||||
|
private UUID id;
|
||||||
|
private String name;
|
||||||
|
private String notes;
|
||||||
|
private String avatarImagePath;
|
||||||
|
private Integer authorRating;
|
||||||
|
private Double averageStoryRating;
|
||||||
|
private Integer storyCount;
|
||||||
|
private List<String> urls;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public AuthorSummaryDto() {}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNotes() {
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNotes(String notes) {
|
||||||
|
this.notes = notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAvatarImagePath() {
|
||||||
|
return avatarImagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvatarImagePath(String avatarImagePath) {
|
||||||
|
this.avatarImagePath = avatarImagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getAuthorRating() {
|
||||||
|
return authorRating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAuthorRating(Integer authorRating) {
|
||||||
|
this.authorRating = authorRating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Double getAverageStoryRating() {
|
||||||
|
return averageStoryRating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAverageStoryRating(Double averageStoryRating) {
|
||||||
|
this.averageStoryRating = averageStoryRating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getStoryCount() {
|
||||||
|
return storyCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStoryCount(Integer storyCount) {
|
||||||
|
this.storyCount = storyCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getUrls() {
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUrls(List<String> urls) {
|
||||||
|
this.urls = urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
141
backend/src/main/java/com/storycove/dto/CollectionDto.java
Normal file
141
backend/src/main/java/com/storycove/dto/CollectionDto.java
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for Collection with lightweight story references
|
||||||
|
*/
|
||||||
|
public class CollectionDto {
|
||||||
|
|
||||||
|
private UUID id;
|
||||||
|
private String name;
|
||||||
|
private String description;
|
||||||
|
private Integer rating;
|
||||||
|
private String coverImagePath;
|
||||||
|
private Boolean isArchived;
|
||||||
|
private List<TagDto> tags;
|
||||||
|
private List<CollectionStoryDto> collectionStories;
|
||||||
|
private Integer storyCount;
|
||||||
|
private Integer totalWordCount;
|
||||||
|
private Integer estimatedReadingTime;
|
||||||
|
private Double averageStoryRating;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public CollectionDto() {}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getRating() {
|
||||||
|
return rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRating(Integer rating) {
|
||||||
|
this.rating = rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCoverImagePath() {
|
||||||
|
return coverImagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCoverImagePath(String coverImagePath) {
|
||||||
|
this.coverImagePath = coverImagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsArchived() {
|
||||||
|
return isArchived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsArchived(Boolean isArchived) {
|
||||||
|
this.isArchived = isArchived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TagDto> getTags() {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTags(List<TagDto> tags) {
|
||||||
|
this.tags = tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CollectionStoryDto> getCollectionStories() {
|
||||||
|
return collectionStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCollectionStories(List<CollectionStoryDto> collectionStories) {
|
||||||
|
this.collectionStories = collectionStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getStoryCount() {
|
||||||
|
return storyCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStoryCount(Integer storyCount) {
|
||||||
|
this.storyCount = storyCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTotalWordCount() {
|
||||||
|
return totalWordCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalWordCount(Integer totalWordCount) {
|
||||||
|
this.totalWordCount = totalWordCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getEstimatedReadingTime() {
|
||||||
|
return estimatedReadingTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEstimatedReadingTime(Integer estimatedReadingTime) {
|
||||||
|
this.estimatedReadingTime = estimatedReadingTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Double getAverageStoryRating() {
|
||||||
|
return averageStoryRating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAverageStoryRating(Double averageStoryRating) {
|
||||||
|
this.averageStoryRating = averageStoryRating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for CollectionStory with lightweight story reference
|
||||||
|
*/
|
||||||
|
public class CollectionStoryDto {
|
||||||
|
|
||||||
|
private StorySummaryDto story;
|
||||||
|
private Integer position;
|
||||||
|
private LocalDateTime addedAt;
|
||||||
|
|
||||||
|
public CollectionStoryDto() {}
|
||||||
|
|
||||||
|
public CollectionStoryDto(StorySummaryDto story, Integer position, LocalDateTime addedAt) {
|
||||||
|
this.story = story;
|
||||||
|
this.position = position;
|
||||||
|
this.addedAt = addedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public StorySummaryDto getStory() {
|
||||||
|
return story;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStory(StorySummaryDto story) {
|
||||||
|
this.story = story;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPosition() {
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPosition(Integer position) {
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getAddedAt() {
|
||||||
|
return addedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAddedAt(LocalDateTime addedAt) {
|
||||||
|
this.addedAt = addedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
172
backend/src/main/java/com/storycove/dto/StorySummaryDto.java
Normal file
172
backend/src/main/java/com/storycove/dto/StorySummaryDto.java
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight Story DTO for listings and collection views.
|
||||||
|
* Excludes contentHtml and contentPlain to reduce payload size.
|
||||||
|
*/
|
||||||
|
public class StorySummaryDto {
|
||||||
|
|
||||||
|
private UUID id;
|
||||||
|
private String title;
|
||||||
|
private String summary;
|
||||||
|
private String description;
|
||||||
|
private String sourceUrl;
|
||||||
|
private String coverPath;
|
||||||
|
private Integer wordCount;
|
||||||
|
private Integer rating;
|
||||||
|
private Integer volume;
|
||||||
|
|
||||||
|
// Related entities as simple references
|
||||||
|
private UUID authorId;
|
||||||
|
private String authorName;
|
||||||
|
private UUID seriesId;
|
||||||
|
private String seriesName;
|
||||||
|
private List<TagDto> tags;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private boolean partOfSeries;
|
||||||
|
|
||||||
|
public StorySummaryDto() {}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSummary() {
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSummary(String summary) {
|
||||||
|
this.summary = summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSourceUrl() {
|
||||||
|
return sourceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSourceUrl(String sourceUrl) {
|
||||||
|
this.sourceUrl = sourceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCoverPath() {
|
||||||
|
return coverPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCoverPath(String coverPath) {
|
||||||
|
this.coverPath = coverPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getWordCount() {
|
||||||
|
return wordCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWordCount(Integer wordCount) {
|
||||||
|
this.wordCount = wordCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getRating() {
|
||||||
|
return rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRating(Integer rating) {
|
||||||
|
this.rating = rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getVolume() {
|
||||||
|
return volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVolume(Integer volume) {
|
||||||
|
this.volume = volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getAuthorId() {
|
||||||
|
return authorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAuthorId(UUID authorId) {
|
||||||
|
this.authorId = authorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAuthorName() {
|
||||||
|
return authorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAuthorName(String authorName) {
|
||||||
|
this.authorName = authorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getSeriesId() {
|
||||||
|
return seriesId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeriesId(UUID seriesId) {
|
||||||
|
this.seriesId = seriesId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeriesName() {
|
||||||
|
return seriesName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeriesName(String seriesName) {
|
||||||
|
this.seriesName = seriesName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TagDto> getTags() {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTags(List<TagDto> tags) {
|
||||||
|
this.tags = tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPartOfSeries() {
|
||||||
|
return partOfSeries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPartOfSeries(boolean partOfSeries) {
|
||||||
|
this.partOfSeries = partOfSeries;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank;
|
|||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -40,6 +41,7 @@ public class Author {
|
|||||||
private List<String> urls = new ArrayList<>();
|
private List<String> urls = new ArrayList<>();
|
||||||
|
|
||||||
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||||
|
@JsonManagedReference("author-stories")
|
||||||
private List<Story> stories = new ArrayList<>();
|
private List<Story> stories = new ArrayList<>();
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
|
|||||||
233
backend/src/main/java/com/storycove/entity/Collection.java
Normal file
233
backend/src/main/java/com/storycove/entity/Collection.java
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
package com.storycove.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "collections")
|
||||||
|
public class Collection {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@NotBlank(message = "Collection name is required")
|
||||||
|
@Size(max = 500, message = "Collection name must not exceed 500 characters")
|
||||||
|
@Column(nullable = false, length = 500)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "rating")
|
||||||
|
private Integer rating;
|
||||||
|
|
||||||
|
@Column(name = "cover_image_path", length = 500)
|
||||||
|
private String coverImagePath;
|
||||||
|
|
||||||
|
@Column(name = "is_archived", nullable = false)
|
||||||
|
private Boolean isArchived = false;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "collection", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@OrderBy("position ASC")
|
||||||
|
@JsonManagedReference("collection-stories")
|
||||||
|
private List<CollectionStory> collectionStories = new ArrayList<>();
|
||||||
|
|
||||||
|
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
|
||||||
|
@JoinTable(
|
||||||
|
name = "collection_tags",
|
||||||
|
joinColumns = @JoinColumn(name = "collection_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "tag_id")
|
||||||
|
)
|
||||||
|
private Set<Tag> tags = new HashSet<>();
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public Collection() {}
|
||||||
|
|
||||||
|
public Collection(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection(String name, String description) {
|
||||||
|
this.name = name;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for managing collection stories
|
||||||
|
public void addStory(Story story, int position) {
|
||||||
|
CollectionStory collectionStory = new CollectionStory();
|
||||||
|
collectionStory.setCollection(this);
|
||||||
|
collectionStory.setStory(story);
|
||||||
|
collectionStory.setPosition(position);
|
||||||
|
collectionStories.add(collectionStory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeStory(UUID storyId) {
|
||||||
|
collectionStories.removeIf(cs -> cs.getStory().getId().equals(storyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reorderStories(List<UUID> storyIds) {
|
||||||
|
for (int i = 0; i < storyIds.size(); i++) {
|
||||||
|
UUID storyId = storyIds.get(i);
|
||||||
|
final int position = (i + 1) * 1000; // Gap-based positioning
|
||||||
|
collectionStories.stream()
|
||||||
|
.filter(cs -> cs.getStory().getId().equals(storyId))
|
||||||
|
.findFirst()
|
||||||
|
.ifPresent(cs -> cs.setPosition(position));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addTag(Tag tag) {
|
||||||
|
tags.add(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeTag(Tag tag) {
|
||||||
|
tags.remove(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculated properties
|
||||||
|
public int getStoryCount() {
|
||||||
|
return collectionStories.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTotalWordCount() {
|
||||||
|
return collectionStories.stream()
|
||||||
|
.mapToInt(cs -> cs.getStory().getWordCount() != null ? cs.getStory().getWordCount() : 0)
|
||||||
|
.sum();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getEstimatedReadingTime() {
|
||||||
|
// Assuming 200 words per minute reading speed
|
||||||
|
return Math.max(1, getTotalWordCount() / 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Double getAverageStoryRating() {
|
||||||
|
return collectionStories.stream()
|
||||||
|
.filter(cs -> cs.getStory().getRating() != null)
|
||||||
|
.mapToInt(cs -> cs.getStory().getRating())
|
||||||
|
.average()
|
||||||
|
.orElse(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getRating() {
|
||||||
|
return rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRating(Integer rating) {
|
||||||
|
this.rating = rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCoverImagePath() {
|
||||||
|
return coverImagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCoverImagePath(String coverImagePath) {
|
||||||
|
this.coverImagePath = coverImagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsArchived() {
|
||||||
|
return isArchived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsArchived(Boolean isArchived) {
|
||||||
|
this.isArchived = isArchived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CollectionStory> getCollectionStories() {
|
||||||
|
return collectionStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCollectionStories(List<CollectionStory> collectionStories) {
|
||||||
|
this.collectionStories = collectionStories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<Tag> getTags() {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTags(Set<Tag> tags) {
|
||||||
|
this.tags = tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof Collection)) return false;
|
||||||
|
Collection collection = (Collection) o;
|
||||||
|
return id != null && id.equals(collection.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return getClass().hashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "Collection{" +
|
||||||
|
"id=" + id +
|
||||||
|
", name='" + name + '\'' +
|
||||||
|
", storyCount=" + getStoryCount() +
|
||||||
|
", isArchived=" + isArchived +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
114
backend/src/main/java/com/storycove/entity/CollectionStory.java
Normal file
114
backend/src/main/java/com/storycove/entity/CollectionStory.java
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package com.storycove.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "collection_stories",
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(columnNames = {"collection_id", "position"})
|
||||||
|
})
|
||||||
|
public class CollectionStory {
|
||||||
|
|
||||||
|
@EmbeddedId
|
||||||
|
private CollectionStoryId id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@MapsId("collectionId")
|
||||||
|
@JoinColumn(name = "collection_id")
|
||||||
|
@JsonBackReference("collection-stories")
|
||||||
|
private Collection collection;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@MapsId("storyId")
|
||||||
|
@JoinColumn(name = "story_id")
|
||||||
|
private Story story;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Integer position;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "added_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime addedAt;
|
||||||
|
|
||||||
|
public CollectionStory() {}
|
||||||
|
|
||||||
|
public CollectionStory(Collection collection, Story story, Integer position) {
|
||||||
|
this.id = new CollectionStoryId(collection.getId(), story.getId());
|
||||||
|
this.collection = collection;
|
||||||
|
this.story = story;
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public CollectionStoryId getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(CollectionStoryId id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection getCollection() {
|
||||||
|
return collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCollection(Collection collection) {
|
||||||
|
this.collection = collection;
|
||||||
|
if (this.story != null) {
|
||||||
|
this.id = new CollectionStoryId(collection.getId(), this.story.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Story getStory() {
|
||||||
|
return story;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStory(Story story) {
|
||||||
|
this.story = story;
|
||||||
|
if (this.collection != null) {
|
||||||
|
this.id = new CollectionStoryId(this.collection.getId(), story.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPosition() {
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPosition(Integer position) {
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getAddedAt() {
|
||||||
|
return addedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAddedAt(LocalDateTime addedAt) {
|
||||||
|
this.addedAt = addedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof CollectionStory)) return false;
|
||||||
|
CollectionStory that = (CollectionStory) o;
|
||||||
|
return id != null && id.equals(that.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return getClass().hashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "CollectionStory{" +
|
||||||
|
"collectionId=" + (collection != null ? collection.getId() : null) +
|
||||||
|
", storyId=" + (story != null ? story.getId() : null) +
|
||||||
|
", position=" + position +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.storycove.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Embeddable;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Embeddable
|
||||||
|
public class CollectionStoryId implements java.io.Serializable {
|
||||||
|
|
||||||
|
@Column(name = "collection_id")
|
||||||
|
private UUID collectionId;
|
||||||
|
|
||||||
|
@Column(name = "story_id")
|
||||||
|
private UUID storyId;
|
||||||
|
|
||||||
|
public CollectionStoryId() {}
|
||||||
|
|
||||||
|
public CollectionStoryId(UUID collectionId, UUID storyId) {
|
||||||
|
this.collectionId = collectionId;
|
||||||
|
this.storyId = storyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public UUID getCollectionId() {
|
||||||
|
return collectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCollectionId(UUID collectionId) {
|
||||||
|
this.collectionId = collectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getStoryId() {
|
||||||
|
return storyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStoryId(UUID storyId) {
|
||||||
|
this.storyId = storyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof CollectionStoryId)) return false;
|
||||||
|
CollectionStoryId that = (CollectionStoryId) o;
|
||||||
|
return collectionId != null && collectionId.equals(that.collectionId) &&
|
||||||
|
storyId != null && storyId.equals(that.storyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return java.util.Objects.hash(collectionId, storyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "CollectionStoryId{" +
|
||||||
|
"collectionId=" + collectionId +
|
||||||
|
", storyId=" + storyId +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import jakarta.persistence.*;
|
|||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -29,6 +30,7 @@ public class Series {
|
|||||||
|
|
||||||
@OneToMany(mappedBy = "series", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
@OneToMany(mappedBy = "series", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||||
@OrderBy("volume ASC")
|
@OrderBy("volume ASC")
|
||||||
|
@JsonManagedReference("series-stories")
|
||||||
private List<Story> stories = new ArrayList<>();
|
private List<Story> stories = new ArrayList<>();
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import jakarta.validation.constraints.Size;
|
|||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
import org.jsoup.Jsoup;
|
import org.jsoup.Jsoup;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -55,10 +57,12 @@ public class Story {
|
|||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "author_id")
|
@JoinColumn(name = "author_id")
|
||||||
|
@JsonBackReference("author-stories")
|
||||||
private Author author;
|
private Author author;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "series_id")
|
@JoinColumn(name = "series_id")
|
||||||
|
@JsonBackReference("series-stories")
|
||||||
private Series series;
|
private Series series;
|
||||||
|
|
||||||
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
|
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
|
||||||
@@ -67,6 +71,7 @@ public class Story {
|
|||||||
joinColumns = @JoinColumn(name = "story_id"),
|
joinColumns = @JoinColumn(name = "story_id"),
|
||||||
inverseJoinColumns = @JoinColumn(name = "tag_id")
|
inverseJoinColumns = @JoinColumn(name = "tag_id")
|
||||||
)
|
)
|
||||||
|
@JsonManagedReference("story-tags")
|
||||||
private Set<Tag> tags = new HashSet<>();
|
private Set<Tag> tags = new HashSet<>();
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import jakarta.persistence.*;
|
|||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -25,6 +26,7 @@ public class Tag {
|
|||||||
|
|
||||||
|
|
||||||
@ManyToMany(mappedBy = "tags")
|
@ManyToMany(mappedBy = "tags")
|
||||||
|
@JsonBackReference("story-tags")
|
||||||
private Set<Story> stories = new HashSet<>();
|
private Set<Story> stories = new HashSet<>();
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.storycove.repository;
|
||||||
|
|
||||||
|
import com.storycove.entity.Collection;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface CollectionRepository extends JpaRepository<Collection, UUID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find collection by ID with tags eagerly loaded
|
||||||
|
* Used for detailed collection retrieval
|
||||||
|
*/
|
||||||
|
@Query("SELECT c FROM Collection c LEFT JOIN FETCH c.tags WHERE c.id = :id")
|
||||||
|
Optional<Collection> findByIdWithTags(@Param("id") UUID id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find collection by ID with full story details
|
||||||
|
* Used for collection detail view with story list
|
||||||
|
*/
|
||||||
|
@Query("SELECT c FROM Collection c " +
|
||||||
|
"LEFT JOIN FETCH c.collectionStories cs " +
|
||||||
|
"LEFT JOIN FETCH cs.story s " +
|
||||||
|
"LEFT JOIN FETCH s.author " +
|
||||||
|
"LEFT JOIN FETCH c.tags " +
|
||||||
|
"WHERE c.id = :id " +
|
||||||
|
"ORDER BY cs.position ASC")
|
||||||
|
Optional<Collection> findByIdWithStoriesAndTags(@Param("id") UUID id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count all collections for statistics
|
||||||
|
*/
|
||||||
|
long countByIsArchivedFalse();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all collections with basic info (for batch operations)
|
||||||
|
* NOTE: This method should only be used for operations that require all collections
|
||||||
|
* For search/filter/list operations, use TypesenseService instead
|
||||||
|
*/
|
||||||
|
@Query("SELECT c FROM Collection c WHERE c.isArchived = false ORDER BY c.updatedAt DESC")
|
||||||
|
List<Collection> findAllActiveCollections();
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.storycove.repository;
|
||||||
|
|
||||||
|
import com.storycove.entity.CollectionStory;
|
||||||
|
import com.storycove.entity.CollectionStoryId;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface CollectionStoryRepository extends JpaRepository<CollectionStory, CollectionStoryId> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all stories in a collection ordered by position
|
||||||
|
*/
|
||||||
|
@Query("SELECT cs FROM CollectionStory cs " +
|
||||||
|
"LEFT JOIN FETCH cs.story s " +
|
||||||
|
"LEFT JOIN FETCH s.author " +
|
||||||
|
"WHERE cs.collection.id = :collectionId " +
|
||||||
|
"ORDER BY cs.position ASC")
|
||||||
|
List<CollectionStory> findByCollectionIdOrderByPosition(@Param("collectionId") UUID collectionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find story by collection and story ID
|
||||||
|
*/
|
||||||
|
@Query("SELECT cs FROM CollectionStory cs " +
|
||||||
|
"WHERE cs.collection.id = :collectionId AND cs.story.id = :storyId")
|
||||||
|
CollectionStory findByCollectionIdAndStoryId(@Param("collectionId") UUID collectionId, @Param("storyId") UUID storyId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get next available position in collection
|
||||||
|
*/
|
||||||
|
@Query("SELECT COALESCE(MAX(cs.position), 0) + 1000 FROM CollectionStory cs WHERE cs.collection.id = :collectionId")
|
||||||
|
Integer getNextPosition(@Param("collectionId") UUID collectionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all stories from a collection (used when deleting collection)
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("DELETE FROM CollectionStory cs WHERE cs.collection.id = :collectionId")
|
||||||
|
void deleteByCollectionId(@Param("collectionId") UUID collectionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update positions for stories in a collection
|
||||||
|
* Used for bulk position updates during reordering
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE CollectionStory cs SET cs.position = :position " +
|
||||||
|
"WHERE cs.collection.id = :collectionId AND cs.story.id = :storyId")
|
||||||
|
void updatePosition(@Param("collectionId") UUID collectionId,
|
||||||
|
@Param("storyId") UUID storyId,
|
||||||
|
@Param("position") Integer position);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a story already exists in a collection
|
||||||
|
*/
|
||||||
|
boolean existsByCollectionIdAndStoryId(UUID collectionId, UUID storyId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count stories in a collection
|
||||||
|
*/
|
||||||
|
long countByCollectionId(UUID collectionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all collections that contain a specific story
|
||||||
|
*/
|
||||||
|
@Query("SELECT cs FROM CollectionStory cs " +
|
||||||
|
"LEFT JOIN FETCH cs.collection c " +
|
||||||
|
"WHERE cs.story.id = :storyId " +
|
||||||
|
"ORDER BY c.name ASC")
|
||||||
|
List<CollectionStory> findByStoryId(@Param("storyId") UUID storyId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find previous and next stories for reading navigation
|
||||||
|
*/
|
||||||
|
@Query("SELECT cs FROM CollectionStory cs " +
|
||||||
|
"WHERE cs.collection.id = :collectionId " +
|
||||||
|
"AND cs.position < (SELECT current.position FROM CollectionStory current " +
|
||||||
|
" WHERE current.collection.id = :collectionId AND current.story.id = :currentStoryId) " +
|
||||||
|
"ORDER BY cs.position DESC")
|
||||||
|
List<CollectionStory> findPreviousStory(@Param("collectionId") UUID collectionId, @Param("currentStoryId") UUID currentStoryId);
|
||||||
|
|
||||||
|
@Query("SELECT cs FROM CollectionStory cs " +
|
||||||
|
"WHERE cs.collection.id = :collectionId " +
|
||||||
|
"AND cs.position > (SELECT current.position FROM CollectionStory current " +
|
||||||
|
" WHERE current.collection.id = :collectionId AND current.story.id = :currentStoryId) " +
|
||||||
|
"ORDER BY cs.position ASC")
|
||||||
|
List<CollectionStory> findNextStory(@Param("collectionId") UUID collectionId, @Param("currentStoryId") UUID currentStoryId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.storycove.service;
|
||||||
|
|
||||||
|
import com.storycove.entity.Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Special Collection subclass for search results that provides pre-calculated statistics
|
||||||
|
* to avoid lazy loading issues when displaying collection lists.
|
||||||
|
*/
|
||||||
|
public class CollectionSearchResult extends Collection {
|
||||||
|
|
||||||
|
private Integer storedStoryCount;
|
||||||
|
private Integer storedTotalWordCount;
|
||||||
|
|
||||||
|
public CollectionSearchResult(Collection collection) {
|
||||||
|
this.setId(collection.getId());
|
||||||
|
this.setName(collection.getName());
|
||||||
|
this.setDescription(collection.getDescription());
|
||||||
|
this.setRating(collection.getRating());
|
||||||
|
this.setIsArchived(collection.getIsArchived());
|
||||||
|
this.setCreatedAt(collection.getCreatedAt());
|
||||||
|
this.setUpdatedAt(collection.getUpdatedAt());
|
||||||
|
this.setCoverImagePath(collection.getCoverImagePath());
|
||||||
|
// Note: don't copy collectionStories or tags to avoid lazy loading issues
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStoredStoryCount(Integer storyCount) {
|
||||||
|
this.storedStoryCount = storyCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStoredTotalWordCount(Integer totalWordCount) {
|
||||||
|
this.storedTotalWordCount = totalWordCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getStoryCount() {
|
||||||
|
return storedStoryCount != null ? storedStoryCount : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getTotalWordCount() {
|
||||||
|
return storedTotalWordCount != null ? storedTotalWordCount : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getEstimatedReadingTime() {
|
||||||
|
// Assuming 200 words per minute reading speed
|
||||||
|
return Math.max(1, getTotalWordCount() / 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Double getAverageStoryRating() {
|
||||||
|
// For search results, we don't calculate average rating to avoid complexity
|
||||||
|
// This would require loading all stories. Can be enhanced later if needed.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
package com.storycove.service;
|
||||||
|
|
||||||
|
import com.storycove.dto.SearchResultDto;
|
||||||
|
import com.storycove.entity.Collection;
|
||||||
|
import com.storycove.entity.CollectionStory;
|
||||||
|
import com.storycove.entity.Story;
|
||||||
|
import com.storycove.entity.Tag;
|
||||||
|
import com.storycove.repository.CollectionRepository;
|
||||||
|
import com.storycove.repository.CollectionStoryRepository;
|
||||||
|
import com.storycove.repository.StoryRepository;
|
||||||
|
import com.storycove.repository.TagRepository;
|
||||||
|
import com.storycove.service.exception.DuplicateResourceException;
|
||||||
|
import com.storycove.service.exception.ResourceNotFoundException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class CollectionService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(CollectionService.class);
|
||||||
|
|
||||||
|
private final CollectionRepository collectionRepository;
|
||||||
|
private final CollectionStoryRepository collectionStoryRepository;
|
||||||
|
private final StoryRepository storyRepository;
|
||||||
|
private final TagRepository tagRepository;
|
||||||
|
private final TypesenseService typesenseService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public CollectionService(CollectionRepository collectionRepository,
|
||||||
|
CollectionStoryRepository collectionStoryRepository,
|
||||||
|
StoryRepository storyRepository,
|
||||||
|
TagRepository tagRepository,
|
||||||
|
@Autowired(required = false) TypesenseService typesenseService) {
|
||||||
|
this.collectionRepository = collectionRepository;
|
||||||
|
this.collectionStoryRepository = collectionStoryRepository;
|
||||||
|
this.storyRepository = storyRepository;
|
||||||
|
this.tagRepository = tagRepository;
|
||||||
|
this.typesenseService = typesenseService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search collections using Typesense (MANDATORY for all search/filter operations)
|
||||||
|
* This method MUST be used instead of JPA queries for listing collections
|
||||||
|
*/
|
||||||
|
public SearchResultDto<Collection> searchCollections(String query, List<String> tags, boolean includeArchived, int page, int limit) {
|
||||||
|
if (typesenseService == null) {
|
||||||
|
logger.warn("Typesense service not available, returning empty results");
|
||||||
|
return new SearchResultDto<>(new ArrayList<>(), 0, page, limit, query != null ? query : "", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate to TypesenseService for all search operations
|
||||||
|
return typesenseService.searchCollections(query, tags, includeArchived, page, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find collection by ID with full details
|
||||||
|
*/
|
||||||
|
public Collection findById(UUID id) {
|
||||||
|
return collectionRepository.findByIdWithStoriesAndTags(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Collection not found with id: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find collection by ID with basic info only
|
||||||
|
*/
|
||||||
|
public Collection findByIdBasic(UUID id) {
|
||||||
|
return collectionRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Collection not found with id: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new collection with optional initial stories
|
||||||
|
*/
|
||||||
|
public Collection createCollection(String name, String description, List<String> tagNames, List<UUID> initialStoryIds) {
|
||||||
|
Collection collection = new Collection(name, description);
|
||||||
|
|
||||||
|
// Add tags if provided
|
||||||
|
if (tagNames != null && !tagNames.isEmpty()) {
|
||||||
|
Set<Tag> tags = findOrCreateTags(tagNames);
|
||||||
|
collection.setTags(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
Collection savedCollection = collectionRepository.save(collection);
|
||||||
|
|
||||||
|
// Add initial stories if provided
|
||||||
|
if (initialStoryIds != null && !initialStoryIds.isEmpty()) {
|
||||||
|
addStoriesToCollection(savedCollection.getId(), initialStoryIds, null);
|
||||||
|
// Reload to get updated collection with stories
|
||||||
|
savedCollection = findById(savedCollection.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index in Typesense
|
||||||
|
if (typesenseService != null) {
|
||||||
|
typesenseService.indexCollection(savedCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Created collection: {} with {} stories", name, initialStoryIds != null ? initialStoryIds.size() : 0);
|
||||||
|
return savedCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update collection metadata
|
||||||
|
*/
|
||||||
|
public Collection updateCollection(UUID id, String name, String description, List<String> tagNames, Integer rating) {
|
||||||
|
Collection collection = findByIdBasic(id);
|
||||||
|
|
||||||
|
if (name != null) {
|
||||||
|
collection.setName(name);
|
||||||
|
}
|
||||||
|
if (description != null) {
|
||||||
|
collection.setDescription(description);
|
||||||
|
}
|
||||||
|
if (rating != null) {
|
||||||
|
collection.setRating(rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tags if provided
|
||||||
|
if (tagNames != null) {
|
||||||
|
Set<Tag> tags = findOrCreateTags(tagNames);
|
||||||
|
collection.setTags(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
Collection savedCollection = collectionRepository.save(collection);
|
||||||
|
|
||||||
|
// Update in Typesense
|
||||||
|
if (typesenseService != null) {
|
||||||
|
typesenseService.indexCollection(savedCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Updated collection: {}", id);
|
||||||
|
return savedCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a collection (stories remain in the system)
|
||||||
|
*/
|
||||||
|
public void deleteCollection(UUID id) {
|
||||||
|
Collection collection = findByIdBasic(id);
|
||||||
|
|
||||||
|
// Remove from Typesense first
|
||||||
|
if (typesenseService != null) {
|
||||||
|
typesenseService.removeCollection(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionRepository.delete(collection);
|
||||||
|
logger.info("Deleted collection: {}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive or unarchive a collection
|
||||||
|
*/
|
||||||
|
public Collection archiveCollection(UUID id, boolean archived) {
|
||||||
|
Collection collection = findByIdBasic(id);
|
||||||
|
collection.setIsArchived(archived);
|
||||||
|
|
||||||
|
Collection savedCollection = collectionRepository.save(collection);
|
||||||
|
|
||||||
|
// Update in Typesense
|
||||||
|
if (typesenseService != null) {
|
||||||
|
typesenseService.indexCollection(savedCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("{} collection: {}", archived ? "Archived" : "Unarchived", id);
|
||||||
|
return savedCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add stories to a collection
|
||||||
|
*/
|
||||||
|
public Map<String, Object> addStoriesToCollection(UUID collectionId, List<UUID> storyIds, Integer startPosition) {
|
||||||
|
Collection collection = findByIdBasic(collectionId);
|
||||||
|
|
||||||
|
// Validate stories exist
|
||||||
|
List<Story> stories = storyRepository.findAllById(storyIds);
|
||||||
|
if (stories.size() != storyIds.size()) {
|
||||||
|
throw new ResourceNotFoundException("One or more stories not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
int added = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
|
// Get starting position
|
||||||
|
int position = startPosition != null ? startPosition : collectionStoryRepository.getNextPosition(collectionId);
|
||||||
|
|
||||||
|
for (UUID storyId : storyIds) {
|
||||||
|
// Check if story is already in collection
|
||||||
|
if (collectionStoryRepository.existsByCollectionIdAndStoryId(collectionId, storyId)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add story to collection
|
||||||
|
Story story = stories.stream()
|
||||||
|
.filter(s -> s.getId().equals(storyId))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
|
||||||
|
CollectionStory collectionStory = new CollectionStory(collection, story, position);
|
||||||
|
collectionStoryRepository.save(collectionStory);
|
||||||
|
|
||||||
|
added++;
|
||||||
|
position += 1000; // Gap-based positioning
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update collection in Typesense
|
||||||
|
if (typesenseService != null) {
|
||||||
|
Collection updatedCollection = findById(collectionId);
|
||||||
|
typesenseService.indexCollection(updatedCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
long totalStories = collectionStoryRepository.countByCollectionId(collectionId);
|
||||||
|
|
||||||
|
logger.info("Added {} stories to collection {}, skipped {} duplicates", added, collectionId, skipped);
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"added", added,
|
||||||
|
"skipped", skipped,
|
||||||
|
"totalStories", totalStories
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a story from a collection
|
||||||
|
*/
|
||||||
|
public void removeStoryFromCollection(UUID collectionId, UUID storyId) {
|
||||||
|
if (!collectionStoryRepository.existsByCollectionIdAndStoryId(collectionId, storyId)) {
|
||||||
|
throw new ResourceNotFoundException("Story not found in collection");
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectionStory collectionStory = collectionStoryRepository.findByCollectionIdAndStoryId(collectionId, storyId);
|
||||||
|
collectionStoryRepository.delete(collectionStory);
|
||||||
|
|
||||||
|
// Update collection in Typesense
|
||||||
|
if (typesenseService != null) {
|
||||||
|
Collection updatedCollection = findById(collectionId);
|
||||||
|
typesenseService.indexCollection(updatedCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Removed story {} from collection {}", storyId, collectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder stories in a collection
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void reorderStories(UUID collectionId, List<Map<String, Object>> storyOrders) {
|
||||||
|
Collection collection = findByIdBasic(collectionId);
|
||||||
|
|
||||||
|
// Two-phase update to avoid unique constraint violations:
|
||||||
|
// Phase 1: Set all positions to negative values (temporary)
|
||||||
|
logger.debug("Phase 1: Setting temporary negative positions for collection {}", collectionId);
|
||||||
|
for (int i = 0; i < storyOrders.size(); i++) {
|
||||||
|
Map<String, Object> order = storyOrders.get(i);
|
||||||
|
UUID storyId = UUID.fromString(String.valueOf(order.get("storyId")));
|
||||||
|
|
||||||
|
// Set temporary negative position to avoid conflicts
|
||||||
|
collectionStoryRepository.updatePosition(collectionId, storyId, -(i + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Set final positions
|
||||||
|
logger.debug("Phase 2: Setting final positions for collection {}", collectionId);
|
||||||
|
for (Map<String, Object> order : storyOrders) {
|
||||||
|
UUID storyId = UUID.fromString(String.valueOf(order.get("storyId")));
|
||||||
|
Integer position = (Integer) order.get("position");
|
||||||
|
|
||||||
|
collectionStoryRepository.updatePosition(collectionId, storyId, position * 1000); // Gap-based positioning
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update collection in Typesense
|
||||||
|
if (typesenseService != null) {
|
||||||
|
Collection updatedCollection = findById(collectionId);
|
||||||
|
typesenseService.indexCollection(updatedCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Reordered {} stories in collection {}", storyOrders.size(), collectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get story with collection reading context
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getStoryWithCollectionContext(UUID collectionId, UUID storyId) {
|
||||||
|
Collection collection = findByIdBasic(collectionId);
|
||||||
|
Story story = storyRepository.findById(storyId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Story not found: " + storyId));
|
||||||
|
|
||||||
|
// Find current position
|
||||||
|
CollectionStory currentStory = collectionStoryRepository.findByCollectionIdAndStoryId(collectionId, storyId);
|
||||||
|
if (currentStory == null) {
|
||||||
|
throw new ResourceNotFoundException("Story not found in collection");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find previous and next stories
|
||||||
|
List<CollectionStory> previousStories = collectionStoryRepository.findPreviousStory(collectionId, storyId);
|
||||||
|
List<CollectionStory> nextStories = collectionStoryRepository.findNextStory(collectionId, storyId);
|
||||||
|
|
||||||
|
UUID previousStoryId = previousStories.isEmpty() ? null : previousStories.get(0).getStory().getId();
|
||||||
|
UUID nextStoryId = nextStories.isEmpty() ? null : nextStories.get(0).getStory().getId();
|
||||||
|
|
||||||
|
// Get current position in collection
|
||||||
|
List<CollectionStory> allStories = collectionStoryRepository.findByCollectionIdOrderByPosition(collectionId);
|
||||||
|
int currentPosition = 0;
|
||||||
|
for (int i = 0; i < allStories.size(); i++) {
|
||||||
|
if (allStories.get(i).getStory().getId().equals(storyId)) {
|
||||||
|
currentPosition = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> collectionContext = Map.of(
|
||||||
|
"id", collection.getId(),
|
||||||
|
"name", collection.getName(),
|
||||||
|
"currentPosition", currentPosition,
|
||||||
|
"totalStories", allStories.size(),
|
||||||
|
"previousStoryId", previousStoryId != null ? previousStoryId : "",
|
||||||
|
"nextStoryId", nextStoryId != null ? nextStoryId : ""
|
||||||
|
);
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"story", story,
|
||||||
|
"collection", collectionContext
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get collection statistics
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getCollectionStatistics(UUID collectionId) {
|
||||||
|
Collection collection = findById(collectionId);
|
||||||
|
|
||||||
|
List<CollectionStory> collectionStories = collection.getCollectionStories();
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
int totalStories = collectionStories.size();
|
||||||
|
int totalWordCount = collectionStories.stream()
|
||||||
|
.mapToInt(cs -> cs.getStory().getWordCount() != null ? cs.getStory().getWordCount() : 0)
|
||||||
|
.sum();
|
||||||
|
int estimatedReadingTime = Math.max(1, totalWordCount / 200); // 200 words per minute
|
||||||
|
|
||||||
|
double averageStoryRating = collectionStories.stream()
|
||||||
|
.filter(cs -> cs.getStory().getRating() != null)
|
||||||
|
.mapToInt(cs -> cs.getStory().getRating())
|
||||||
|
.average()
|
||||||
|
.orElse(0.0);
|
||||||
|
|
||||||
|
double averageWordCount = totalStories > 0 ? (double) totalWordCount / totalStories : 0.0;
|
||||||
|
|
||||||
|
// Tag frequency
|
||||||
|
Map<String, Long> tagFrequency = collectionStories.stream()
|
||||||
|
.flatMap(cs -> cs.getStory().getTags().stream())
|
||||||
|
.collect(Collectors.groupingBy(Tag::getName, Collectors.counting()));
|
||||||
|
|
||||||
|
// Author distribution
|
||||||
|
List<Map<String, Object>> authorDistribution = collectionStories.stream()
|
||||||
|
.filter(cs -> cs.getStory().getAuthor() != null)
|
||||||
|
.collect(Collectors.groupingBy(cs -> cs.getStory().getAuthor().getName(), Collectors.counting()))
|
||||||
|
.entrySet().stream()
|
||||||
|
.map(entry -> Map.<String, Object>of(
|
||||||
|
"authorName", entry.getKey(),
|
||||||
|
"storyCount", entry.getValue()
|
||||||
|
))
|
||||||
|
.sorted((a, b) -> Long.compare((Long) b.get("storyCount"), (Long) a.get("storyCount")))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"totalStories", totalStories,
|
||||||
|
"totalWordCount", totalWordCount,
|
||||||
|
"estimatedReadingTime", estimatedReadingTime,
|
||||||
|
"averageStoryRating", Math.round(averageStoryRating * 100.0) / 100.0,
|
||||||
|
"averageWordCount", Math.round(averageWordCount),
|
||||||
|
"tagFrequency", tagFrequency,
|
||||||
|
"authorDistribution", authorDistribution
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find or create tags by names
|
||||||
|
*/
|
||||||
|
private Set<Tag> findOrCreateTags(List<String> tagNames) {
|
||||||
|
Set<Tag> tags = new HashSet<>();
|
||||||
|
|
||||||
|
for (String tagName : tagNames) {
|
||||||
|
String trimmedName = tagName.trim();
|
||||||
|
if (!trimmedName.isEmpty()) {
|
||||||
|
Tag tag = tagRepository.findByName(trimmedName)
|
||||||
|
.orElseGet(() -> {
|
||||||
|
Tag newTag = new Tag();
|
||||||
|
newTag.setName(trimmedName);
|
||||||
|
return tagRepository.save(newTag);
|
||||||
|
});
|
||||||
|
tags.add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get collections that contain a specific story
|
||||||
|
*/
|
||||||
|
public List<Collection> getCollectionsForStory(UUID storyId) {
|
||||||
|
List<CollectionStory> collectionStories = collectionStoryRepository.findByStoryId(storyId);
|
||||||
|
return collectionStories.stream()
|
||||||
|
.map(CollectionStory::getCollection)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all collections for indexing (used by TypesenseService)
|
||||||
|
*/
|
||||||
|
public List<Collection> findAllForIndexing() {
|
||||||
|
return collectionRepository.findAllActiveCollections();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ import com.storycove.dto.AuthorSearchDto;
|
|||||||
import com.storycove.dto.SearchResultDto;
|
import com.storycove.dto.SearchResultDto;
|
||||||
import com.storycove.dto.StorySearchDto;
|
import com.storycove.dto.StorySearchDto;
|
||||||
import com.storycove.entity.Author;
|
import com.storycove.entity.Author;
|
||||||
|
import com.storycove.entity.Collection;
|
||||||
|
import com.storycove.entity.CollectionStory;
|
||||||
import com.storycove.entity.Story;
|
import com.storycove.entity.Story;
|
||||||
|
import com.storycove.repository.CollectionStoryRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -14,6 +17,7 @@ import org.typesense.api.Client;
|
|||||||
import org.typesense.model.*;
|
import org.typesense.model.*;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -24,12 +28,16 @@ public class TypesenseService {
|
|||||||
private static final Logger logger = LoggerFactory.getLogger(TypesenseService.class);
|
private static final Logger logger = LoggerFactory.getLogger(TypesenseService.class);
|
||||||
private static final String STORIES_COLLECTION = "stories";
|
private static final String STORIES_COLLECTION = "stories";
|
||||||
private static final String AUTHORS_COLLECTION = "authors";
|
private static final String AUTHORS_COLLECTION = "authors";
|
||||||
|
private static final String COLLECTIONS_COLLECTION = "collections";
|
||||||
|
|
||||||
private final Client typesenseClient;
|
private final Client typesenseClient;
|
||||||
|
private final CollectionStoryRepository collectionStoryRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public TypesenseService(Client typesenseClient) {
|
public TypesenseService(Client typesenseClient,
|
||||||
|
@Autowired(required = false) CollectionStoryRepository collectionStoryRepository) {
|
||||||
this.typesenseClient = typesenseClient;
|
this.typesenseClient = typesenseClient;
|
||||||
|
this.collectionStoryRepository = collectionStoryRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
@@ -37,6 +45,7 @@ public class TypesenseService {
|
|||||||
try {
|
try {
|
||||||
createStoriesCollectionIfNotExists();
|
createStoriesCollectionIfNotExists();
|
||||||
createAuthorsCollectionIfNotExists();
|
createAuthorsCollectionIfNotExists();
|
||||||
|
createCollectionsCollectionIfNotExists();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Failed to initialize Typesense collections", e);
|
logger.error("Failed to initialize Typesense collections", e);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
142
frontend/src/app/collections/[id]/edit/page.tsx
Normal file
142
frontend/src/app/collections/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { collectionApi } from '../../../../lib/api';
|
||||||
|
import { Collection } from '../../../../types/api';
|
||||||
|
import AppLayout from '../../../../components/layout/AppLayout';
|
||||||
|
import CollectionForm from '../../../../components/collections/CollectionForm';
|
||||||
|
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||||
|
|
||||||
|
export default function EditCollectionPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const collectionId = params.id as string;
|
||||||
|
|
||||||
|
const [collection, setCollection] = useState<Collection | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCollection = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await collectionApi.getCollection(collectionId);
|
||||||
|
setCollection(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to load collection:', err);
|
||||||
|
setError(err.response?.data?.message || 'Failed to load collection');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (collectionId) {
|
||||||
|
loadCollection();
|
||||||
|
}
|
||||||
|
}, [collectionId]);
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
storyIds?: string[];
|
||||||
|
coverImage?: File;
|
||||||
|
}) => {
|
||||||
|
if (!collection) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Update basic info
|
||||||
|
await collectionApi.updateCollection(collection.id, {
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description,
|
||||||
|
tagNames: formData.tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload cover image if provided
|
||||||
|
if (formData.coverImage) {
|
||||||
|
await collectionApi.uploadCover(collection.id, formData.coverImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect back to collection detail
|
||||||
|
router.push(`/collections/${collection.id}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to update collection:', err);
|
||||||
|
setError(err.response?.data?.message || 'Failed to update collection');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.push(`/collections/${collectionId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !collection) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<div className="text-red-600 text-lg mb-4">
|
||||||
|
{error || 'Collection not found'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/collections')}
|
||||||
|
className="theme-accent hover:underline"
|
||||||
|
>
|
||||||
|
Back to Collections
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialData = {
|
||||||
|
name: collection.name,
|
||||||
|
description: collection.description,
|
||||||
|
tags: collection.tags?.map(tag => tag.name) || [],
|
||||||
|
storyIds: collection.collectionStories?.map(cs => cs.story.id) || [],
|
||||||
|
coverImagePath: collection.coverImagePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold theme-header">Edit Collection</h1>
|
||||||
|
<p className="theme-text mt-2">
|
||||||
|
Update your collection details and organization.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-100 border border-red-300 text-red-700 rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CollectionForm
|
||||||
|
initialData={initialData}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
loading={saving}
|
||||||
|
submitLabel="Update Collection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
frontend/src/app/collections/[id]/page.tsx
Normal file
85
frontend/src/app/collections/[id]/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { collectionApi } from '../../../lib/api';
|
||||||
|
import { Collection } from '../../../types/api';
|
||||||
|
import AppLayout from '../../../components/layout/AppLayout';
|
||||||
|
import CollectionDetailView from '../../../components/collections/CollectionDetailView';
|
||||||
|
import LoadingSpinner from '../../../components/ui/LoadingSpinner';
|
||||||
|
|
||||||
|
export default function CollectionDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const collectionId = params.id as string;
|
||||||
|
|
||||||
|
const [collection, setCollection] = useState<Collection | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadCollection = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await collectionApi.getCollection(collectionId);
|
||||||
|
setCollection(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to load collection:', err);
|
||||||
|
setError(err.response?.data?.message || 'Failed to load collection');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (collectionId) {
|
||||||
|
loadCollection();
|
||||||
|
}
|
||||||
|
}, [collectionId]);
|
||||||
|
|
||||||
|
const handleCollectionUpdate = () => {
|
||||||
|
loadCollection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCollectionDelete = () => {
|
||||||
|
router.push('/collections');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !collection) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<div className="text-red-600 text-lg mb-4">
|
||||||
|
{error || 'Collection not found'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/collections')}
|
||||||
|
className="theme-accent hover:underline"
|
||||||
|
>
|
||||||
|
Back to Collections
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<CollectionDetailView
|
||||||
|
collection={collection}
|
||||||
|
onUpdate={handleCollectionUpdate}
|
||||||
|
onDelete={handleCollectionDelete}
|
||||||
|
/>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/src/app/collections/[id]/read/[storyId]/page.tsx
Normal file
82
frontend/src/app/collections/[id]/read/[storyId]/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { collectionApi } from '../../../../../lib/api';
|
||||||
|
import { StoryWithCollectionContext } from '../../../../../types/api';
|
||||||
|
import AppLayout from '../../../../../components/layout/AppLayout';
|
||||||
|
import CollectionReadingView from '../../../../../components/collections/CollectionReadingView';
|
||||||
|
import LoadingSpinner from '../../../../../components/ui/LoadingSpinner';
|
||||||
|
|
||||||
|
export default function CollectionReadingPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const collectionId = params.id as string;
|
||||||
|
const storyId = params.storyId as string;
|
||||||
|
|
||||||
|
const [data, setData] = useState<StoryWithCollectionContext | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadStoryWithContext = async () => {
|
||||||
|
if (!collectionId || !storyId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await collectionApi.getStoryWithCollectionContext(collectionId, storyId);
|
||||||
|
setData(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to load story with collection context:', err);
|
||||||
|
setError(err.response?.data?.message || 'Failed to load story');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadStoryWithContext();
|
||||||
|
}, [collectionId, storyId]);
|
||||||
|
|
||||||
|
const handleNavigate = (newStoryId: string) => {
|
||||||
|
router.push(`/collections/${collectionId}/read/${newStoryId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<div className="text-red-600 text-lg mb-4">
|
||||||
|
{error || 'Story not found'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/collections/${collectionId}`)}
|
||||||
|
className="theme-accent hover:underline"
|
||||||
|
>
|
||||||
|
Back to Collection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<CollectionReadingView
|
||||||
|
data={data}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
onBackToCollection={() => router.push(`/collections/${collectionId}`)}
|
||||||
|
/>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
frontend/src/app/collections/new/page.tsx
Normal file
84
frontend/src/app/collections/new/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { collectionApi } from '../../../lib/api';
|
||||||
|
import AppLayout from '../../../components/layout/AppLayout';
|
||||||
|
import CollectionForm from '../../../components/collections/CollectionForm';
|
||||||
|
import { Collection } from '../../../types/api';
|
||||||
|
|
||||||
|
export default function NewCollectionPage() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
storyIds?: string[];
|
||||||
|
coverImage?: File;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
let collection: Collection;
|
||||||
|
|
||||||
|
if (formData.coverImage) {
|
||||||
|
collection = await collectionApi.createCollectionWithImage({
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description,
|
||||||
|
tags: formData.tags,
|
||||||
|
storyIds: formData.storyIds,
|
||||||
|
coverImage: formData.coverImage,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
collection = await collectionApi.createCollection({
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description,
|
||||||
|
tagNames: formData.tags,
|
||||||
|
storyIds: formData.storyIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to the new collection's detail page
|
||||||
|
router.push(`/collections/${collection.id}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to create collection:', err);
|
||||||
|
setError(err.response?.data?.message || 'Failed to create collection');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.push('/collections');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold theme-header">Create New Collection</h1>
|
||||||
|
<p className="theme-text mt-2">
|
||||||
|
Organize your stories into a curated collection for better reading experience.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-100 border border-red-300 text-red-700 rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CollectionForm
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
loading={loading}
|
||||||
|
submitLabel="Create Collection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
frontend/src/app/collections/page.tsx
Normal file
286
frontend/src/app/collections/page.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { collectionApi, tagApi } from '../../lib/api';
|
||||||
|
import { Collection, Tag } from '../../types/api';
|
||||||
|
import AppLayout from '../../components/layout/AppLayout';
|
||||||
|
import { Input } from '../../components/ui/Input';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import CollectionGrid from '../../components/collections/CollectionGrid';
|
||||||
|
import TagFilter from '../../components/stories/TagFilter';
|
||||||
|
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
||||||
|
|
||||||
|
type ViewMode = 'grid' | 'list';
|
||||||
|
|
||||||
|
export default function CollectionsPage() {
|
||||||
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalCollections, setTotalCollections] = useState(0);
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
|
// Load tags for filtering
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTags = async () => {
|
||||||
|
try {
|
||||||
|
const tagsResult = await tagApi.getTags({ page: 0, size: 1000 });
|
||||||
|
setTags(tagsResult?.content || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tags:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load collections with search and filters
|
||||||
|
useEffect(() => {
|
||||||
|
const debounceTimer = setTimeout(() => {
|
||||||
|
const loadCollections = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const result = await collectionApi.getCollections({
|
||||||
|
page: page,
|
||||||
|
limit: pageSize,
|
||||||
|
search: searchQuery.trim() || undefined,
|
||||||
|
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||||
|
archived: showArchived,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCollections(result?.results || []);
|
||||||
|
setTotalPages(Math.ceil((result?.totalHits || 0) / pageSize));
|
||||||
|
setTotalCollections(result?.totalHits || 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load collections:', error);
|
||||||
|
setCollections([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCollections();
|
||||||
|
}, searchQuery ? 300 : 0); // Debounce search, but not other changes
|
||||||
|
|
||||||
|
return () => clearTimeout(debounceTimer);
|
||||||
|
}, [searchQuery, selectedTags, page, pageSize, showArchived, refreshTrigger]);
|
||||||
|
|
||||||
|
// Reset page when search or filters change
|
||||||
|
const resetPage = () => {
|
||||||
|
if (page !== 0) {
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagToggle = (tagName: string) => {
|
||||||
|
setSelectedTags(prev => {
|
||||||
|
const newTags = prev.includes(tagName)
|
||||||
|
? prev.filter(t => t !== tagName)
|
||||||
|
: [...prev, tagName];
|
||||||
|
resetPage();
|
||||||
|
return newTags;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
resetPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageSizeChange = (newSize: number) => {
|
||||||
|
setPageSize(newSize);
|
||||||
|
resetPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedTags([]);
|
||||||
|
setShowArchived(false);
|
||||||
|
resetPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCollectionUpdate = () => {
|
||||||
|
// Trigger reload by incrementing refresh trigger
|
||||||
|
setRefreshTrigger(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && collections.length === 0) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold theme-header">Collections</h1>
|
||||||
|
<p className="theme-text mt-1">
|
||||||
|
{totalCollections} {totalCollections === 1 ? 'collection' : 'collections'}
|
||||||
|
{searchQuery || selectedTags.length > 0 || showArchived ? ` found` : ` total`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button href="/collections/new">
|
||||||
|
Create New Collection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search collections by name or description..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'theme-accent-bg text-white'
|
||||||
|
: 'theme-card theme-text hover:bg-opacity-80'
|
||||||
|
}`}
|
||||||
|
aria-label="Grid view"
|
||||||
|
>
|
||||||
|
⊞
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'theme-accent-bg text-white'
|
||||||
|
: 'theme-card theme-text hover:bg-opacity-80'
|
||||||
|
}`}
|
||||||
|
aria-label="List view"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters and Controls */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||||
|
{/* Page Size Selector */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="theme-text font-medium text-sm">Show:</label>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
|
||||||
|
className="px-3 py-1 rounded-lg theme-card theme-text theme-border border focus:outline-none focus:ring-2 focus:ring-theme-accent"
|
||||||
|
>
|
||||||
|
<option value={10}>10</option>
|
||||||
|
<option value={20}>20</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Archive Toggle */}
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showArchived}
|
||||||
|
onChange={(e) => {
|
||||||
|
setShowArchived(e.target.checked);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="theme-text text-sm">Show archived</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Clear Filters */}
|
||||||
|
{(searchQuery || selectedTags.length > 0 || showArchived) && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag Filter */}
|
||||||
|
<TagFilter
|
||||||
|
tags={tags}
|
||||||
|
selectedTags={selectedTags}
|
||||||
|
onTagToggle={handleTagToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collections Display */}
|
||||||
|
<CollectionGrid
|
||||||
|
collections={collections}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onUpdate={handleCollectionUpdate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center items-center gap-4 mt-8">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page === 0}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="theme-text text-sm">Page</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={totalPages}
|
||||||
|
value={page + 1}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newPage = Math.max(0, Math.min(totalPages - 1, parseInt(e.target.value) - 1));
|
||||||
|
if (!isNaN(newPage)) {
|
||||||
|
setPage(newPage);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-16 px-2 py-1 text-center rounded theme-card theme-text theme-border border focus:outline-none focus:ring-2 focus:ring-theme-accent"
|
||||||
|
/>
|
||||||
|
<span className="theme-text text-sm">of {totalPages}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading Overlay */}
|
||||||
|
{loading && collections.length > 0 && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-20 flex items-center justify-center z-50">
|
||||||
|
<div className="theme-card p-4 rounded-lg">
|
||||||
|
<LoadingSpinner size="md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { Story, Tag } from '../../types/api';
|
|||||||
import AppLayout from '../../components/layout/AppLayout';
|
import AppLayout from '../../components/layout/AppLayout';
|
||||||
import { Input } from '../../components/ui/Input';
|
import { Input } from '../../components/ui/Input';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
import StoryCard from '../../components/stories/StoryCard';
|
import StoryMultiSelect from '../../components/stories/StoryMultiSelect';
|
||||||
import TagFilter from '../../components/stories/TagFilter';
|
import TagFilter from '../../components/stories/TagFilter';
|
||||||
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
||||||
|
|
||||||
@@ -242,20 +242,12 @@ export default function LibraryPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={
|
<StoryMultiSelect
|
||||||
viewMode === 'grid'
|
stories={stories}
|
||||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'
|
viewMode={viewMode}
|
||||||
: 'space-y-4'
|
onUpdate={handleStoryUpdate}
|
||||||
}>
|
allowMultiSelect={true}
|
||||||
{stories.map((story) => (
|
/>
|
||||||
<StoryCard
|
|
||||||
key={story.id}
|
|
||||||
story={story}
|
|
||||||
viewMode={viewMode}
|
|
||||||
onUpdate={handleStoryUpdate}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useParams, useRouter } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { storyApi, seriesApi, getImageUrl } from '../../../../lib/api';
|
import { storyApi, seriesApi, getImageUrl } from '../../../../lib/api';
|
||||||
import { Story } from '../../../../types/api';
|
import { Story, Collection } from '../../../../types/api';
|
||||||
import AppLayout from '../../../../components/layout/AppLayout';
|
import AppLayout from '../../../../components/layout/AppLayout';
|
||||||
import Button from '../../../../components/ui/Button';
|
import Button from '../../../../components/ui/Button';
|
||||||
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||||
@@ -17,6 +17,7 @@ export default function StoryDetailPage() {
|
|||||||
|
|
||||||
const [story, setStory] = useState<Story | null>(null);
|
const [story, setStory] = useState<Story | null>(null);
|
||||||
const [seriesStories, setSeriesStories] = useState<Story[]>([]);
|
const [seriesStories, setSeriesStories] = useState<Story[]>([]);
|
||||||
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [updating, setUpdating] = useState(false);
|
const [updating, setUpdating] = useState(false);
|
||||||
|
|
||||||
@@ -32,6 +33,10 @@ export default function StoryDetailPage() {
|
|||||||
const seriesData = await seriesApi.getSeriesStories(storyData.seriesId);
|
const seriesData = await seriesApi.getSeriesStories(storyData.seriesId);
|
||||||
setSeriesStories(seriesData);
|
setSeriesStories(seriesData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load collections that contain this story
|
||||||
|
const collectionsData = await storyApi.getStoryCollections(storyId);
|
||||||
|
setCollections(collectionsData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load story data:', error);
|
console.error('Failed to load story data:', error);
|
||||||
router.push('/library');
|
router.push('/library');
|
||||||
@@ -250,6 +255,57 @@ export default function StoryDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Collections */}
|
||||||
|
{collections.length > 0 && (
|
||||||
|
<div className="theme-card theme-shadow rounded-lg p-4">
|
||||||
|
<h3 className="font-semibold theme-header mb-3">
|
||||||
|
Part of Collections ({collections.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{collections.map((collection) => (
|
||||||
|
<Link
|
||||||
|
key={collection.id}
|
||||||
|
href={`/collections/${collection.id}`}
|
||||||
|
className="block p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{collection.coverImagePath ? (
|
||||||
|
<img
|
||||||
|
src={getImageUrl(collection.coverImagePath)}
|
||||||
|
alt={`${collection.name} cover`}
|
||||||
|
className="w-8 h-10 object-cover rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-10 bg-gradient-to-br from-blue-100 to-purple-100 rounded flex items-center justify-center">
|
||||||
|
<span className="text-xs font-bold text-gray-600">
|
||||||
|
{collection.storyCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-medium theme-header truncate">
|
||||||
|
{collection.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm theme-text opacity-70">
|
||||||
|
{collection.storyCount} {collection.storyCount === 1 ? 'story' : 'stories'}
|
||||||
|
{collection.estimatedReadingTime && (
|
||||||
|
<span> • ~{Math.ceil(collection.estimatedReadingTime / 60)}h reading</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{collection.rating && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<span className="text-yellow-400">★</span>
|
||||||
|
<span className="text-sm theme-text ml-1">{collection.rating}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
{story.summary && (
|
{story.summary && (
|
||||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||||
|
|||||||
201
frontend/src/components/collections/AddToCollectionModal.tsx
Normal file
201
frontend/src/components/collections/AddToCollectionModal.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { collectionApi, searchApi } from '../../lib/api';
|
||||||
|
import { Collection, Story } from '../../types/api';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import { Input } from '../ui/Input';
|
||||||
|
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||||
|
|
||||||
|
interface AddToCollectionModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
collection: Collection;
|
||||||
|
onUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddToCollectionModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
collection,
|
||||||
|
onUpdate
|
||||||
|
}: AddToCollectionModalProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [availableStories, setAvailableStories] = useState<Story[]>([]);
|
||||||
|
const [selectedStoryIds, setSelectedStoryIds] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
|
// Get IDs of stories already in the collection
|
||||||
|
const existingStoryIds = collection.collectionStories?.map(cs => cs.story.id) || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadStories();
|
||||||
|
}
|
||||||
|
}, [isOpen, searchQuery]);
|
||||||
|
|
||||||
|
const loadStories = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await searchApi.search({
|
||||||
|
query: searchQuery || '*',
|
||||||
|
page: 0,
|
||||||
|
size: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out stories already in the collection
|
||||||
|
const filteredStories = result.results.filter(
|
||||||
|
story => !existingStoryIds.includes(story.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
setAvailableStories(filteredStories);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load stories:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleStorySelection = (storyId: string) => {
|
||||||
|
setSelectedStoryIds(prev =>
|
||||||
|
prev.includes(storyId)
|
||||||
|
? prev.filter(id => id !== storyId)
|
||||||
|
: [...prev, storyId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddStories = async () => {
|
||||||
|
if (selectedStoryIds.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setAdding(true);
|
||||||
|
await collectionApi.addStoriesToCollection(collection.id, selectedStoryIds);
|
||||||
|
onUpdate();
|
||||||
|
onClose();
|
||||||
|
setSelectedStoryIds([]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add stories to collection:', error);
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!adding) {
|
||||||
|
setSelectedStoryIds([]);
|
||||||
|
setSearchQuery('');
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="theme-card max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b theme-border">
|
||||||
|
<h2 className="text-xl font-semibold theme-header">
|
||||||
|
Add Stories to "{collection.name}"
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={adding}
|
||||||
|
className="text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="p-6 border-b theme-border">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search stories to add..."
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stories List */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<LoadingSpinner size="md" />
|
||||||
|
</div>
|
||||||
|
) : availableStories.length === 0 ? (
|
||||||
|
<div className="text-center py-8 theme-text opacity-70">
|
||||||
|
{searchQuery ? 'No stories found matching your search.' : 'All stories are already in this collection.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{availableStories.map((story) => {
|
||||||
|
const isSelected = selectedStoryIds.includes(story.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={story.id}
|
||||||
|
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'theme-border hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleStorySelection(story.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleStorySelection(story.id)}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium theme-text truncate">
|
||||||
|
{story.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm theme-text opacity-70 truncate">
|
||||||
|
by {story.authorName}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 mt-1 text-xs theme-text opacity-60">
|
||||||
|
<span>{story.wordCount?.toLocaleString()} words</span>
|
||||||
|
{story.rating && (
|
||||||
|
<span className="flex items-center">
|
||||||
|
★ {story.rating}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-t theme-border">
|
||||||
|
<div className="text-sm theme-text opacity-70">
|
||||||
|
{selectedStoryIds.length} stories selected
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={adding}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddStories}
|
||||||
|
disabled={selectedStoryIds.length === 0 || adding}
|
||||||
|
>
|
||||||
|
{adding ? <LoadingSpinner size="sm" /> : `Add ${selectedStoryIds.length} Stories`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
frontend/src/components/collections/CollectionCard.tsx
Normal file
203
frontend/src/components/collections/CollectionCard.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Collection } from '../../types/api';
|
||||||
|
import { getImageUrl } from '../../lib/api';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface CollectionCardProps {
|
||||||
|
collection: Collection;
|
||||||
|
viewMode: 'grid' | 'list';
|
||||||
|
onUpdate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionCard({ collection, viewMode, onUpdate }: CollectionCardProps) {
|
||||||
|
const formatReadingTime = (minutes: number): string => {
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRatingStars = (rating?: number) => {
|
||||||
|
if (!rating) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<span
|
||||||
|
key={star}
|
||||||
|
className={`text-sm ${
|
||||||
|
star <= rating ? 'text-yellow-400' : 'text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (viewMode === 'grid') {
|
||||||
|
return (
|
||||||
|
<Link href={`/collections/${collection.id}`}>
|
||||||
|
<div className="theme-card p-4 hover:border-gray-400 transition-colors cursor-pointer">
|
||||||
|
{/* Cover Image or Placeholder */}
|
||||||
|
<div className="aspect-[3/4] mb-3 relative overflow-hidden rounded-lg bg-gray-100">
|
||||||
|
{collection.coverImagePath ? (
|
||||||
|
<img
|
||||||
|
src={getImageUrl(collection.coverImagePath)}
|
||||||
|
alt={`${collection.name} cover`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
|
||||||
|
<div className="text-center p-4">
|
||||||
|
<div className="text-2xl font-bold theme-text mb-1">
|
||||||
|
{collection.storyCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs theme-text opacity-60">
|
||||||
|
{collection.storyCount === 1 ? 'story' : 'stories'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{collection.isArchived && (
|
||||||
|
<div className="absolute top-2 right-2 bg-yellow-500 text-white px-2 py-1 rounded text-xs">
|
||||||
|
Archived
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collection Info */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold theme-header line-clamp-2">
|
||||||
|
{collection.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{collection.description && (
|
||||||
|
<p className="text-sm theme-text opacity-70 line-clamp-2">
|
||||||
|
{collection.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs theme-text opacity-60">
|
||||||
|
<span>{collection.storyCount} stories</span>
|
||||||
|
<span>{collection.estimatedReadingTime ? formatReadingTime(collection.estimatedReadingTime) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collection.rating && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{renderRatingStars(collection.rating)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{collection.tags && collection.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{collection.tags.slice(0, 3).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="inline-block px-2 py-1 text-xs rounded-full theme-accent-bg text-white"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{collection.tags.length > 3 && (
|
||||||
|
<span className="text-xs theme-text opacity-60">
|
||||||
|
+{collection.tags.length - 3} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// List view
|
||||||
|
return (
|
||||||
|
<Link href={`/collections/${collection.id}`}>
|
||||||
|
<div className="theme-card p-4 hover:border-gray-400 transition-colors cursor-pointer">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Cover Image */}
|
||||||
|
<div className="w-16 h-20 flex-shrink-0 rounded overflow-hidden bg-gray-100">
|
||||||
|
{collection.coverImagePath ? (
|
||||||
|
<img
|
||||||
|
src={getImageUrl(collection.coverImagePath)}
|
||||||
|
alt={`${collection.name} cover`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm font-bold theme-text">
|
||||||
|
{collection.storyCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collection Details */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold theme-header line-clamp-1">
|
||||||
|
{collection.name}
|
||||||
|
{collection.isArchived && (
|
||||||
|
<span className="ml-2 inline-block bg-yellow-500 text-white px-2 py-1 rounded text-xs">
|
||||||
|
Archived
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{collection.description && (
|
||||||
|
<p className="text-sm theme-text opacity-70 line-clamp-2 mt-1">
|
||||||
|
{collection.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-sm theme-text opacity-60">
|
||||||
|
<span>{collection.storyCount} stories</span>
|
||||||
|
<span>{collection.estimatedReadingTime ? formatReadingTime(collection.estimatedReadingTime) : '—'} reading</span>
|
||||||
|
{collection.averageStoryRating && collection.averageStoryRating > 0 && (
|
||||||
|
<span>★ {collection.averageStoryRating.toFixed(1)} avg</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{collection.tags && collection.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{collection.tags.slice(0, 5).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="inline-block px-2 py-1 text-xs rounded-full theme-accent-bg text-white"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{collection.tags.length > 5 && (
|
||||||
|
<span className="text-xs theme-text opacity-60">
|
||||||
|
+{collection.tags.length - 5} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collection.rating && (
|
||||||
|
<div className="flex-shrink-0 ml-4">
|
||||||
|
{renderRatingStars(collection.rating)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
360
frontend/src/components/collections/CollectionDetailView.tsx
Normal file
360
frontend/src/components/collections/CollectionDetailView.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Collection } from '../../types/api';
|
||||||
|
import { collectionApi, getImageUrl } from '../../lib/api';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import StoryReorderList from './StoryReorderList';
|
||||||
|
import AddToCollectionModal from './AddToCollectionModal';
|
||||||
|
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface CollectionDetailViewProps {
|
||||||
|
collection: Collection;
|
||||||
|
onUpdate: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionDetailView({
|
||||||
|
collection,
|
||||||
|
onUpdate,
|
||||||
|
onDelete
|
||||||
|
}: CollectionDetailViewProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showAddStories, setShowAddStories] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editName, setEditName] = useState(collection.name);
|
||||||
|
const [editDescription, setEditDescription] = useState(collection.description || '');
|
||||||
|
const [editRating, setEditRating] = useState(collection.rating || '');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const formatReadingTime = (minutes: number): string => {
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes} minutes`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours} hours`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRatingStars = (rating?: number) => {
|
||||||
|
if (!rating) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<span
|
||||||
|
key={star}
|
||||||
|
className={`text-lg ${
|
||||||
|
star <= rating ? 'text-yellow-400' : 'text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdits = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
await collectionApi.updateCollection(collection.id, {
|
||||||
|
name: editName.trim(),
|
||||||
|
description: editDescription.trim() || undefined,
|
||||||
|
rating: editRating ? parseInt(editRating.toString()) : undefined,
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
onUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update collection:', error);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditName(collection.name);
|
||||||
|
setEditDescription(collection.description || '');
|
||||||
|
setEditRating(collection.rating || '');
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async () => {
|
||||||
|
const action = collection.isArchived ? 'unarchive' : 'archive';
|
||||||
|
if (confirm(`Are you sure you want to ${action} this collection?`)) {
|
||||||
|
try {
|
||||||
|
setActionLoading('archive');
|
||||||
|
await collectionApi.archiveCollection(collection.id, !collection.isArchived);
|
||||||
|
onUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to ${action} collection:`, error);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (confirm('Are you sure you want to delete this collection? This cannot be undone. Stories will not be deleted.')) {
|
||||||
|
try {
|
||||||
|
setActionLoading('delete');
|
||||||
|
await collectionApi.deleteCollection(collection.id);
|
||||||
|
onDelete();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete collection:', error);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startReading = () => {
|
||||||
|
if (collection.collectionStories && collection.collectionStories.length > 0) {
|
||||||
|
const firstStory = collection.collectionStories[0].story;
|
||||||
|
router.push(`/collections/${collection.id}/read/${firstStory.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="theme-card p-6">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-6">
|
||||||
|
{/* Cover Image */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-48 h-64 rounded-lg overflow-hidden bg-gray-100 mx-auto lg:mx-0">
|
||||||
|
{collection.coverImagePath ? (
|
||||||
|
<img
|
||||||
|
src={getImageUrl(collection.coverImagePath)}
|
||||||
|
alt={`${collection.name} cover`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
|
||||||
|
<div className="text-center p-4">
|
||||||
|
<div className="text-3xl font-bold theme-text mb-2">
|
||||||
|
{collection.storyCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm theme-text opacity-60">
|
||||||
|
{collection.storyCount === 1 ? 'story' : 'stories'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collection Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
className="text-3xl font-bold theme-header bg-transparent border-b-2 border-gray-300 focus:border-blue-500 focus:outline-none w-full"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={editDescription}
|
||||||
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
|
placeholder="Add a description..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full theme-text bg-transparent border border-gray-300 rounded-lg p-2 focus:border-blue-500 focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="text-sm font-medium theme-text">Rating:</label>
|
||||||
|
<select
|
||||||
|
value={editRating}
|
||||||
|
onChange={(e) => setEditRating(e.target.value)}
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded theme-text focus:border-blue-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">No rating</option>
|
||||||
|
{[1, 2, 3, 4, 5].map(num => (
|
||||||
|
<option key={num} value={num}>{num} star{num > 1 ? 's' : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold theme-header mb-2 break-words">
|
||||||
|
{collection.name}
|
||||||
|
{collection.isArchived && (
|
||||||
|
<span className="ml-3 inline-block bg-yellow-500 text-white px-3 py-1 rounded text-sm font-normal">
|
||||||
|
Archived
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
{collection.description && (
|
||||||
|
<p className="theme-text text-lg mb-4 whitespace-pre-wrap">
|
||||||
|
{collection.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{collection.rating && (
|
||||||
|
<div className="mb-4">
|
||||||
|
{renderRatingStars(collection.rating)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Controls */}
|
||||||
|
<div className="flex gap-2 ml-4">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveEdits}
|
||||||
|
disabled={saving || !editName.trim()}
|
||||||
|
>
|
||||||
|
{saving ? <LoadingSpinner size="sm" /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold theme-text">
|
||||||
|
{collection.storyCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm theme-text opacity-60">
|
||||||
|
{collection.storyCount === 1 ? 'Story' : 'Stories'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold theme-text">
|
||||||
|
{collection.estimatedReadingTime ? formatReadingTime(collection.estimatedReadingTime) : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm theme-text opacity-60">Reading Time</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold theme-text">
|
||||||
|
{collection.totalWordCount ? collection.totalWordCount.toLocaleString() : '0'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm theme-text opacity-60">Total Words</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold theme-text">
|
||||||
|
{collection.averageStoryRating && collection.averageStoryRating > 0 ? collection.averageStoryRating.toFixed(1) : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm theme-text opacity-60">Avg Rating</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={startReading}
|
||||||
|
disabled={collection.storyCount === 0}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
Read Collection
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.push(`/collections/${collection.id}/edit`)}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
Edit Collection
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowAddStories(true)}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
Add Stories
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleArchive}
|
||||||
|
disabled={actionLoading === 'archive'}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
{actionLoading === 'archive' ? <LoadingSpinner size="sm" /> : (collection.isArchived ? 'Unarchive' : 'Archive')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={actionLoading === 'delete'}
|
||||||
|
className="flex-shrink-0 text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
{actionLoading === 'delete' ? <LoadingSpinner size="sm" /> : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{collection.tags && collection.tags.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-sm font-medium theme-text mb-2">Tags:</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{collection.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="inline-block px-3 py-1 text-sm rounded-full theme-accent-bg text-white"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stories Section */}
|
||||||
|
<div className="theme-card p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-semibold theme-header">
|
||||||
|
Stories ({collection.storyCount})
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAddStories(true)}
|
||||||
|
>
|
||||||
|
Add Stories
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StoryReorderList
|
||||||
|
collection={collection}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Stories Modal */}
|
||||||
|
<AddToCollectionModal
|
||||||
|
isOpen={showAddStories}
|
||||||
|
onClose={() => setShowAddStories(false)}
|
||||||
|
collection={collection}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
415
frontend/src/components/collections/CollectionForm.tsx
Normal file
415
frontend/src/components/collections/CollectionForm.tsx
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { searchApi, tagApi } from '../../lib/api';
|
||||||
|
import { Story, Tag } from '../../types/api';
|
||||||
|
import { Input } from '../ui/Input';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||||
|
|
||||||
|
interface CollectionFormProps {
|
||||||
|
initialData?: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
storyIds?: string[];
|
||||||
|
coverImagePath?: string;
|
||||||
|
};
|
||||||
|
onSubmit: (data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
storyIds?: string[];
|
||||||
|
coverImage?: File;
|
||||||
|
}) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
submitLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionForm({
|
||||||
|
initialData,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
loading = false,
|
||||||
|
submitLabel = 'Save Collection'
|
||||||
|
}: CollectionFormProps) {
|
||||||
|
const [name, setName] = useState(initialData?.name || '');
|
||||||
|
const [description, setDescription] = useState(initialData?.description || '');
|
||||||
|
const [tagInput, setTagInput] = useState('');
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>(initialData?.tags || []);
|
||||||
|
const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
|
||||||
|
const [selectedStoryIds, setSelectedStoryIds] = useState<string[]>(initialData?.storyIds || []);
|
||||||
|
const [coverImage, setCoverImage] = useState<File | null>(null);
|
||||||
|
const [coverImagePreview, setCoverImagePreview] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Story selection state
|
||||||
|
const [storySearchQuery, setStorySearchQuery] = useState('');
|
||||||
|
const [availableStories, setAvailableStories] = useState<Story[]>([]);
|
||||||
|
const [selectedStories, setSelectedStories] = useState<Story[]>([]);
|
||||||
|
const [loadingStories, setLoadingStories] = useState(false);
|
||||||
|
const [showStorySelection, setShowStorySelection] = useState(false);
|
||||||
|
|
||||||
|
// Load tag suggestions when typing
|
||||||
|
useEffect(() => {
|
||||||
|
if (tagInput.length > 1) {
|
||||||
|
const loadSuggestions = async () => {
|
||||||
|
try {
|
||||||
|
const suggestions = await tagApi.getTagAutocomplete(tagInput);
|
||||||
|
setTagSuggestions(suggestions.filter(tag => !selectedTags.includes(tag)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tag suggestions:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const debounceTimer = setTimeout(loadSuggestions, 300);
|
||||||
|
return () => clearTimeout(debounceTimer);
|
||||||
|
} else {
|
||||||
|
setTagSuggestions([]);
|
||||||
|
}
|
||||||
|
}, [tagInput, selectedTags]);
|
||||||
|
|
||||||
|
// Load stories for selection
|
||||||
|
useEffect(() => {
|
||||||
|
if (showStorySelection) {
|
||||||
|
const loadStories = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingStories(true);
|
||||||
|
const result = await searchApi.search({
|
||||||
|
query: storySearchQuery || '*',
|
||||||
|
page: 0,
|
||||||
|
size: 50,
|
||||||
|
});
|
||||||
|
setAvailableStories(result.results || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load stories:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingStories(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const debounceTimer = setTimeout(loadStories, 300);
|
||||||
|
return () => clearTimeout(debounceTimer);
|
||||||
|
}
|
||||||
|
}, [storySearchQuery, showStorySelection]);
|
||||||
|
|
||||||
|
// Load selected stories data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedStoryIds.length > 0) {
|
||||||
|
const loadSelectedStories = async () => {
|
||||||
|
try {
|
||||||
|
const result = await searchApi.search({
|
||||||
|
query: '*',
|
||||||
|
page: 0,
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
const stories = result.results.filter(story => selectedStoryIds.includes(story.id));
|
||||||
|
setSelectedStories(stories);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load selected stories:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadSelectedStories();
|
||||||
|
}
|
||||||
|
}, [selectedStoryIds]);
|
||||||
|
|
||||||
|
const handleTagInputKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && tagInput.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
const newTag = tagInput.trim();
|
||||||
|
if (!selectedTags.includes(newTag)) {
|
||||||
|
setSelectedTags(prev => [...prev, newTag]);
|
||||||
|
}
|
||||||
|
setTagInput('');
|
||||||
|
setTagSuggestions([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTag = (tag: string) => {
|
||||||
|
if (!selectedTags.includes(tag)) {
|
||||||
|
setSelectedTags(prev => [...prev, tag]);
|
||||||
|
}
|
||||||
|
setTagInput('');
|
||||||
|
setTagSuggestions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (tagToRemove: string) => {
|
||||||
|
setSelectedTags(prev => prev.filter(tag => tag !== tagToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setCoverImage(file);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
setCoverImagePreview(e.target?.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleStorySelection = (story: Story) => {
|
||||||
|
const isSelected = selectedStoryIds.includes(story.id);
|
||||||
|
if (isSelected) {
|
||||||
|
setSelectedStoryIds(prev => prev.filter(id => id !== story.id));
|
||||||
|
setSelectedStories(prev => prev.filter(s => s.id !== story.id));
|
||||||
|
} else {
|
||||||
|
setSelectedStoryIds(prev => [...prev, story.id]);
|
||||||
|
setSelectedStories(prev => [...prev, story]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSelectedStory = (storyId: string) => {
|
||||||
|
setSelectedStoryIds(prev => prev.filter(id => id !== storyId));
|
||||||
|
setSelectedStories(prev => prev.filter(s => s.id !== storyId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubmit({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
tags: selectedTags,
|
||||||
|
storyIds: selectedStoryIds,
|
||||||
|
coverImage: coverImage || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Basic Information */}
|
||||||
|
<div className="theme-card p-6">
|
||||||
|
<h2 className="text-lg font-semibold theme-header mb-4">Basic Information</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium theme-text mb-1">
|
||||||
|
Collection Name *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Enter collection name"
|
||||||
|
required
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description" className="block text-sm font-medium theme-text mb-1">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Describe this collection (optional)"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border theme-border rounded-lg theme-card theme-text focus:outline-none focus:ring-2 focus:ring-theme-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover Image Upload */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="coverImage" className="block text-sm font-medium theme-text mb-1">
|
||||||
|
Cover Image
|
||||||
|
</label>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
id="coverImage"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
onChange={handleCoverImageChange}
|
||||||
|
className="w-full px-3 py-2 border theme-border rounded-lg theme-card theme-text focus:outline-none focus:ring-2 focus:ring-theme-accent"
|
||||||
|
/>
|
||||||
|
<p className="text-xs theme-text opacity-60 mt-1">
|
||||||
|
JPG, PNG, or WebP. Max 800x1200px.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(coverImagePreview || initialData?.coverImagePath) && (
|
||||||
|
<div className="w-20 h-24 rounded overflow-hidden bg-gray-100">
|
||||||
|
<img
|
||||||
|
src={coverImagePreview || (initialData?.coverImagePath ? `/images/${initialData.coverImagePath}` : '')}
|
||||||
|
alt="Cover preview"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="theme-card p-6">
|
||||||
|
<h2 className="text-lg font-semibold theme-header mb-4">Tags</h2>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={handleTagInputKeyDown}
|
||||||
|
placeholder="Type tags and press Enter"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tagSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-10 top-full left-0 right-0 mt-1 bg-white border theme-border rounded-lg shadow-lg max-h-32 overflow-y-auto">
|
||||||
|
{tagSuggestions.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
type="button"
|
||||||
|
onClick={() => addTag(suggestion)}
|
||||||
|
className="w-full px-3 py-2 text-left hover:bg-gray-100 theme-text"
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedTags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center px-3 py-1 text-sm rounded-full theme-accent-bg text-white"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
className="ml-2 hover:text-red-200"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Story Selection */}
|
||||||
|
<div className="theme-card p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold theme-header">Stories</h2>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowStorySelection(!showStorySelection)}
|
||||||
|
>
|
||||||
|
{showStorySelection ? 'Hide' : 'Add'} Stories
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Stories */}
|
||||||
|
{selectedStories.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-sm font-medium theme-text mb-2">
|
||||||
|
Selected Stories ({selectedStories.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||||
|
{selectedStories.map((story) => (
|
||||||
|
<div key={story.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium theme-text truncate">{story.title}</p>
|
||||||
|
<p className="text-xs theme-text opacity-60">{story.authorName}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeSelectedStory(story.id)}
|
||||||
|
className="ml-2 text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Story Selection Interface */}
|
||||||
|
{showStorySelection && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
value={storySearchQuery}
|
||||||
|
onChange={(e) => setStorySearchQuery(e.target.value)}
|
||||||
|
placeholder="Search stories to add..."
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loadingStories ? (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<LoadingSpinner size="sm" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-64 overflow-y-auto space-y-2">
|
||||||
|
{availableStories.map((story) => {
|
||||||
|
const isSelected = selectedStoryIds.includes(story.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={story.id}
|
||||||
|
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'theme-border hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleStorySelection(story)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleStorySelection(story)}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium theme-text truncate">{story.title}</p>
|
||||||
|
<p className="text-sm theme-text opacity-60">{story.authorName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Actions */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !name.trim()}
|
||||||
|
>
|
||||||
|
{loading ? <LoadingSpinner size="sm" /> : submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
frontend/src/components/collections/CollectionGrid.tsx
Normal file
42
frontend/src/components/collections/CollectionGrid.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Collection } from '../../types/api';
|
||||||
|
import CollectionCard from './CollectionCard';
|
||||||
|
|
||||||
|
interface CollectionGridProps {
|
||||||
|
collections: Collection[];
|
||||||
|
viewMode: 'grid' | 'list';
|
||||||
|
onUpdate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionGrid({ collections, viewMode, onUpdate }: CollectionGridProps) {
|
||||||
|
if (collections.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<div className="theme-text text-lg mb-4">
|
||||||
|
No collections found
|
||||||
|
</div>
|
||||||
|
<p className="theme-text opacity-70">
|
||||||
|
Create your first collection to organize your stories
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'
|
||||||
|
: 'space-y-4'
|
||||||
|
}>
|
||||||
|
{collections.map((collection) => (
|
||||||
|
<CollectionCard
|
||||||
|
key={collection.id}
|
||||||
|
collection={collection}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
frontend/src/components/collections/CollectionReadingView.tsx
Normal file
218
frontend/src/components/collections/CollectionReadingView.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { StoryWithCollectionContext } from '../../types/api';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface CollectionReadingViewProps {
|
||||||
|
data: StoryWithCollectionContext;
|
||||||
|
onNavigate: (storyId: string) => void;
|
||||||
|
onBackToCollection: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionReadingView({
|
||||||
|
data,
|
||||||
|
onNavigate,
|
||||||
|
onBackToCollection
|
||||||
|
}: CollectionReadingViewProps) {
|
||||||
|
const { story, collection } = data;
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
if (collection.previousStoryId) {
|
||||||
|
onNavigate(collection.previousStoryId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (collection.nextStoryId) {
|
||||||
|
onNavigate(collection.nextStoryId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRatingStars = (rating?: number) => {
|
||||||
|
if (!rating) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<span
|
||||||
|
key={star}
|
||||||
|
className={`text-sm ${
|
||||||
|
star <= rating ? 'text-yellow-400' : 'text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Collection Context Header */}
|
||||||
|
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onBackToCollection}
|
||||||
|
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
|
||||||
|
title="Back to Collection"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-blue-900 dark:text-blue-100">
|
||||||
|
Reading from: {collection.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
Story {collection.currentPosition} of {collection.totalStories}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-32 bg-blue-200 dark:bg-blue-800 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 dark:bg-blue-400 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${(collection.currentPosition / collection.totalStories) * 100}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-blue-700 dark:text-blue-300 font-mono">
|
||||||
|
{collection.currentPosition}/{collection.totalStories}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Story Header */}
|
||||||
|
<div className="theme-card p-6 mb-6">
|
||||||
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
|
{/* Story Cover */}
|
||||||
|
{story.coverPath && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={`/images/${story.coverPath}`}
|
||||||
|
alt={`${story.title} cover`}
|
||||||
|
className="w-32 h-40 object-cover rounded-lg mx-auto md:mx-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Story Info */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-3xl font-bold theme-header mb-2">
|
||||||
|
{story.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4 mb-4 text-sm theme-text opacity-70">
|
||||||
|
<Link
|
||||||
|
href={`/stories/${story.id}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
by {story.authorName}
|
||||||
|
</Link>
|
||||||
|
<span>{story.wordCount?.toLocaleString()} words</span>
|
||||||
|
{story.rating && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{renderRatingStars(story.rating)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{story.seriesName && (
|
||||||
|
<span>
|
||||||
|
{story.seriesName}
|
||||||
|
{story.volume && ` #${story.volume}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{story.summary && (
|
||||||
|
<p className="theme-text mb-4 italic">
|
||||||
|
{story.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{story.tags && story.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{story.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="inline-block px-2 py-1 text-xs rounded-full theme-accent-bg text-white"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Controls */}
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handlePrevious}
|
||||||
|
disabled={!collection.previousStoryId}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
← Previous Story
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-sm theme-text opacity-70">
|
||||||
|
Story {collection.currentPosition} of {collection.totalStories}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!collection.nextStoryId}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Next Story →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Story Content */}
|
||||||
|
<div className="theme-card p-8">
|
||||||
|
<div
|
||||||
|
className="prose prose-lg max-w-none theme-text"
|
||||||
|
dangerouslySetInnerHTML={{ __html: story.contentHtml }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Navigation */}
|
||||||
|
<div className="flex justify-between items-center mt-8 p-4 theme-card">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handlePrevious}
|
||||||
|
disabled={!collection.previousStoryId}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
← Previous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBackToCollection}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Back to Collection
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!collection.nextStoryId}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
frontend/src/components/collections/StoryReorderList.tsx
Normal file
264
frontend/src/components/collections/StoryReorderList.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Collection, Story } from '../../types/api';
|
||||||
|
import { collectionApi, getImageUrl } from '../../lib/api';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
|
||||||
|
interface StoryReorderListProps {
|
||||||
|
collection: Collection;
|
||||||
|
onUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StoryReorderList({ collection, onUpdate }: StoryReorderListProps) {
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
const [reordering, setReordering] = useState(false);
|
||||||
|
const [localStories, setLocalStories] = useState(collection.collectionStories || []);
|
||||||
|
|
||||||
|
// Update local stories when collection changes
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalStories(collection.collectionStories || []);
|
||||||
|
}, [collection.collectionStories]);
|
||||||
|
|
||||||
|
const stories = localStories;
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||||
|
setDraggedIndex(index);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/html', '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (e: React.DragEvent, dropIndex: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (draggedIndex === null || draggedIndex === dropIndex || reordering) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic update - update local state immediately
|
||||||
|
const newStories = [...stories];
|
||||||
|
const [draggedStory] = newStories.splice(draggedIndex, 1);
|
||||||
|
newStories.splice(dropIndex, 0, draggedStory);
|
||||||
|
setLocalStories(newStories);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setReordering(true);
|
||||||
|
|
||||||
|
// Create reorder request with new positions
|
||||||
|
const storyOrders = newStories.map((storyItem, index) => ({
|
||||||
|
storyId: storyItem.story.id,
|
||||||
|
position: index + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await collectionApi.reorderStories(collection.id, storyOrders);
|
||||||
|
// Don't call onUpdate() to avoid page reload - the local state is already correct
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reorder stories:', error);
|
||||||
|
// On error, refresh to get the correct order
|
||||||
|
onUpdate();
|
||||||
|
} finally {
|
||||||
|
setReordering(false);
|
||||||
|
setDraggedIndex(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveStory = async (storyId: string) => {
|
||||||
|
if (confirm('Remove this story from the collection?')) {
|
||||||
|
try {
|
||||||
|
await collectionApi.removeStoryFromCollection(collection.id, storyId);
|
||||||
|
onUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove story:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveStoryUp = async (index: number) => {
|
||||||
|
if (index === 0 || reordering) return;
|
||||||
|
|
||||||
|
// Optimistic update - update local state immediately
|
||||||
|
const newStories = [...stories];
|
||||||
|
[newStories[index - 1], newStories[index]] = [newStories[index], newStories[index - 1]];
|
||||||
|
setLocalStories(newStories);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setReordering(true);
|
||||||
|
|
||||||
|
const storyOrders = newStories.map((storyItem, idx) => ({
|
||||||
|
storyId: storyItem.story.id,
|
||||||
|
position: idx + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await collectionApi.reorderStories(collection.id, storyOrders);
|
||||||
|
// Don't call onUpdate() to avoid page reload
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reorder stories:', error);
|
||||||
|
// On error, refresh to get the correct order
|
||||||
|
onUpdate();
|
||||||
|
} finally {
|
||||||
|
setReordering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveStoryDown = async (index: number) => {
|
||||||
|
if (index === stories.length - 1 || reordering) return;
|
||||||
|
|
||||||
|
// Optimistic update - update local state immediately
|
||||||
|
const newStories = [...stories];
|
||||||
|
[newStories[index], newStories[index + 1]] = [newStories[index + 1], newStories[index]];
|
||||||
|
setLocalStories(newStories);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setReordering(true);
|
||||||
|
|
||||||
|
const storyOrders = newStories.map((storyItem, idx) => ({
|
||||||
|
storyId: storyItem.story.id,
|
||||||
|
position: idx + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await collectionApi.reorderStories(collection.id, storyOrders);
|
||||||
|
// Don't call onUpdate() to avoid page reload
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reorder stories:', error);
|
||||||
|
// On error, refresh to get the correct order
|
||||||
|
onUpdate();
|
||||||
|
} finally {
|
||||||
|
setReordering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (stories.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 theme-text opacity-70">
|
||||||
|
<p className="mb-4">No stories in this collection yet.</p>
|
||||||
|
<p className="text-sm">Add stories to start building your collection.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stories.map((storyItem, index) => {
|
||||||
|
const story = storyItem.story;
|
||||||
|
const isDragging = draggedIndex === index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${story.id}-${index}`}
|
||||||
|
draggable={!reordering}
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-4 p-4 theme-card rounded-lg border transition-all duration-200
|
||||||
|
${isDragging ? 'opacity-50 scale-95' : 'hover:border-gray-400'}
|
||||||
|
${reordering ? 'pointer-events-none' : 'cursor-move'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Drag Handle */}
|
||||||
|
<div className="flex flex-col items-center text-gray-400 hover:text-gray-600">
|
||||||
|
<div className="text-xs font-mono bg-gray-100 px-2 py-1 rounded mb-1">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="w-3 h-1 bg-gray-300 rounded"></div>
|
||||||
|
<div className="w-3 h-1 bg-gray-300 rounded"></div>
|
||||||
|
<div className="w-3 h-1 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Story Cover */}
|
||||||
|
<div className="w-12 h-16 flex-shrink-0 rounded overflow-hidden bg-gray-100">
|
||||||
|
{story.coverPath ? (
|
||||||
|
<img
|
||||||
|
src={getImageUrl(story.coverPath)}
|
||||||
|
alt={`${story.title} cover`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-100 to-purple-100">
|
||||||
|
<span className="text-xs font-bold text-gray-600">
|
||||||
|
{story.title.charAt(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Story Details */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Link
|
||||||
|
href={`/stories/${story.id}`}
|
||||||
|
className="block hover:underline"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium theme-header truncate">
|
||||||
|
{story.title}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm theme-text opacity-70 truncate">
|
||||||
|
by {story.authorName}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 mt-1 text-xs theme-text opacity-60">
|
||||||
|
<span>{story.wordCount?.toLocaleString()} words</span>
|
||||||
|
{story.rating && (
|
||||||
|
<span className="flex items-center">
|
||||||
|
★ {story.rating}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => moveStoryUp(index)}
|
||||||
|
disabled={index === 0 || reordering}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
title="Move up"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => moveStoryDown(index)}
|
||||||
|
disabled={index === stories.length - 1 || reordering}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
title="Move down"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveStory(story.id)}
|
||||||
|
disabled={reordering}
|
||||||
|
className="p-2 text-red-500 hover:text-red-700 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
title="Remove from collection"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{reordering && (
|
||||||
|
<div className="text-center py-4 theme-text opacity-70">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
Reordering stories...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,6 +45,12 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
Library
|
Library
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/collections"
|
||||||
|
className="theme-text hover:theme-accent transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Collections
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/authors"
|
href="/authors"
|
||||||
className="theme-text hover:theme-accent transition-colors font-medium"
|
className="theme-text hover:theme-accent transition-colors font-medium"
|
||||||
@@ -111,6 +117,13 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
Library
|
Library
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/collections"
|
||||||
|
className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1"
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Collections
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/authors"
|
href="/authors"
|
||||||
className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1"
|
className="theme-text hover:theme-accent transition-colors font-medium px-2 py-1"
|
||||||
|
|||||||
@@ -10,10 +10,20 @@ import Button from '../ui/Button';
|
|||||||
interface StoryCardProps {
|
interface StoryCardProps {
|
||||||
story: Story;
|
story: Story;
|
||||||
viewMode: 'grid' | 'list';
|
viewMode: 'grid' | 'list';
|
||||||
onUpdate: () => void;
|
onUpdate?: () => void;
|
||||||
|
showSelection?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onSelect?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StoryCard({ story, viewMode, onUpdate }: StoryCardProps) {
|
export default function StoryCard({
|
||||||
|
story,
|
||||||
|
viewMode,
|
||||||
|
onUpdate,
|
||||||
|
showSelection = false,
|
||||||
|
isSelected = false,
|
||||||
|
onSelect
|
||||||
|
}: StoryCardProps) {
|
||||||
const [rating, setRating] = useState(story.rating || 0);
|
const [rating, setRating] = useState(story.rating || 0);
|
||||||
const [updating, setUpdating] = useState(false);
|
const [updating, setUpdating] = useState(false);
|
||||||
|
|
||||||
@@ -24,7 +34,7 @@ export default function StoryCard({ story, viewMode, onUpdate }: StoryCardProps)
|
|||||||
setUpdating(true);
|
setUpdating(true);
|
||||||
await storyApi.updateRating(story.id, newRating);
|
await storyApi.updateRating(story.id, newRating);
|
||||||
setRating(newRating);
|
setRating(newRating);
|
||||||
onUpdate();
|
onUpdate?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update rating:', error);
|
console.error('Failed to update rating:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
131
frontend/src/components/stories/StoryMultiSelect.tsx
Normal file
131
frontend/src/components/stories/StoryMultiSelect.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Story } from '../../types/api';
|
||||||
|
import StoryCard from './StoryCard';
|
||||||
|
import StorySelectionToolbar from './StorySelectionToolbar';
|
||||||
|
|
||||||
|
interface StoryMultiSelectProps {
|
||||||
|
stories: Story[];
|
||||||
|
viewMode: 'grid' | 'list';
|
||||||
|
onUpdate?: () => void;
|
||||||
|
allowMultiSelect?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StoryMultiSelect({
|
||||||
|
stories,
|
||||||
|
viewMode,
|
||||||
|
onUpdate,
|
||||||
|
allowMultiSelect = true
|
||||||
|
}: StoryMultiSelectProps) {
|
||||||
|
const [selectedStoryIds, setSelectedStoryIds] = useState<string[]>([]);
|
||||||
|
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||||
|
|
||||||
|
const handleStorySelect = (storyId: string) => {
|
||||||
|
setSelectedStoryIds(prev => {
|
||||||
|
if (prev.includes(storyId)) {
|
||||||
|
const newSelection = prev.filter(id => id !== storyId);
|
||||||
|
if (newSelection.length === 0) {
|
||||||
|
setIsSelectionMode(false);
|
||||||
|
}
|
||||||
|
return newSelection;
|
||||||
|
} else {
|
||||||
|
if (!isSelectionMode) {
|
||||||
|
setIsSelectionMode(true);
|
||||||
|
}
|
||||||
|
return [...prev, storyId];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectedStoryIds.length === stories.length) {
|
||||||
|
setSelectedStoryIds([]);
|
||||||
|
setIsSelectionMode(false);
|
||||||
|
} else {
|
||||||
|
setSelectedStoryIds(stories.map(story => story.id));
|
||||||
|
setIsSelectionMode(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearSelection = () => {
|
||||||
|
setSelectedStoryIds([]);
|
||||||
|
setIsSelectionMode(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchOperation = (operation: string) => {
|
||||||
|
// This will trigger the appropriate action based on the operation
|
||||||
|
console.log(`Batch operation: ${operation} on stories:`, selectedStoryIds);
|
||||||
|
// After operation, clear selection
|
||||||
|
setSelectedStoryIds([]);
|
||||||
|
setIsSelectionMode(false);
|
||||||
|
onUpdate?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (stories.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<div className="theme-text text-lg mb-4">
|
||||||
|
No stories found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Selection Toolbar */}
|
||||||
|
{allowMultiSelect && (
|
||||||
|
<StorySelectionToolbar
|
||||||
|
selectedCount={selectedStoryIds.length}
|
||||||
|
totalCount={stories.length}
|
||||||
|
isSelectionMode={isSelectionMode}
|
||||||
|
onSelectAll={handleSelectAll}
|
||||||
|
onClearSelection={handleClearSelection}
|
||||||
|
onBatchOperation={handleBatchOperation}
|
||||||
|
selectedStoryIds={selectedStoryIds}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stories Grid/List */}
|
||||||
|
<div className={
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'
|
||||||
|
: 'space-y-4'
|
||||||
|
}>
|
||||||
|
{stories.map((story) => (
|
||||||
|
<div key={story.id} className="relative">
|
||||||
|
{/* Selection Checkbox */}
|
||||||
|
{allowMultiSelect && (isSelectionMode || selectedStoryIds.includes(story.id)) && (
|
||||||
|
<div className="absolute top-2 left-2 z-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedStoryIds.includes(story.id)}
|
||||||
|
onChange={() => handleStorySelect(story.id)}
|
||||||
|
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 bg-white shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Story Card */}
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-200 ${
|
||||||
|
selectedStoryIds.includes(story.id) ? 'ring-2 ring-blue-500 ring-opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
onDoubleClick={() => allowMultiSelect && handleStorySelect(story.id)}
|
||||||
|
>
|
||||||
|
<StoryCard
|
||||||
|
story={story}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
showSelection={isSelectionMode}
|
||||||
|
isSelected={selectedStoryIds.includes(story.id)}
|
||||||
|
onSelect={() => handleStorySelect(story.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
frontend/src/components/stories/StorySelectionToolbar.tsx
Normal file
251
frontend/src/components/stories/StorySelectionToolbar.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { collectionApi } from '../../lib/api';
|
||||||
|
import { Collection } from '../../types/api';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||||
|
|
||||||
|
interface StorySelectionToolbarProps {
|
||||||
|
selectedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
isSelectionMode: boolean;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
onBatchOperation: (operation: string) => void;
|
||||||
|
selectedStoryIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StorySelectionToolbar({
|
||||||
|
selectedCount,
|
||||||
|
totalCount,
|
||||||
|
isSelectionMode,
|
||||||
|
onSelectAll,
|
||||||
|
onClearSelection,
|
||||||
|
onBatchOperation,
|
||||||
|
selectedStoryIds
|
||||||
|
}: StorySelectionToolbarProps) {
|
||||||
|
const [showAddToCollection, setShowAddToCollection] = useState(false);
|
||||||
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
|
const [loadingCollections, setLoadingCollections] = useState(false);
|
||||||
|
const [addingToCollection, setAddingToCollection] = useState(false);
|
||||||
|
const [newCollectionName, setNewCollectionName] = useState('');
|
||||||
|
const [showCreateNew, setShowCreateNew] = useState(false);
|
||||||
|
|
||||||
|
const loadCollections = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingCollections(true);
|
||||||
|
const result = await collectionApi.getCollections({
|
||||||
|
page: 0,
|
||||||
|
limit: 50,
|
||||||
|
archived: false,
|
||||||
|
});
|
||||||
|
setCollections(result.results || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load collections:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingCollections(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowAddToCollection = async () => {
|
||||||
|
setShowAddToCollection(true);
|
||||||
|
await loadCollections();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToExistingCollection = async (collectionId: string) => {
|
||||||
|
try {
|
||||||
|
setAddingToCollection(true);
|
||||||
|
await collectionApi.addStoriesToCollection(collectionId, selectedStoryIds);
|
||||||
|
setShowAddToCollection(false);
|
||||||
|
onBatchOperation('addToCollection');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add stories to collection:', error);
|
||||||
|
} finally {
|
||||||
|
setAddingToCollection(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNewCollection = async () => {
|
||||||
|
if (!newCollectionName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setAddingToCollection(true);
|
||||||
|
const collection = await collectionApi.createCollection({
|
||||||
|
name: newCollectionName.trim(),
|
||||||
|
storyIds: selectedStoryIds,
|
||||||
|
});
|
||||||
|
setShowAddToCollection(false);
|
||||||
|
setNewCollectionName('');
|
||||||
|
setShowCreateNew(false);
|
||||||
|
onBatchOperation('createCollection');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create collection:', error);
|
||||||
|
} finally {
|
||||||
|
setAddingToCollection(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isSelectionMode && selectedCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
{/* Selection Info */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="font-medium text-blue-900 dark:text-blue-100">
|
||||||
|
{selectedCount} of {totalCount} stories selected
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={selectedCount === totalCount ? onClearSelection : onSelectAll}
|
||||||
|
className="text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{selectedCount === totalCount ? 'Deselect All' : 'Select All'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
className="text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Batch Actions */}
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleShowAddToCollection}
|
||||||
|
disabled={addingToCollection}
|
||||||
|
>
|
||||||
|
Add to Collection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add to Collection Modal */}
|
||||||
|
{showAddToCollection && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="theme-card max-w-lg w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b theme-border">
|
||||||
|
<h2 className="text-xl font-semibold theme-header">
|
||||||
|
Add {selectedCount} Stories to Collection
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowAddToCollection(false);
|
||||||
|
setShowCreateNew(false);
|
||||||
|
setNewCollectionName('');
|
||||||
|
}}
|
||||||
|
disabled={addingToCollection}
|
||||||
|
className="text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{loadingCollections ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<LoadingSpinner size="md" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Create New Collection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateNew(!showCreateNew)}
|
||||||
|
className="w-full p-3 border-2 border-dashed theme-border rounded-lg theme-text hover:border-gray-400 transition-colors"
|
||||||
|
>
|
||||||
|
+ Create New Collection
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showCreateNew && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newCollectionName}
|
||||||
|
onChange={(e) => setNewCollectionName(e.target.value)}
|
||||||
|
placeholder="Enter collection name"
|
||||||
|
className="w-full px-3 py-2 border theme-border rounded-lg theme-card theme-text focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleCreateNewCollection();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCreateNewCollection}
|
||||||
|
disabled={!newCollectionName.trim() || addingToCollection}
|
||||||
|
>
|
||||||
|
{addingToCollection ? <LoadingSpinner size="sm" /> : 'Create'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateNew(false);
|
||||||
|
setNewCollectionName('');
|
||||||
|
}}
|
||||||
|
disabled={addingToCollection}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing Collections */}
|
||||||
|
{collections.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-medium theme-text">Add to Existing Collection:</h3>
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{collections.map((collection) => (
|
||||||
|
<button
|
||||||
|
key={collection.id}
|
||||||
|
onClick={() => handleAddToExistingCollection(collection.id)}
|
||||||
|
disabled={addingToCollection}
|
||||||
|
className="w-full p-3 text-left theme-card hover:border-gray-400 border theme-border rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<div className="font-medium theme-text">{collection.name}</div>
|
||||||
|
<div className="text-sm theme-text opacity-70">
|
||||||
|
{collection.storyCount} stories
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{collections.length === 0 && !loadingCollections && (
|
||||||
|
<div className="text-center py-8 theme-text opacity-70">
|
||||||
|
No collections found. Create a new one above.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 '';
|
||||||
|
|||||||
@@ -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
BIN
pinch-and-twist.epub
Normal file
Binary file not shown.
642
storycove-collections-spec.md
Normal file
642
storycove-collections-spec.md
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
# StoryCove - Story Collections Feature Specification
|
||||||
|
|
||||||
|
## 1. Feature Overview
|
||||||
|
|
||||||
|
Story Collections allow users to organize stories into ordered lists for better content management and reading workflows. Collections support custom ordering, metadata, and provide an enhanced reading experience for grouped content.
|
||||||
|
|
||||||
|
### 1.1 Core Capabilities
|
||||||
|
- Create and manage ordered lists of stories
|
||||||
|
- Stories can belong to multiple collections
|
||||||
|
- Drag-and-drop reordering
|
||||||
|
- Collection-level metadata and ratings
|
||||||
|
- Dedicated collection reading flow
|
||||||
|
- Batch operations on collection contents
|
||||||
|
|
||||||
|
## 2. Data Model Updates
|
||||||
|
|
||||||
|
### 2.1 New Database Tables
|
||||||
|
|
||||||
|
#### Collections Table
|
||||||
|
```sql
|
||||||
|
CREATE TABLE collections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(500) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
rating INTEGER CHECK (rating >= 1 AND rating <= 5),
|
||||||
|
cover_image_path VARCHAR(500),
|
||||||
|
is_archived BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_collections_archived ON collections(is_archived);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Collection Stories Junction Table
|
||||||
|
```sql
|
||||||
|
CREATE TABLE collection_stories (
|
||||||
|
collection_id UUID NOT NULL,
|
||||||
|
story_id UUID NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (collection_id, story_id),
|
||||||
|
FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(collection_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_collection_stories_position ON collection_stories(collection_id, position);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Collection Tags Junction Table
|
||||||
|
```sql
|
||||||
|
CREATE TABLE collection_tags (
|
||||||
|
collection_id UUID NOT NULL,
|
||||||
|
tag_id UUID NOT NULL,
|
||||||
|
PRIMARY KEY (collection_id, tag_id),
|
||||||
|
FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Typesense Schema Update
|
||||||
|
|
||||||
|
Add new collection schema:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "collections",
|
||||||
|
"fields": [
|
||||||
|
{"name": "id", "type": "string"},
|
||||||
|
{"name": "name", "type": "string"},
|
||||||
|
{"name": "description", "type": "string", "optional": true},
|
||||||
|
{"name": "tags", "type": "string[]", "optional": true},
|
||||||
|
{"name": "story_count", "type": "int32"},
|
||||||
|
{"name": "total_word_count", "type": "int32"},
|
||||||
|
{"name": "rating", "type": "int32", "optional": true},
|
||||||
|
{"name": "is_archived", "type": "bool"},
|
||||||
|
{"name": "created_at", "type": "int64"},
|
||||||
|
{"name": "updated_at", "type": "int64"}
|
||||||
|
],
|
||||||
|
"default_sorting_field": "updated_at"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. API Specification
|
||||||
|
|
||||||
|
### 3.1 Collection Endpoints
|
||||||
|
|
||||||
|
#### GET /api/collections
|
||||||
|
**IMPORTANT**: This endpoint MUST use Typesense for all search and filtering operations.
|
||||||
|
Do NOT implement search/filter logic using JPA/SQL queries.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- `page` (integer): Page number
|
||||||
|
- `limit` (integer): Items per page
|
||||||
|
- `search` (string): Search in name and description (via Typesense)
|
||||||
|
- `tags` (string[]): Filter by tags (via Typesense)
|
||||||
|
- `archived` (boolean): Include archived collections (via Typesense filter)
|
||||||
|
|
||||||
|
Implementation note:
|
||||||
|
```java
|
||||||
|
// CORRECT: Use Typesense
|
||||||
|
return typesenseService.searchCollections(search, tags, archived, page, limit);
|
||||||
|
|
||||||
|
// INCORRECT: Do not use repository queries like this
|
||||||
|
// return collectionRepository.findByNameContainingAndTags(...);
|
||||||
|
```
|
||||||
|
|
||||||
|
Response includes:
|
||||||
|
- Collection metadata
|
||||||
|
- Story count
|
||||||
|
- Average story rating
|
||||||
|
- Total word count
|
||||||
|
- Estimated reading time
|
||||||
|
|
||||||
|
#### POST /api/collections
|
||||||
|
```json
|
||||||
|
Request (multipart/form-data):
|
||||||
|
{
|
||||||
|
"name": "string",
|
||||||
|
"description": "string",
|
||||||
|
"tags": ["string"],
|
||||||
|
"storyIds": ["uuid"], // Optional initial stories
|
||||||
|
"coverImage": "file (optional)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "string",
|
||||||
|
"description": "string",
|
||||||
|
"tags": ["string"],
|
||||||
|
"storyCount": 0,
|
||||||
|
"averageStoryRating": null,
|
||||||
|
"rating": null,
|
||||||
|
"createdAt": "timestamp"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /api/collections/{id}
|
||||||
|
Returns full collection details with ordered story list
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "string",
|
||||||
|
"description": "string",
|
||||||
|
"tags": ["string"],
|
||||||
|
"rating": 1-5,
|
||||||
|
"coverImagePath": "string",
|
||||||
|
"storyCount": "integer",
|
||||||
|
"totalWordCount": "integer",
|
||||||
|
"estimatedReadingTime": "integer (minutes)",
|
||||||
|
"averageStoryRating": "float",
|
||||||
|
"stories": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"title": "string",
|
||||||
|
"author": "string",
|
||||||
|
"wordCount": "integer",
|
||||||
|
"rating": 1-5,
|
||||||
|
"coverImagePath": "string",
|
||||||
|
"position": "integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"createdAt": "timestamp",
|
||||||
|
"updatedAt": "timestamp"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PUT /api/collections/{id}
|
||||||
|
Update collection metadata (same structure as POST without storyIds)
|
||||||
|
|
||||||
|
#### DELETE /api/collections/{id}
|
||||||
|
Delete a collection (stories remain in the system)
|
||||||
|
|
||||||
|
#### PUT /api/collections/{id}/archive
|
||||||
|
Archive/unarchive a collection
|
||||||
|
```json
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"archived": boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Collection Story Management
|
||||||
|
|
||||||
|
#### POST /api/collections/{id}/stories
|
||||||
|
Add stories to collection
|
||||||
|
```json
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"storyIds": ["uuid"],
|
||||||
|
"position": "integer" // Optional, defaults to end
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"added": 3,
|
||||||
|
"skipped": 1, // Already in collection
|
||||||
|
"totalStories": 15
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PUT /api/collections/{id}/stories/order
|
||||||
|
Reorder stories in collection
|
||||||
|
```json
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"storyOrders": [
|
||||||
|
{"storyId": "uuid", "position": 1},
|
||||||
|
{"storyId": "uuid", "position": 2}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DELETE /api/collections/{id}/stories/{storyId}
|
||||||
|
Remove a story from collection
|
||||||
|
|
||||||
|
#### GET /api/collections/{id}/read/{storyId}
|
||||||
|
Get story content with collection navigation context
|
||||||
|
```json
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"story": { /* full story data */ },
|
||||||
|
"collection": {
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "string",
|
||||||
|
"currentPosition": 3,
|
||||||
|
"totalStories": 10,
|
||||||
|
"previousStoryId": "uuid",
|
||||||
|
"nextStoryId": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Batch Operations
|
||||||
|
|
||||||
|
#### POST /api/stories/batch/add-to-collection
|
||||||
|
Add multiple stories to a collection
|
||||||
|
```json
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"storyIds": ["uuid"],
|
||||||
|
"collectionId": "uuid" // null to create new
|
||||||
|
"newCollectionName": "string" // if creating new
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Collection Statistics
|
||||||
|
|
||||||
|
#### GET /api/collections/{id}/stats
|
||||||
|
Detailed statistics for a collection
|
||||||
|
```json
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"totalStories": 15,
|
||||||
|
"totalWordCount": 125000,
|
||||||
|
"estimatedReadingTime": 625, // minutes
|
||||||
|
"averageStoryRating": 4.2,
|
||||||
|
"averageWordCount": 8333,
|
||||||
|
"tagFrequency": {
|
||||||
|
"fantasy": 12,
|
||||||
|
"adventure": 8
|
||||||
|
},
|
||||||
|
"authorDistribution": [
|
||||||
|
{"authorName": "string", "storyCount": 5}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. UI/UX Specifications
|
||||||
|
|
||||||
|
### 4.1 Navigation Updates
|
||||||
|
Add "Collections" to the main navigation menu, same level as "Stories" and "Authors"
|
||||||
|
|
||||||
|
### 4.2 Collections Overview Page
|
||||||
|
- Grid/List view toggle
|
||||||
|
- Collection cards showing:
|
||||||
|
- Cover image (or first 4 story covers as mosaic)
|
||||||
|
- Name and description preview
|
||||||
|
- Story count and total reading time
|
||||||
|
- Rating stars
|
||||||
|
- Tag badges
|
||||||
|
- **Pagination controls** (page size selector, page navigation)
|
||||||
|
- Filter by tags
|
||||||
|
- Search collections
|
||||||
|
- "Create New Collection" button
|
||||||
|
- Archive toggle
|
||||||
|
|
||||||
|
**IMPORTANT**: This view MUST use pagination via Typesense. Do NOT load all collections at once.
|
||||||
|
Default page size: 20 collections per page (configurable: 10, 20, 50)
|
||||||
|
|
||||||
|
### 4.3 Collection Creation/Edit Modal
|
||||||
|
- Name input (required)
|
||||||
|
- Description textarea
|
||||||
|
- Tag input with autocomplete
|
||||||
|
- Cover image upload
|
||||||
|
- Initial story selection (optional):
|
||||||
|
- Search and filter stories
|
||||||
|
- Checkbox selection
|
||||||
|
- Selected stories preview
|
||||||
|
|
||||||
|
### 4.4 Collection Detail View
|
||||||
|
- Header section:
|
||||||
|
- Cover image or story mosaic
|
||||||
|
- Collection name (editable inline)
|
||||||
|
- Description (editable inline)
|
||||||
|
- Statistics bar: X stories • Y hours reading time • Average rating
|
||||||
|
- Action buttons: Read Collection, Edit, Export (Phase 2), Archive, Delete
|
||||||
|
- Story list section:
|
||||||
|
- Drag-and-drop reordering (drag handle on each row)
|
||||||
|
- Story cards with position number
|
||||||
|
- Remove from collection button
|
||||||
|
- Add stories button
|
||||||
|
- Rating section:
|
||||||
|
- Collection rating (manual)
|
||||||
|
- Average story rating (calculated)
|
||||||
|
|
||||||
|
### 4.5 Story List View Updates
|
||||||
|
- Multi-select mode:
|
||||||
|
- Checkbox appears on hover/in mobile
|
||||||
|
- Selection toolbar with "Add to Collection" button
|
||||||
|
- Create new or add to existing collection
|
||||||
|
|
||||||
|
### 4.6 Story Detail View Updates
|
||||||
|
- "Add to Collection" button in the action bar
|
||||||
|
- "Part of Collections" section showing which collections include this story
|
||||||
|
|
||||||
|
### 4.7 Reading View Updates
|
||||||
|
When reading from a collection:
|
||||||
|
- Collection name and progress (Story 3 of 15) in header
|
||||||
|
- Previous/Next navigation uses collection order
|
||||||
|
- "Back to Collection" button
|
||||||
|
- Progress bar showing position in collection
|
||||||
|
|
||||||
|
### 4.8 Responsive Design Considerations
|
||||||
|
- Mobile: Single column layout, bottom sheet for actions
|
||||||
|
- Tablet: Two-column layout for collection detail
|
||||||
|
- Desktop: Full drag-and-drop, hover states
|
||||||
|
|
||||||
|
## 5. Technical Implementation Details
|
||||||
|
|
||||||
|
### 5.1 Frontend Updates
|
||||||
|
|
||||||
|
#### State Management
|
||||||
|
```typescript
|
||||||
|
// Collection context for managing active collection during reading
|
||||||
|
interface CollectionReadingContext {
|
||||||
|
collectionId: string;
|
||||||
|
currentPosition: number;
|
||||||
|
totalStories: number;
|
||||||
|
stories: StoryPreview[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag and drop using @dnd-kit/sortable
|
||||||
|
interface DragEndEvent {
|
||||||
|
active: { id: string };
|
||||||
|
over: { id: string };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Components Structure
|
||||||
|
```
|
||||||
|
components/
|
||||||
|
collections/
|
||||||
|
CollectionCard.tsx
|
||||||
|
CollectionGrid.tsx
|
||||||
|
CollectionForm.tsx
|
||||||
|
CollectionDetailView.tsx
|
||||||
|
StoryReorderList.tsx
|
||||||
|
AddToCollectionModal.tsx
|
||||||
|
stories/
|
||||||
|
StoryMultiSelect.tsx
|
||||||
|
StorySelectionToolbar.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pagination Implementation
|
||||||
|
```typescript
|
||||||
|
// Collections overview MUST use pagination
|
||||||
|
interface CollectionsPageState {
|
||||||
|
page: number;
|
||||||
|
pageSize: number; // 10, 20, or 50
|
||||||
|
totalPages: number;
|
||||||
|
totalCollections: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch collections with pagination via Typesense
|
||||||
|
const fetchCollections = async (page: number, pageSize: number, filters: any) => {
|
||||||
|
// MUST use Typesense API with pagination params
|
||||||
|
const response = await api.get('/api/collections', {
|
||||||
|
params: {
|
||||||
|
page,
|
||||||
|
limit: pageSize,
|
||||||
|
...filters
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component must handle:
|
||||||
|
// - Page navigation (previous/next, direct page input)
|
||||||
|
// - Page size selection
|
||||||
|
// - Maintaining filters/search across page changes
|
||||||
|
// - URL state sync for shareable/bookmarkable pages
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Backend Updates
|
||||||
|
|
||||||
|
#### Service Layer
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
public class CollectionService {
|
||||||
|
@Autowired
|
||||||
|
private TypesenseService typesenseService;
|
||||||
|
|
||||||
|
// IMPORTANT: All search and filtering MUST use Typesense, not JPA queries
|
||||||
|
public Page<Collection> searchCollections(String query, List<String> tags, boolean includeArchived) {
|
||||||
|
// Use typesenseService.searchCollections() - NOT repository queries
|
||||||
|
return typesenseService.searchCollections(query, tags, includeArchived);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate statistics dynamically
|
||||||
|
public CollectionStats calculateStats(UUID collectionId);
|
||||||
|
|
||||||
|
// Validate no duplicate stories
|
||||||
|
public void validateStoryAddition(UUID collectionId, List<UUID> storyIds);
|
||||||
|
|
||||||
|
// Reorder with gap-based positioning
|
||||||
|
public void reorderStories(UUID collectionId, List<StoryOrder> newOrder);
|
||||||
|
|
||||||
|
// Cascade position updates on removal
|
||||||
|
public void removeStoryAndReorder(UUID collectionId, UUID storyId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Search Implementation Requirements
|
||||||
|
```java
|
||||||
|
// CRITICAL: All collection search operations MUST go through Typesense
|
||||||
|
// DO NOT implement search/filter logic in JPA repositories
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TypesenseService {
|
||||||
|
// Existing methods for stories...
|
||||||
|
|
||||||
|
// New methods for collections
|
||||||
|
public SearchResult<Collection> searchCollections(
|
||||||
|
String query,
|
||||||
|
List<String> tags,
|
||||||
|
boolean includeArchived,
|
||||||
|
int page,
|
||||||
|
int limit
|
||||||
|
) {
|
||||||
|
// Build Typesense query
|
||||||
|
// Handle text search, tag filtering, archive status
|
||||||
|
// Return paginated results
|
||||||
|
}
|
||||||
|
|
||||||
|
public void indexCollection(Collection collection) {
|
||||||
|
// Index/update collection in Typesense
|
||||||
|
// Include calculated fields like story_count, total_word_count
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeCollection(UUID collectionId) {
|
||||||
|
// Remove from Typesense index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository should ONLY be used for:
|
||||||
|
// - CRUD operations by ID
|
||||||
|
// - Relationship management
|
||||||
|
// - Position updates
|
||||||
|
// NOT for search, filtering, or listing with criteria
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Position Management Strategy
|
||||||
|
Use gap-based positioning (multiples of 1000) to minimize reorder updates:
|
||||||
|
- Initial positions: 1000, 2000, 3000...
|
||||||
|
- Insert between: (prev + next) / 2
|
||||||
|
- Rebalance when gaps get too small
|
||||||
|
|
||||||
|
### 5.3 Performance Optimizations
|
||||||
|
|
||||||
|
1. **Lazy Loading**: Load collection stories in batches
|
||||||
|
2. **Caching**: Cache collection statistics for 5 minutes
|
||||||
|
3. **Batch Operations**: Multi-story operations in single transaction
|
||||||
|
4. **Optimistic Updates**: Immediate UI updates for reordering
|
||||||
|
|
||||||
|
### 5.4 Critical Implementation Guidelines
|
||||||
|
|
||||||
|
#### Search and Filtering Architecture
|
||||||
|
**MANDATORY**: All search, filtering, and listing operations MUST use Typesense as the primary data source.
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Controller pattern for ALL list/search endpoints
|
||||||
|
@GetMapping("/api/collections")
|
||||||
|
public ResponseEntity<Page<CollectionDTO>> getCollections(
|
||||||
|
@RequestParam(required = false) String search,
|
||||||
|
@RequestParam(required = false) List<String> tags,
|
||||||
|
@RequestParam(defaultValue = "false") boolean archived,
|
||||||
|
Pageable pageable
|
||||||
|
) {
|
||||||
|
// MUST delegate to Typesense service
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
typesenseService.searchCollections(search, tags, archived, pageable)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service layer MUST use Typesense
|
||||||
|
// NEVER implement search logic in repositories
|
||||||
|
// Repository pattern should ONLY be used for:
|
||||||
|
// 1. Direct ID lookups
|
||||||
|
// 2. Saving/updating entities
|
||||||
|
// 3. Managing relationships
|
||||||
|
// 4. NOT for searching, filtering, or conditional queries
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Synchronization Strategy
|
||||||
|
1. On every collection create/update/delete → immediately sync with Typesense
|
||||||
|
2. Include denormalized data in Typesense documents (story count, word count, etc.)
|
||||||
|
3. Use database only as source of truth for relationships and detailed data
|
||||||
|
4. Use Typesense for all discovery operations (search, filter, list)
|
||||||
|
|
||||||
|
## 6. Migration and Upgrade Path
|
||||||
|
|
||||||
|
### 6.1 Database Migration
|
||||||
|
```sql
|
||||||
|
-- Run migrations in order
|
||||||
|
-- 1. Create new tables
|
||||||
|
-- 2. Add indexes
|
||||||
|
-- 3. No data migration needed (new feature)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Search Index Update
|
||||||
|
- Deploy new Typesense schema
|
||||||
|
- No reindexing needed for existing stories
|
||||||
|
|
||||||
|
## 7. Testing Requirements
|
||||||
|
|
||||||
|
### 7.1 Unit Tests
|
||||||
|
- Collection CRUD operations
|
||||||
|
- Position management logic
|
||||||
|
- Statistics calculation
|
||||||
|
- Duplicate prevention
|
||||||
|
|
||||||
|
### 7.2 Integration Tests
|
||||||
|
- Multi-story batch operations
|
||||||
|
- Drag-and-drop reordering
|
||||||
|
- Collection reading flow
|
||||||
|
- Search and filtering
|
||||||
|
|
||||||
|
### 7.3 E2E Tests
|
||||||
|
- Create collection from multiple entry points
|
||||||
|
- Complete reading flow through collection
|
||||||
|
- Reorder and verify persistence
|
||||||
|
|
||||||
|
## 8. Future Enhancements (Phase 2+)
|
||||||
|
|
||||||
|
1. **Collection Templates**: Pre-configured collection types
|
||||||
|
2. **Smart Collections**: Auto-populate based on criteria
|
||||||
|
3. **Collection Sharing**: Generate shareable links
|
||||||
|
4. **Reading Progress**: Track progress through collections
|
||||||
|
5. **Export Collections**: PDF/EPUB with proper ordering
|
||||||
|
6. **Collection Recommendations**: Based on reading patterns
|
||||||
|
7. **Nested Collections**: Collections within collections
|
||||||
|
8. **Collection Permissions**: For multi-user scenarios
|
||||||
|
|
||||||
|
## 9. Implementation Checklist
|
||||||
|
|
||||||
|
### Backend Tasks
|
||||||
|
- [ ] Create database migrations
|
||||||
|
- [ ] Implement entity models
|
||||||
|
- [ ] Create repository interfaces
|
||||||
|
- [ ] Implement service layer with business logic
|
||||||
|
- [ ] Create REST controllers
|
||||||
|
- [ ] Add validation and error handling
|
||||||
|
- [ ] Update Typesense sync logic
|
||||||
|
- [ ] Write unit and integration tests
|
||||||
|
|
||||||
|
### Frontend Tasks
|
||||||
|
- [ ] Update navigation structure
|
||||||
|
- [ ] Create collection components
|
||||||
|
- [ ] Implement drag-and-drop reordering
|
||||||
|
- [ ] Add multi-select to story list
|
||||||
|
- [ ] Update story detail view
|
||||||
|
- [ ] Implement collection reading flow
|
||||||
|
- [ ] Add loading and error states
|
||||||
|
- [ ] Write component tests
|
||||||
|
|
||||||
|
### Documentation Tasks
|
||||||
|
- [ ] Update API documentation
|
||||||
|
- [ ] Create user guide for collections
|
||||||
|
- [ ] Document position management strategy
|
||||||
|
- [ ] Add collection examples to README
|
||||||
|
|
||||||
|
## 10. Acceptance Criteria
|
||||||
|
|
||||||
|
1. Users can create, edit, and delete collections
|
||||||
|
2. Stories can be added/removed from collections without duplication
|
||||||
|
3. Collection order persists across sessions
|
||||||
|
4. Drag-and-drop reordering works smoothly
|
||||||
|
5. Collection statistics update in real-time
|
||||||
|
6. Reading flow respects collection order
|
||||||
|
7. **Search and filtering work for collections using Typesense (NOT database queries)**
|
||||||
|
8. All actions are validated and provide clear feedback
|
||||||
|
9. Performance remains smooth with large collections (100+ stories)
|
||||||
|
10. Mobile experience is fully functional
|
||||||
|
|
||||||
|
## 11. Implementation Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
### CRITICAL: Search Implementation
|
||||||
|
The following patterns MUST NOT be used for search/filter operations:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// ❌ WRONG - Do not use JPA/Repository for search
|
||||||
|
@Repository
|
||||||
|
public interface CollectionRepository extends JpaRepository<Collection, UUID> {
|
||||||
|
// DO NOT ADD THESE METHODS:
|
||||||
|
List<Collection> findByNameContaining(String name);
|
||||||
|
List<Collection> findByTagsIn(List<String> tags);
|
||||||
|
Page<Collection> findByNameContainingAndArchived(String name, boolean archived, Pageable pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ WRONG - Do not implement search in service using repositories
|
||||||
|
public Page<Collection> searchCollections(String query) {
|
||||||
|
return collectionRepository.findByNameContaining(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ CORRECT - Always use Typesense for search/filter
|
||||||
|
public Page<Collection> searchCollections(String query, List<String> tags) {
|
||||||
|
SearchResult result = typesenseClient.collections("collections")
|
||||||
|
.documents()
|
||||||
|
.search(searchParameters);
|
||||||
|
return convertToPage(result);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remember:
|
||||||
|
1. **Typesense** = Search, filter, list, discover (with pagination)
|
||||||
|
2. **Database** = Store, retrieve by ID, manage relationships
|
||||||
|
3. **Never** mix search logic between the two systems
|
||||||
|
4. **Always** paginate list views - never load all items at once
|
||||||
Reference in New Issue
Block a user