From f95d7aa8bb92e4d8fef8ffdd27b268058384f81d Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Sat, 26 Jul 2025 12:05:54 +0200 Subject: [PATCH] Various Fixes and QoL enhancements. --- backend/backend.log | 1 + .../controller/CollectionController.java | 46 ++++++- .../controller/ConfigController.java | 54 ++++++++ .../HtmlSanitizationController.java | 31 ----- .../storycove/controller/StoryController.java | 35 ++++- .../storycove/controller/TagController.java | 22 ++++ .../java/com/storycove/dto/CollectionDto.java | 9 ++ .../main/java/com/storycove/dto/TagDto.java | 9 ++ .../java/com/storycove/entity/Collection.java | 12 ++ .../main/java/com/storycove/entity/Tag.java | 12 ++ .../repository/CollectionRepository.java | 6 + .../storycove/repository/StoryRepository.java | 3 + .../storycove/repository/TagRepository.java | 3 + .../service/CollectionSearchResult.java | 9 +- .../storycove/service/CollectionService.java | 14 +- .../storycove/service/ReadingTimeService.java | 28 ++++ .../com/storycove/service/StoryService.java | 8 ++ .../com/storycove/service/TagService.java | 5 + .../storycove/service/TypesenseService.java | 87 ++++++++----- frontend/src/app/add-story/page.tsx | 123 +++++++++++++++++- frontend/src/app/authors/[id]/page.tsx | 11 +- frontend/src/app/collections/page.tsx | 40 ++++-- frontend/src/app/library/page.tsx | 69 +++++++--- frontend/src/app/settings/page.tsx | 29 +++++ frontend/src/app/stories/[id]/detail/page.tsx | 5 +- .../src/components/stories/RichTextEditor.tsx | 105 +++++++++++++-- frontend/src/components/stories/TagFilter.tsx | 16 ++- frontend/src/contexts/AuthContext.tsx | 16 ++- frontend/src/lib/api.ts | 48 ++++++- frontend/src/lib/settings.ts | 33 +++++ frontend/src/types/api.ts | 3 + frontend/tsconfig.tsbuildinfo | 2 +- 32 files changed, 758 insertions(+), 136 deletions(-) create mode 100644 backend/backend.log create mode 100644 backend/src/main/java/com/storycove/controller/ConfigController.java delete mode 100644 backend/src/main/java/com/storycove/controller/HtmlSanitizationController.java create mode 100644 backend/src/main/java/com/storycove/service/ReadingTimeService.java create mode 100644 frontend/src/lib/settings.ts diff --git a/backend/backend.log b/backend/backend.log new file mode 100644 index 0000000..687fd33 --- /dev/null +++ b/backend/backend.log @@ -0,0 +1 @@ +(eval):1: no such file or directory: ./mvnw diff --git a/backend/src/main/java/com/storycove/controller/CollectionController.java b/backend/src/main/java/com/storycove/controller/CollectionController.java index 0bb7e70..d315e67 100644 --- a/backend/src/main/java/com/storycove/controller/CollectionController.java +++ b/backend/src/main/java/com/storycove/controller/CollectionController.java @@ -7,6 +7,8 @@ import com.storycove.entity.Story; import com.storycove.entity.Tag; import com.storycove.service.CollectionService; import com.storycove.service.ImageService; +import com.storycove.service.ReadingTimeService; +import com.storycove.service.TypesenseService; import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,12 +30,18 @@ public class CollectionController { private final CollectionService collectionService; private final ImageService imageService; + private final TypesenseService typesenseService; + private final ReadingTimeService readingTimeService; @Autowired public CollectionController(CollectionService collectionService, - ImageService imageService) { + ImageService imageService, + @Autowired(required = false) TypesenseService typesenseService, + ReadingTimeService readingTimeService) { this.collectionService = collectionService; this.imageService = imageService; + this.typesenseService = typesenseService; + this.readingTimeService = readingTimeService; } /** @@ -270,6 +278,35 @@ public class CollectionController { return ResponseEntity.ok(Map.of("message", "Cover removed successfully")); } + /** + * POST /api/collections/reindex-typesense - Reindex all collections in Typesense + */ + @PostMapping("/reindex-typesense") + public ResponseEntity> reindexCollectionsTypesense() { + try { + List allCollections = collectionService.findAllWithTags(); + if (typesenseService != null) { + typesenseService.reindexAllCollections(allCollections); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Successfully reindexed all collections", + "count", allCollections.size() + )); + } else { + return ResponseEntity.ok(Map.of( + "success", false, + "message", "Typesense service not available" + )); + } + } catch (Exception e) { + logger.error("Failed to reindex collections", e); + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", e.getMessage() + )); + } + } + // Mapper methods private CollectionDto mapToCollectionDto(Collection collection) { @@ -290,6 +327,11 @@ public class CollectionController { .toList()); } + // Map tag names for search results + if (collection.getTagNames() != null) { + dto.setTagNames(collection.getTagNames()); + } + // Map collection stories (lightweight) if (collection.getCollectionStories() != null) { dto.setCollectionStories(collection.getCollectionStories().stream() @@ -300,7 +342,7 @@ public class CollectionController { // Set calculated properties dto.setStoryCount(collection.getStoryCount()); dto.setTotalWordCount(collection.getTotalWordCount()); - dto.setEstimatedReadingTime(collection.getEstimatedReadingTime()); + dto.setEstimatedReadingTime(readingTimeService.calculateReadingTime(collection.getTotalWordCount())); dto.setAverageStoryRating(collection.getAverageStoryRating()); return dto; diff --git a/backend/src/main/java/com/storycove/controller/ConfigController.java b/backend/src/main/java/com/storycove/controller/ConfigController.java new file mode 100644 index 0000000..8e22bfa --- /dev/null +++ b/backend/src/main/java/com/storycove/controller/ConfigController.java @@ -0,0 +1,54 @@ +package com.storycove.controller; + +import com.storycove.dto.HtmlSanitizationConfigDto; +import com.storycove.service.HtmlSanitizationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/config") +public class ConfigController { + + private final HtmlSanitizationService htmlSanitizationService; + + @Value("${app.reading.speed.default:200}") + private int defaultReadingSpeed; + + @Autowired + public ConfigController(HtmlSanitizationService htmlSanitizationService) { + this.htmlSanitizationService = htmlSanitizationService; + } + + /** + * Get the HTML sanitization configuration for frontend use + * This allows the frontend to use the same sanitization rules as the backend + */ + @GetMapping("/html-sanitization") + public ResponseEntity getHtmlSanitizationConfig() { + HtmlSanitizationConfigDto config = htmlSanitizationService.getConfiguration(); + return ResponseEntity.ok(config); + } + + /** + * Get application settings configuration + */ + @GetMapping("/settings") + public ResponseEntity> getSettings() { + Map settings = Map.of( + "defaultReadingSpeed", defaultReadingSpeed + ); + return ResponseEntity.ok(settings); + } + + /** + * Get reading speed for calculation purposes + */ + @GetMapping("/reading-speed") + public ResponseEntity> getReadingSpeed() { + return ResponseEntity.ok(Map.of("wordsPerMinute", defaultReadingSpeed)); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/controller/HtmlSanitizationController.java b/backend/src/main/java/com/storycove/controller/HtmlSanitizationController.java deleted file mode 100644 index c5aa491..0000000 --- a/backend/src/main/java/com/storycove/controller/HtmlSanitizationController.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.storycove.controller; - -import com.storycove.dto.HtmlSanitizationConfigDto; -import com.storycove.service.HtmlSanitizationService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/config") -public class HtmlSanitizationController { - - private final HtmlSanitizationService htmlSanitizationService; - - @Autowired - public HtmlSanitizationController(HtmlSanitizationService htmlSanitizationService) { - this.htmlSanitizationService = htmlSanitizationService; - } - - /** - * Get the HTML sanitization configuration for frontend use - * This allows the frontend to use the same sanitization rules as the backend - */ - @GetMapping("/html-sanitization") - public ResponseEntity getHtmlSanitizationConfig() { - HtmlSanitizationConfigDto config = htmlSanitizationService.getConfiguration(); - return ResponseEntity.ok(config); - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/controller/StoryController.java b/backend/src/main/java/com/storycove/controller/StoryController.java index 107c124..31740d7 100644 --- a/backend/src/main/java/com/storycove/controller/StoryController.java +++ b/backend/src/main/java/com/storycove/controller/StoryController.java @@ -41,6 +41,7 @@ public class StoryController { private final ImageService imageService; private final TypesenseService typesenseService; private final CollectionService collectionService; + private final ReadingTimeService readingTimeService; public StoryController(StoryService storyService, AuthorService authorService, @@ -48,7 +49,8 @@ public class StoryController { HtmlSanitizationService sanitizationService, ImageService imageService, CollectionService collectionService, - @Autowired(required = false) TypesenseService typesenseService) { + @Autowired(required = false) TypesenseService typesenseService, + ReadingTimeService readingTimeService) { this.storyService = storyService; this.authorService = authorService; this.seriesService = seriesService; @@ -56,6 +58,7 @@ public class StoryController { this.imageService = imageService; this.collectionService = collectionService; this.typesenseService = typesenseService; + this.readingTimeService = readingTimeService; } @GetMapping @@ -467,12 +470,40 @@ public class StoryController { // to avoid circular references and keep it lightweight dto.setStoryCount(collection.getStoryCount()); dto.setTotalWordCount(collection.getTotalWordCount()); - dto.setEstimatedReadingTime(collection.getEstimatedReadingTime()); + dto.setEstimatedReadingTime(readingTimeService.calculateReadingTime(collection.getTotalWordCount())); dto.setAverageStoryRating(collection.getAverageStoryRating()); return dto; } + @GetMapping("/check-duplicate") + public ResponseEntity> checkDuplicate( + @RequestParam String title, + @RequestParam String authorName) { + try { + List duplicates = storyService.findPotentialDuplicates(title, authorName); + + Map response = Map.of( + "hasDuplicates", !duplicates.isEmpty(), + "count", duplicates.size(), + "duplicates", duplicates.stream() + .map(story -> Map.of( + "id", story.getId(), + "title", story.getTitle(), + "authorName", story.getAuthor() != null ? story.getAuthor().getName() : "", + "createdAt", story.getCreatedAt() + )) + .collect(Collectors.toList()) + ); + + return ResponseEntity.ok(response); + } catch (Exception e) { + logger.error("Error checking for duplicates", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to check for duplicates")); + } + } + // Request DTOs public static class CreateStoryRequest { private String title; diff --git a/backend/src/main/java/com/storycove/controller/TagController.java b/backend/src/main/java/com/storycove/controller/TagController.java index cace4a8..a5201a1 100644 --- a/backend/src/main/java/com/storycove/controller/TagController.java +++ b/backend/src/main/java/com/storycove/controller/TagController.java @@ -132,17 +132,39 @@ public class TagController { return ResponseEntity.ok(stats); } + @GetMapping("/collections") + public ResponseEntity> getTagsUsedByCollections() { + List tags = tagService.findTagsUsedByCollections(); + List tagDtos = tags.stream() + .map(this::convertToDtoWithCollectionCount) + .collect(Collectors.toList()); + + return ResponseEntity.ok(tagDtos); + } + private TagDto convertToDto(Tag tag) { TagDto dto = new TagDto(); dto.setId(tag.getId()); dto.setName(tag.getName()); dto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0); + dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0); dto.setCreatedAt(tag.getCreatedAt()); // updatedAt field not present in Tag entity per spec return dto; } + private TagDto convertToDtoWithCollectionCount(Tag tag) { + TagDto dto = new TagDto(); + dto.setId(tag.getId()); + dto.setName(tag.getName()); + dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0); + dto.setCreatedAt(tag.getCreatedAt()); + // Don't set storyCount for collection-focused endpoint + + return dto; + } + // Request DTOs public static class CreateTagRequest { private String name; diff --git a/backend/src/main/java/com/storycove/dto/CollectionDto.java b/backend/src/main/java/com/storycove/dto/CollectionDto.java index 290305d..ec45967 100644 --- a/backend/src/main/java/com/storycove/dto/CollectionDto.java +++ b/backend/src/main/java/com/storycove/dto/CollectionDto.java @@ -16,6 +16,7 @@ public class CollectionDto { private String coverImagePath; private Boolean isArchived; private List tags; + private List tagNames; // For search results private List collectionStories; private Integer storyCount; private Integer totalWordCount; @@ -83,6 +84,14 @@ public class CollectionDto { this.tags = tags; } + public List getTagNames() { + return tagNames; + } + + public void setTagNames(List tagNames) { + this.tagNames = tagNames; + } + public List getCollectionStories() { return collectionStories; } diff --git a/backend/src/main/java/com/storycove/dto/TagDto.java b/backend/src/main/java/com/storycove/dto/TagDto.java index 5f1f203..aa8f152 100644 --- a/backend/src/main/java/com/storycove/dto/TagDto.java +++ b/backend/src/main/java/com/storycove/dto/TagDto.java @@ -15,6 +15,7 @@ public class TagDto { private String name; private Integer storyCount; + private Integer collectionCount; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -49,6 +50,14 @@ public class TagDto { this.storyCount = storyCount; } + public Integer getCollectionCount() { + return collectionCount; + } + + public void setCollectionCount(Integer collectionCount) { + this.collectionCount = collectionCount; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/com/storycove/entity/Collection.java b/backend/src/main/java/com/storycove/entity/Collection.java index efd4251..4ef909c 100644 --- a/backend/src/main/java/com/storycove/entity/Collection.java +++ b/backend/src/main/java/com/storycove/entity/Collection.java @@ -52,6 +52,10 @@ public class Collection { ) private Set tags = new HashSet<>(); + // Transient field for search results - tag names only to avoid lazy loading issues + @Transient + private List tagNames; + @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @@ -192,6 +196,14 @@ public class Collection { this.tags = tags; } + public List getTagNames() { + return tagNames; + } + + public void setTagNames(List tagNames) { + this.tagNames = tagNames; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/com/storycove/entity/Tag.java b/backend/src/main/java/com/storycove/entity/Tag.java index 4f5867e..4b57944 100644 --- a/backend/src/main/java/com/storycove/entity/Tag.java +++ b/backend/src/main/java/com/storycove/entity/Tag.java @@ -29,6 +29,10 @@ public class Tag { @JsonBackReference("story-tags") private Set stories = new HashSet<>(); + @ManyToMany(mappedBy = "tags") + @JsonBackReference("collection-tags") + private Set collections = new HashSet<>(); + @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @@ -67,6 +71,14 @@ public class Tag { this.stories = stories; } + public Set getCollections() { + return collections; + } + + public void setCollections(Set collections) { + this.collections = collections; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/com/storycove/repository/CollectionRepository.java b/backend/src/main/java/com/storycove/repository/CollectionRepository.java index 30ffa38..2d95c96 100644 --- a/backend/src/main/java/com/storycove/repository/CollectionRepository.java +++ b/backend/src/main/java/com/storycove/repository/CollectionRepository.java @@ -45,4 +45,10 @@ public interface CollectionRepository extends JpaRepository { */ @Query("SELECT c FROM Collection c WHERE c.isArchived = false ORDER BY c.updatedAt DESC") List findAllActiveCollections(); + + /** + * Find all collections with tags for reindexing operations + */ + @Query("SELECT c FROM Collection c LEFT JOIN FETCH c.tags ORDER BY c.updatedAt DESC") + List findAllWithTags(); } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/repository/StoryRepository.java b/backend/src/main/java/com/storycove/repository/StoryRepository.java index 7db3190..38c040d 100644 --- a/backend/src/main/java/com/storycove/repository/StoryRepository.java +++ b/backend/src/main/java/com/storycove/repository/StoryRepository.java @@ -114,4 +114,7 @@ public interface StoryRepository extends JpaRepository { "LEFT JOIN FETCH s.series " + "LEFT JOIN FETCH s.tags") List findAllWithAssociations(); + + @Query("SELECT s FROM Story s WHERE UPPER(s.title) = UPPER(:title) AND UPPER(s.author.name) = UPPER(:authorName)") + List findByTitleAndAuthorNameIgnoreCase(@Param("title") String title, @Param("authorName") String authorName); } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/repository/TagRepository.java b/backend/src/main/java/com/storycove/repository/TagRepository.java index 077fc89..63d50b7 100644 --- a/backend/src/main/java/com/storycove/repository/TagRepository.java +++ b/backend/src/main/java/com/storycove/repository/TagRepository.java @@ -54,4 +54,7 @@ public interface TagRepository extends JpaRepository { @Query("SELECT COUNT(t) FROM Tag t WHERE SIZE(t.stories) > 0") long countUsedTags(); + + @Query("SELECT t FROM Tag t WHERE SIZE(t.collections) > 0 ORDER BY SIZE(t.collections) DESC, t.name ASC") + List findTagsUsedByCollections(); } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/CollectionSearchResult.java b/backend/src/main/java/com/storycove/service/CollectionSearchResult.java index a9a1eb9..d3ec35d 100644 --- a/backend/src/main/java/com/storycove/service/CollectionSearchResult.java +++ b/backend/src/main/java/com/storycove/service/CollectionSearchResult.java @@ -10,6 +10,7 @@ public class CollectionSearchResult extends Collection { private Integer storedStoryCount; private Integer storedTotalWordCount; + private int wordsPerMinute = 200; // Default, can be overridden public CollectionSearchResult(Collection collection) { this.setId(collection.getId()); @@ -20,6 +21,7 @@ public class CollectionSearchResult extends Collection { this.setCreatedAt(collection.getCreatedAt()); this.setUpdatedAt(collection.getUpdatedAt()); this.setCoverImagePath(collection.getCoverImagePath()); + this.setTagNames(collection.getTagNames()); // Copy tag names for search results // Note: don't copy collectionStories or tags to avoid lazy loading issues } @@ -31,6 +33,10 @@ public class CollectionSearchResult extends Collection { this.storedTotalWordCount = totalWordCount; } + public void setWordsPerMinute(int wordsPerMinute) { + this.wordsPerMinute = wordsPerMinute; + } + @Override public int getStoryCount() { return storedStoryCount != null ? storedStoryCount : 0; @@ -43,8 +49,7 @@ public class CollectionSearchResult extends Collection { @Override public int getEstimatedReadingTime() { - // Assuming 200 words per minute reading speed - return Math.max(1, getTotalWordCount() / 200); + return Math.max(1, getTotalWordCount() / wordsPerMinute); } @Override diff --git a/backend/src/main/java/com/storycove/service/CollectionService.java b/backend/src/main/java/com/storycove/service/CollectionService.java index 6c408cd..ae2f384 100644 --- a/backend/src/main/java/com/storycove/service/CollectionService.java +++ b/backend/src/main/java/com/storycove/service/CollectionService.java @@ -34,18 +34,21 @@ public class CollectionService { private final StoryRepository storyRepository; private final TagRepository tagRepository; private final TypesenseService typesenseService; + private final ReadingTimeService readingTimeService; @Autowired public CollectionService(CollectionRepository collectionRepository, CollectionStoryRepository collectionStoryRepository, StoryRepository storyRepository, TagRepository tagRepository, - @Autowired(required = false) TypesenseService typesenseService) { + @Autowired(required = false) TypesenseService typesenseService, + ReadingTimeService readingTimeService) { this.collectionRepository = collectionRepository; this.collectionStoryRepository = collectionStoryRepository; this.storyRepository = storyRepository; this.tagRepository = tagRepository; this.typesenseService = typesenseService; + this.readingTimeService = readingTimeService; } /** @@ -78,6 +81,13 @@ public class CollectionService { .orElseThrow(() -> new ResourceNotFoundException("Collection not found with id: " + id)); } + /** + * Find all collections with tags for reindexing + */ + public List findAllWithTags() { + return collectionRepository.findAllWithTags(); + } + /** * Create a new collection with optional initial stories */ @@ -344,7 +354,7 @@ public class CollectionService { 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 + int estimatedReadingTime = readingTimeService.calculateReadingTime(totalWordCount); double averageStoryRating = collectionStories.stream() .filter(cs -> cs.getStory().getRating() != null) diff --git a/backend/src/main/java/com/storycove/service/ReadingTimeService.java b/backend/src/main/java/com/storycove/service/ReadingTimeService.java new file mode 100644 index 0000000..f8736d5 --- /dev/null +++ b/backend/src/main/java/com/storycove/service/ReadingTimeService.java @@ -0,0 +1,28 @@ +package com.storycove.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class ReadingTimeService { + + @Value("${app.reading.speed.default:200}") + private int defaultWordsPerMinute; + + /** + * Calculate estimated reading time in minutes for the given word count + * @param wordCount the number of words to read + * @return estimated reading time in minutes (minimum 1 minute) + */ + public int calculateReadingTime(int wordCount) { + return Math.max(1, wordCount / defaultWordsPerMinute); + } + + /** + * Get the current words per minute setting + * @return words per minute reading speed + */ + public int getWordsPerMinute() { + return defaultWordsPerMinute; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/StoryService.java b/backend/src/main/java/com/storycove/service/StoryService.java index 14bce04..982b3ea 100644 --- a/backend/src/main/java/com/storycove/service/StoryService.java +++ b/backend/src/main/java/com/storycove/service/StoryService.java @@ -593,4 +593,12 @@ public class StoryService { } } } + + @Transactional(readOnly = true) + public List findPotentialDuplicates(String title, String authorName) { + if (title == null || title.trim().isEmpty() || authorName == null || authorName.trim().isEmpty()) { + return List.of(); + } + return storyRepository.findByTitleAndAuthorNameIgnoreCase(title.trim(), authorName.trim()); + } } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/TagService.java b/backend/src/main/java/com/storycove/service/TagService.java index 08b4b96..1bab88c 100644 --- a/backend/src/main/java/com/storycove/service/TagService.java +++ b/backend/src/main/java/com/storycove/service/TagService.java @@ -191,6 +191,11 @@ public class TagService { public long countUsedTags() { return tagRepository.countUsedTags(); } + + @Transactional(readOnly = true) + public List findTagsUsedByCollections() { + return tagRepository.findTagsUsedByCollections(); + } private void validateTagForCreate(Tag tag) { if (existsByName(tag.getName())) { diff --git a/backend/src/main/java/com/storycove/service/TypesenseService.java b/backend/src/main/java/com/storycove/service/TypesenseService.java index e5e99d5..890ef86 100644 --- a/backend/src/main/java/com/storycove/service/TypesenseService.java +++ b/backend/src/main/java/com/storycove/service/TypesenseService.java @@ -32,12 +32,15 @@ public class TypesenseService { private final Client typesenseClient; private final CollectionStoryRepository collectionStoryRepository; + private final ReadingTimeService readingTimeService; @Autowired public TypesenseService(Client typesenseClient, - @Autowired(required = false) CollectionStoryRepository collectionStoryRepository) { + @Autowired(required = false) CollectionStoryRepository collectionStoryRepository, + ReadingTimeService readingTimeService) { this.typesenseClient = typesenseClient; this.collectionStoryRepository = collectionStoryRepository; + this.readingTimeService = readingTimeService; } @PostConstruct @@ -65,19 +68,19 @@ public class TypesenseService { private void createStoriesCollection() throws Exception { List fields = Arrays.asList( new Field().name("id").type("string").facet(false), - new Field().name("title").type("string").facet(false), + new Field().name("title").type("string").facet(false).sort(true), new Field().name("summary").type("string").facet(false).optional(true), new Field().name("description").type("string").facet(false), new Field().name("contentPlain").type("string").facet(false), new Field().name("authorId").type("string").facet(true), - new Field().name("authorName").type("string").facet(true), + new Field().name("authorName").type("string").facet(true).sort(true), new Field().name("seriesId").type("string").facet(true).optional(true), - new Field().name("seriesName").type("string").facet(true).optional(true), + new Field().name("seriesName").type("string").facet(true).sort(true).optional(true), new Field().name("tagNames").type("string[]").facet(true).optional(true), - new Field().name("rating").type("int32").facet(true).optional(true), - new Field().name("wordCount").type("int32").facet(true).optional(true), - new Field().name("volume").type("int32").facet(true).optional(true), - new Field().name("createdAt").type("int64").facet(false), + new Field().name("rating").type("int32").facet(true).sort(true).optional(true), + new Field().name("wordCount").type("int32").facet(true).sort(true).optional(true), + new Field().name("volume").type("int32").facet(true).sort(true).optional(true), + new Field().name("createdAt").type("int64").facet(false).sort(true), new Field().name("sourceUrl").type("string").facet(false).optional(true), new Field().name("coverPath").type("string").facet(false).optional(true) ); @@ -101,6 +104,26 @@ public class TypesenseService { } } + /** + * Force recreate the stories collection, deleting it first if it exists + */ + public void recreateStoriesCollection() throws Exception { + try { + logger.info("Force deleting stories collection for recreation..."); + typesenseClient.collections(STORIES_COLLECTION).delete(); + logger.info("Successfully deleted stories collection"); + } catch (Exception e) { + logger.debug("Stories collection didn't exist for deletion: {}", e.getMessage()); + } + + // Wait a brief moment to ensure deletion is complete + Thread.sleep(100); + + logger.info("Creating stories collection with fresh schema..."); + createStoriesCollection(); + logger.info("Successfully created stories collection"); + } + /** * Force recreate the authors collection, deleting it first if it exists */ @@ -220,16 +243,14 @@ public class TypesenseService { if (tagFilters != null && !tagFilters.isEmpty()) { logger.info("SEARCH DEBUG: Processing {} tag filters: {}", tagFilters.size(), tagFilters); - String tagFilter = tagFilters.stream() - .map(tag -> { - String escaped = escapeTypesenseValue(tag); - String condition = "tagNames:=" + escaped; - logger.info("SEARCH DEBUG: Tag '{}' -> escaped '{}' -> condition '{}'", tag, escaped, condition); - return condition; - }) - .collect(Collectors.joining(" || ")); - logger.info("SEARCH DEBUG: Final tag filter condition: '{}'", tagFilter); - filterConditions.add("(" + tagFilter + ")"); + // Use AND logic for multiple tags - items must have ALL selected tags + for (String tag : tagFilters) { + String escaped = escapeTypesenseValue(tag); + String condition = "tagNames:=" + escaped; + logger.info("SEARCH DEBUG: Tag '{}' -> escaped '{}' -> condition '{}'", tag, escaped, condition); + filterConditions.add(condition); + } + logger.info("SEARCH DEBUG: Added {} individual tag filter conditions", tagFilters.size()); } if (minRating != null) { @@ -294,15 +315,8 @@ public class TypesenseService { public void reindexAllStories(List stories) { try { - // Clear existing collection - try { - typesenseClient.collections(STORIES_COLLECTION).delete(); - } catch (Exception e) { - logger.debug("Collection didn't exist for deletion: {}", e.getMessage()); - } - - // Recreate collection - createStoriesCollection(); + // Force recreate collection with proper schema + recreateStoriesCollection(); // Bulk index all stories bulkIndexStories(stories); @@ -1007,10 +1021,11 @@ public class TypesenseService { } if (tags != null && !tags.isEmpty()) { - String tagFilter = tags.stream() - .map(tag -> "tags:=" + escapeTypesenseValue(tag)) - .collect(Collectors.joining(" || ")); - filterConditions.add("(" + tagFilter + ")"); + // Use AND logic for multiple tags - collections must have ALL selected tags + for (String tag : tags) { + String condition = "tags:=" + escapeTypesenseValue(tag); + filterConditions.add(condition); + } } if (!filterConditions.isEmpty()) { @@ -1197,6 +1212,15 @@ public class TypesenseService { collection.setCoverImagePath((String) doc.get("cover_image_path")); collection.setIsArchived((Boolean) doc.get("is_archived")); + // Set tags from Typesense document + if (doc.get("tags") != null) { + @SuppressWarnings("unchecked") + List tagNames = (List) doc.get("tags"); + // For search results, we'll store tag names in a special field for frontend + // since we don't want to load full Tag entities for performance + collection.setTagNames(tagNames); + } + // Set timestamps if (doc.get("created_at") != null) { long createdAtSeconds = ((Number) doc.get("created_at")).longValue(); @@ -1210,6 +1234,7 @@ public class TypesenseService { // 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); + searchCollection.setWordsPerMinute(readingTimeService.getWordsPerMinute()); // Set the calculated statistics from the Typesense document if (doc.get("story_count") != null) { diff --git a/frontend/src/app/add-story/page.tsx b/frontend/src/app/add-story/page.tsx index 92ee9d5..49179d8 100644 --- a/frontend/src/app/add-story/page.tsx +++ b/frontend/src/app/add-story/page.tsx @@ -1,14 +1,15 @@ 'use client'; -import { useState, useRef } from 'react'; -import { useRouter } from 'next/navigation'; +import { useState, useRef, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useAuth } from '../../contexts/AuthContext'; import AppLayout from '../../components/layout/AppLayout'; import { Input, Textarea } from '../../components/ui/Input'; import Button from '../../components/ui/Button'; import TagInput from '../../components/stories/TagInput'; import RichTextEditor from '../../components/stories/RichTextEditor'; import ImageUpload from '../../components/ui/ImageUpload'; -import { storyApi } from '../../lib/api'; +import { storyApi, authorApi } from '../../lib/api'; export default function AddStoryPage() { const [formData, setFormData] = useState({ @@ -25,8 +26,84 @@ export default function AddStoryPage() { const [coverImage, setCoverImage] = useState(null); const [loading, setLoading] = useState(false); const [errors, setErrors] = useState>({}); + const [duplicateWarning, setDuplicateWarning] = useState<{ + show: boolean; + count: number; + duplicates: Array<{ + id: string; + title: string; + authorName: string; + createdAt: string; + }>; + }>({ show: false, count: 0, duplicates: [] }); + const [checkingDuplicates, setCheckingDuplicates] = useState(false); const router = useRouter(); + const searchParams = useSearchParams(); + const { isAuthenticated } = useAuth(); + + // Pre-fill author if authorId is provided in URL + useEffect(() => { + const authorId = searchParams.get('authorId'); + if (authorId) { + const loadAuthor = async () => { + try { + const author = await authorApi.getAuthor(authorId); + setFormData(prev => ({ + ...prev, + authorName: author.name + })); + } catch (error) { + console.error('Failed to load author:', error); + } + }; + loadAuthor(); + } + }, [searchParams]); + + // Check for duplicates when title and author are both present + useEffect(() => { + const checkDuplicates = async () => { + const title = formData.title.trim(); + const authorName = formData.authorName.trim(); + + // Don't check if user isn't authenticated or if title/author are empty + if (!isAuthenticated || !title || !authorName) { + setDuplicateWarning({ show: false, count: 0, duplicates: [] }); + return; + } + + // Debounce the check to avoid too many API calls + const timeoutId = setTimeout(async () => { + try { + setCheckingDuplicates(true); + const result = await storyApi.checkDuplicate(title, authorName); + + if (result.hasDuplicates) { + setDuplicateWarning({ + show: true, + count: result.count, + duplicates: result.duplicates + }); + } else { + setDuplicateWarning({ show: false, count: 0, duplicates: [] }); + } + } catch (error) { + console.error('Failed to check for duplicates:', error); + // Clear any existing duplicate warnings on error + setDuplicateWarning({ show: false, count: 0, duplicates: [] }); + // Don't show error to user as this is just a helpful warning + // Authentication errors will be handled by the API interceptor + } finally { + setCheckingDuplicates(false); + } + }, 500); // 500ms debounce + + return () => clearTimeout(timeoutId); + }; + + checkDuplicates(); + }, [formData.title, formData.authorName, isAuthenticated]); const handleInputChange = (field: string) => ( e: React.ChangeEvent @@ -150,6 +227,46 @@ export default function AddStoryPage() { required /> + {/* Duplicate Warning */} + {duplicateWarning.show && ( +
+
+
+ ⚠️ +
+
+

+ Potential Duplicate Detected +

+

+ Found {duplicateWarning.count} existing {duplicateWarning.count === 1 ? 'story' : 'stories'} with the same title and author: +

+
    + {duplicateWarning.duplicates.map((duplicate, index) => ( +
  • + • {duplicate.title} by {duplicate.authorName} + + (added {new Date(duplicate.createdAt).toLocaleDateString()}) + +
  • + ))} +
+

+ You can still create this story if it's different from the existing ones. +

+
+
+
+ )} + + {/* Checking indicator */} + {checkingDuplicates && ( +
+
+ Checking for duplicates... +
+ )} + {/* Summary */}
{/* Clear Filters */} diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index e372a48..54d4471 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -15,6 +15,7 @@ interface Settings { fontFamily: FontFamily; fontSize: FontSize; readingWidth: ReadingWidth; + readingSpeed: number; // words per minute } const defaultSettings: Settings = { @@ -22,6 +23,7 @@ const defaultSettings: Settings = { fontFamily: 'serif', fontSize: 'medium', readingWidth: 'medium', + readingSpeed: 200, }; export default function SettingsPage() { @@ -288,6 +290,33 @@ export default function SettingsPage() { ))} + + {/* Reading Speed */} +
+ +
+ updateSetting('readingSpeed', parseInt(e.target.value))} + className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" + /> +
+ {settings.readingSpeed} +
WPM
+
+
+
+ Slow (100) + Average (200) + Fast (400) +
+
diff --git a/frontend/src/app/stories/[id]/detail/page.tsx b/frontend/src/app/stories/[id]/detail/page.tsx index 337d637..294e607 100644 --- a/frontend/src/app/stories/[id]/detail/page.tsx +++ b/frontend/src/app/stories/[id]/detail/page.tsx @@ -9,6 +9,7 @@ import { Story, Collection } from '../../../../types/api'; import AppLayout from '../../../../components/layout/AppLayout'; import Button from '../../../../components/ui/Button'; import LoadingSpinner from '../../../../components/ui/LoadingSpinner'; +import { calculateReadingTime } from '../../../../lib/settings'; export default function StoryDetailPage() { const params = useParams(); @@ -73,9 +74,7 @@ export default function StoryDetailPage() { }; const estimateReadingTime = (wordCount: number) => { - const wordsPerMinute = 200; // Average reading speed - const minutes = Math.ceil(wordCount / wordsPerMinute); - return minutes; + return calculateReadingTime(wordCount); }; if (loading) { diff --git a/frontend/src/components/stories/RichTextEditor.tsx b/frontend/src/components/stories/RichTextEditor.tsx index d074095..1828814 100644 --- a/frontend/src/components/stories/RichTextEditor.tsx +++ b/frontend/src/components/stories/RichTextEditor.tsx @@ -23,6 +23,62 @@ export default function RichTextEditor({ const previewRef = useRef(null); const visualTextareaRef = useRef(null); const visualDivRef = useRef(null); + const [isUserTyping, setIsUserTyping] = useState(false); + + // Utility functions for cursor position preservation + const saveCursorPosition = () => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return null; + + const range = selection.getRangeAt(0); + const div = visualDivRef.current; + if (!div) return null; + + return { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset + }; + }; + + const restoreCursorPosition = (position: any) => { + if (!position) return; + + try { + const selection = window.getSelection(); + if (!selection) return; + + const range = document.createRange(); + range.setStart(position.startContainer, position.startOffset); + range.setEnd(position.endContainer, position.endOffset); + + selection.removeAllRanges(); + selection.addRange(range); + } catch (e) { + console.warn('Could not restore cursor position:', e); + } + }; + + // Set initial content when component mounts + useEffect(() => { + const div = visualDivRef.current; + if (div && div.innerHTML !== value) { + div.innerHTML = value || ''; + } + }, []); + + // Update div content when value changes externally (not from user typing) + useEffect(() => { + const div = visualDivRef.current; + if (div && !isUserTyping && div.innerHTML !== value) { + const cursorPosition = saveCursorPosition(); + div.innerHTML = value || ''; + if (cursorPosition) { + setTimeout(() => restoreCursorPosition(cursorPosition), 0); + } + } + }, [value, isUserTyping]); // Preload sanitization config useEffect(() => { @@ -38,8 +94,16 @@ export default function RichTextEditor({ const div = visualDivRef.current; if (div) { const newHtml = div.innerHTML; - onChange(newHtml); - setHtmlValue(newHtml); + setIsUserTyping(true); + + // Only call onChange if content actually changed + if (newHtml !== value) { + onChange(newHtml); + setHtmlValue(newHtml); + } + + // Reset typing state after a short delay + setTimeout(() => setIsUserTyping(false), 100); } }; @@ -155,8 +219,10 @@ export default function RichTextEditor({ } // Update the state + setIsUserTyping(true); onChange(visualDiv.innerHTML); setHtmlValue(visualDiv.innerHTML); + setTimeout(() => setIsUserTyping(false), 100); } else if (textarea) { // Fallback for textarea mode (shouldn't happen in visual mode but good to have) const start = textarea.selectionStart; @@ -213,8 +279,10 @@ export default function RichTextEditor({ visualDiv.innerHTML += textAsHtml; } + setIsUserTyping(true); onChange(visualDiv.innerHTML); setHtmlValue(visualDiv.innerHTML); + setTimeout(() => setIsUserTyping(false), 100); } } else { console.log('No usable clipboard content found'); @@ -229,8 +297,10 @@ export default function RichTextEditor({ .filter(paragraph => paragraph.trim()) .map(paragraph => `

${paragraph.replace(/\n/g, '
')}

`) .join('\n'); + setIsUserTyping(true); onChange(value + textAsHtml); setHtmlValue(value + textAsHtml); + setTimeout(() => setIsUserTyping(false), 100); } } }; @@ -293,8 +363,10 @@ export default function RichTextEditor({ } // Update the state + setIsUserTyping(true); onChange(visualDiv.innerHTML); setHtmlValue(visualDiv.innerHTML); + setTimeout(() => setIsUserTyping(false), 100); } } else { // HTML mode - existing logic with improvements @@ -434,16 +506,25 @@ export default function RichTextEditor({ {/* Editor */}
{viewMode === 'visual' ? ( -
${placeholder}

` }} - suppressContentEditableWarning={true} - /> +
+
+ {!value && ( +
+ {placeholder} +
+ )} +
) : (