Various Fixes and QoL enhancements.

This commit is contained in:
Stefan Hardegger
2025-07-26 12:05:54 +02:00
parent 5e8164c6a4
commit f95d7aa8bb
32 changed files with 758 additions and 136 deletions

1
backend/backend.log Normal file
View File

@@ -0,0 +1 @@
(eval):1: no such file or directory: ./mvnw

View File

@@ -7,6 +7,8 @@ import com.storycove.entity.Story;
import com.storycove.entity.Tag; import com.storycove.entity.Tag;
import com.storycove.service.CollectionService; import com.storycove.service.CollectionService;
import com.storycove.service.ImageService; import com.storycove.service.ImageService;
import com.storycove.service.ReadingTimeService;
import com.storycove.service.TypesenseService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -28,12 +30,18 @@ public class CollectionController {
private final CollectionService collectionService; private final CollectionService collectionService;
private final ImageService imageService; private final ImageService imageService;
private final TypesenseService typesenseService;
private final ReadingTimeService readingTimeService;
@Autowired @Autowired
public CollectionController(CollectionService collectionService, public CollectionController(CollectionService collectionService,
ImageService imageService) { ImageService imageService,
@Autowired(required = false) TypesenseService typesenseService,
ReadingTimeService readingTimeService) {
this.collectionService = collectionService; this.collectionService = collectionService;
this.imageService = imageService; 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")); return ResponseEntity.ok(Map.of("message", "Cover removed successfully"));
} }
/**
* POST /api/collections/reindex-typesense - Reindex all collections in Typesense
*/
@PostMapping("/reindex-typesense")
public ResponseEntity<Map<String, Object>> reindexCollectionsTypesense() {
try {
List<Collection> 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 // Mapper methods
private CollectionDto mapToCollectionDto(Collection collection) { private CollectionDto mapToCollectionDto(Collection collection) {
@@ -290,6 +327,11 @@ public class CollectionController {
.toList()); .toList());
} }
// Map tag names for search results
if (collection.getTagNames() != null) {
dto.setTagNames(collection.getTagNames());
}
// Map collection stories (lightweight) // Map collection stories (lightweight)
if (collection.getCollectionStories() != null) { if (collection.getCollectionStories() != null) {
dto.setCollectionStories(collection.getCollectionStories().stream() dto.setCollectionStories(collection.getCollectionStories().stream()
@@ -300,7 +342,7 @@ public class CollectionController {
// Set calculated properties // Set calculated properties
dto.setStoryCount(collection.getStoryCount()); dto.setStoryCount(collection.getStoryCount());
dto.setTotalWordCount(collection.getTotalWordCount()); dto.setTotalWordCount(collection.getTotalWordCount());
dto.setEstimatedReadingTime(collection.getEstimatedReadingTime()); dto.setEstimatedReadingTime(readingTimeService.calculateReadingTime(collection.getTotalWordCount()));
dto.setAverageStoryRating(collection.getAverageStoryRating()); dto.setAverageStoryRating(collection.getAverageStoryRating());
return dto; return dto;

View File

@@ -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<HtmlSanitizationConfigDto> getHtmlSanitizationConfig() {
HtmlSanitizationConfigDto config = htmlSanitizationService.getConfiguration();
return ResponseEntity.ok(config);
}
/**
* Get application settings configuration
*/
@GetMapping("/settings")
public ResponseEntity<Map<String, Object>> getSettings() {
Map<String, Object> settings = Map.of(
"defaultReadingSpeed", defaultReadingSpeed
);
return ResponseEntity.ok(settings);
}
/**
* Get reading speed for calculation purposes
*/
@GetMapping("/reading-speed")
public ResponseEntity<Map<String, Integer>> getReadingSpeed() {
return ResponseEntity.ok(Map.of("wordsPerMinute", defaultReadingSpeed));
}
}

View File

@@ -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<HtmlSanitizationConfigDto> getHtmlSanitizationConfig() {
HtmlSanitizationConfigDto config = htmlSanitizationService.getConfiguration();
return ResponseEntity.ok(config);
}
}

View File

@@ -41,6 +41,7 @@ public class StoryController {
private final ImageService imageService; private final ImageService imageService;
private final TypesenseService typesenseService; private final TypesenseService typesenseService;
private final CollectionService collectionService; private final CollectionService collectionService;
private final ReadingTimeService readingTimeService;
public StoryController(StoryService storyService, public StoryController(StoryService storyService,
AuthorService authorService, AuthorService authorService,
@@ -48,7 +49,8 @@ public class StoryController {
HtmlSanitizationService sanitizationService, HtmlSanitizationService sanitizationService,
ImageService imageService, ImageService imageService,
CollectionService collectionService, CollectionService collectionService,
@Autowired(required = false) TypesenseService typesenseService) { @Autowired(required = false) TypesenseService typesenseService,
ReadingTimeService readingTimeService) {
this.storyService = storyService; this.storyService = storyService;
this.authorService = authorService; this.authorService = authorService;
this.seriesService = seriesService; this.seriesService = seriesService;
@@ -56,6 +58,7 @@ public class StoryController {
this.imageService = imageService; this.imageService = imageService;
this.collectionService = collectionService; this.collectionService = collectionService;
this.typesenseService = typesenseService; this.typesenseService = typesenseService;
this.readingTimeService = readingTimeService;
} }
@GetMapping @GetMapping
@@ -467,12 +470,40 @@ public class StoryController {
// to avoid circular references and keep it lightweight // to avoid circular references and keep it lightweight
dto.setStoryCount(collection.getStoryCount()); dto.setStoryCount(collection.getStoryCount());
dto.setTotalWordCount(collection.getTotalWordCount()); dto.setTotalWordCount(collection.getTotalWordCount());
dto.setEstimatedReadingTime(collection.getEstimatedReadingTime()); dto.setEstimatedReadingTime(readingTimeService.calculateReadingTime(collection.getTotalWordCount()));
dto.setAverageStoryRating(collection.getAverageStoryRating()); dto.setAverageStoryRating(collection.getAverageStoryRating());
return dto; return dto;
} }
@GetMapping("/check-duplicate")
public ResponseEntity<Map<String, Object>> checkDuplicate(
@RequestParam String title,
@RequestParam String authorName) {
try {
List<Story> duplicates = storyService.findPotentialDuplicates(title, authorName);
Map<String, Object> 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 // Request DTOs
public static class CreateStoryRequest { public static class CreateStoryRequest {
private String title; private String title;

View File

@@ -132,17 +132,39 @@ public class TagController {
return ResponseEntity.ok(stats); return ResponseEntity.ok(stats);
} }
@GetMapping("/collections")
public ResponseEntity<List<TagDto>> getTagsUsedByCollections() {
List<Tag> tags = tagService.findTagsUsedByCollections();
List<TagDto> tagDtos = tags.stream()
.map(this::convertToDtoWithCollectionCount)
.collect(Collectors.toList());
return ResponseEntity.ok(tagDtos);
}
private TagDto convertToDto(Tag tag) { private TagDto convertToDto(Tag tag) {
TagDto dto = new TagDto(); TagDto dto = new TagDto();
dto.setId(tag.getId()); dto.setId(tag.getId());
dto.setName(tag.getName()); dto.setName(tag.getName());
dto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0); dto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0);
dto.setCreatedAt(tag.getCreatedAt()); dto.setCreatedAt(tag.getCreatedAt());
// updatedAt field not present in Tag entity per spec // updatedAt field not present in Tag entity per spec
return dto; 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 // Request DTOs
public static class CreateTagRequest { public static class CreateTagRequest {
private String name; private String name;

View File

@@ -16,6 +16,7 @@ public class CollectionDto {
private String coverImagePath; private String coverImagePath;
private Boolean isArchived; private Boolean isArchived;
private List<TagDto> tags; private List<TagDto> tags;
private List<String> tagNames; // For search results
private List<CollectionStoryDto> collectionStories; private List<CollectionStoryDto> collectionStories;
private Integer storyCount; private Integer storyCount;
private Integer totalWordCount; private Integer totalWordCount;
@@ -83,6 +84,14 @@ public class CollectionDto {
this.tags = tags; this.tags = tags;
} }
public List<String> getTagNames() {
return tagNames;
}
public void setTagNames(List<String> tagNames) {
this.tagNames = tagNames;
}
public List<CollectionStoryDto> getCollectionStories() { public List<CollectionStoryDto> getCollectionStories() {
return collectionStories; return collectionStories;
} }

View File

@@ -15,6 +15,7 @@ public class TagDto {
private String name; private String name;
private Integer storyCount; private Integer storyCount;
private Integer collectionCount;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@@ -49,6 +50,14 @@ public class TagDto {
this.storyCount = storyCount; this.storyCount = storyCount;
} }
public Integer getCollectionCount() {
return collectionCount;
}
public void setCollectionCount(Integer collectionCount) {
this.collectionCount = collectionCount;
}
public LocalDateTime getCreatedAt() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }

View File

@@ -52,6 +52,10 @@ public class Collection {
) )
private Set<Tag> tags = new HashSet<>(); private Set<Tag> tags = new HashSet<>();
// Transient field for search results - tag names only to avoid lazy loading issues
@Transient
private List<String> tagNames;
@CreationTimestamp @CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -192,6 +196,14 @@ public class Collection {
this.tags = tags; this.tags = tags;
} }
public List<String> getTagNames() {
return tagNames;
}
public void setTagNames(List<String> tagNames) {
this.tagNames = tagNames;
}
public LocalDateTime getCreatedAt() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }

View File

@@ -29,6 +29,10 @@ public class Tag {
@JsonBackReference("story-tags") @JsonBackReference("story-tags")
private Set<Story> stories = new HashSet<>(); private Set<Story> stories = new HashSet<>();
@ManyToMany(mappedBy = "tags")
@JsonBackReference("collection-tags")
private Set<Collection> collections = new HashSet<>();
@CreationTimestamp @CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -67,6 +71,14 @@ public class Tag {
this.stories = stories; this.stories = stories;
} }
public Set<Collection> getCollections() {
return collections;
}
public void setCollections(Set<Collection> collections) {
this.collections = collections;
}
public LocalDateTime getCreatedAt() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }

View File

@@ -45,4 +45,10 @@ public interface CollectionRepository extends JpaRepository<Collection, UUID> {
*/ */
@Query("SELECT c FROM Collection c WHERE c.isArchived = false ORDER BY c.updatedAt DESC") @Query("SELECT c FROM Collection c WHERE c.isArchived = false ORDER BY c.updatedAt DESC")
List<Collection> findAllActiveCollections(); List<Collection> 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<Collection> findAllWithTags();
} }

View File

@@ -114,4 +114,7 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
"LEFT JOIN FETCH s.series " + "LEFT JOIN FETCH s.series " +
"LEFT JOIN FETCH s.tags") "LEFT JOIN FETCH s.tags")
List<Story> findAllWithAssociations(); List<Story> findAllWithAssociations();
@Query("SELECT s FROM Story s WHERE UPPER(s.title) = UPPER(:title) AND UPPER(s.author.name) = UPPER(:authorName)")
List<Story> findByTitleAndAuthorNameIgnoreCase(@Param("title") String title, @Param("authorName") String authorName);
} }

View File

@@ -54,4 +54,7 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
@Query("SELECT COUNT(t) FROM Tag t WHERE SIZE(t.stories) > 0") @Query("SELECT COUNT(t) FROM Tag t WHERE SIZE(t.stories) > 0")
long countUsedTags(); long countUsedTags();
@Query("SELECT t FROM Tag t WHERE SIZE(t.collections) > 0 ORDER BY SIZE(t.collections) DESC, t.name ASC")
List<Tag> findTagsUsedByCollections();
} }

View File

@@ -10,6 +10,7 @@ public class CollectionSearchResult extends Collection {
private Integer storedStoryCount; private Integer storedStoryCount;
private Integer storedTotalWordCount; private Integer storedTotalWordCount;
private int wordsPerMinute = 200; // Default, can be overridden
public CollectionSearchResult(Collection collection) { public CollectionSearchResult(Collection collection) {
this.setId(collection.getId()); this.setId(collection.getId());
@@ -20,6 +21,7 @@ public class CollectionSearchResult extends Collection {
this.setCreatedAt(collection.getCreatedAt()); this.setCreatedAt(collection.getCreatedAt());
this.setUpdatedAt(collection.getUpdatedAt()); this.setUpdatedAt(collection.getUpdatedAt());
this.setCoverImagePath(collection.getCoverImagePath()); 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 // Note: don't copy collectionStories or tags to avoid lazy loading issues
} }
@@ -31,6 +33,10 @@ public class CollectionSearchResult extends Collection {
this.storedTotalWordCount = totalWordCount; this.storedTotalWordCount = totalWordCount;
} }
public void setWordsPerMinute(int wordsPerMinute) {
this.wordsPerMinute = wordsPerMinute;
}
@Override @Override
public int getStoryCount() { public int getStoryCount() {
return storedStoryCount != null ? storedStoryCount : 0; return storedStoryCount != null ? storedStoryCount : 0;
@@ -43,8 +49,7 @@ public class CollectionSearchResult extends Collection {
@Override @Override
public int getEstimatedReadingTime() { public int getEstimatedReadingTime() {
// Assuming 200 words per minute reading speed return Math.max(1, getTotalWordCount() / wordsPerMinute);
return Math.max(1, getTotalWordCount() / 200);
} }
@Override @Override

View File

@@ -34,18 +34,21 @@ public class CollectionService {
private final StoryRepository storyRepository; private final StoryRepository storyRepository;
private final TagRepository tagRepository; private final TagRepository tagRepository;
private final TypesenseService typesenseService; private final TypesenseService typesenseService;
private final ReadingTimeService readingTimeService;
@Autowired @Autowired
public CollectionService(CollectionRepository collectionRepository, public CollectionService(CollectionRepository collectionRepository,
CollectionStoryRepository collectionStoryRepository, CollectionStoryRepository collectionStoryRepository,
StoryRepository storyRepository, StoryRepository storyRepository,
TagRepository tagRepository, TagRepository tagRepository,
@Autowired(required = false) TypesenseService typesenseService) { @Autowired(required = false) TypesenseService typesenseService,
ReadingTimeService readingTimeService) {
this.collectionRepository = collectionRepository; this.collectionRepository = collectionRepository;
this.collectionStoryRepository = collectionStoryRepository; this.collectionStoryRepository = collectionStoryRepository;
this.storyRepository = storyRepository; this.storyRepository = storyRepository;
this.tagRepository = tagRepository; this.tagRepository = tagRepository;
this.typesenseService = typesenseService; this.typesenseService = typesenseService;
this.readingTimeService = readingTimeService;
} }
/** /**
@@ -78,6 +81,13 @@ public class CollectionService {
.orElseThrow(() -> new ResourceNotFoundException("Collection not found with id: " + id)); .orElseThrow(() -> new ResourceNotFoundException("Collection not found with id: " + id));
} }
/**
* Find all collections with tags for reindexing
*/
public List<Collection> findAllWithTags() {
return collectionRepository.findAllWithTags();
}
/** /**
* Create a new collection with optional initial stories * Create a new collection with optional initial stories
*/ */
@@ -344,7 +354,7 @@ public class CollectionService {
int totalWordCount = collectionStories.stream() int totalWordCount = collectionStories.stream()
.mapToInt(cs -> cs.getStory().getWordCount() != null ? cs.getStory().getWordCount() : 0) .mapToInt(cs -> cs.getStory().getWordCount() != null ? cs.getStory().getWordCount() : 0)
.sum(); .sum();
int estimatedReadingTime = Math.max(1, totalWordCount / 200); // 200 words per minute int estimatedReadingTime = readingTimeService.calculateReadingTime(totalWordCount);
double averageStoryRating = collectionStories.stream() double averageStoryRating = collectionStories.stream()
.filter(cs -> cs.getStory().getRating() != null) .filter(cs -> cs.getStory().getRating() != null)

View File

@@ -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;
}
}

View File

@@ -593,4 +593,12 @@ public class StoryService {
} }
} }
} }
@Transactional(readOnly = true)
public List<Story> 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());
}
} }

View File

@@ -192,6 +192,11 @@ public class TagService {
return tagRepository.countUsedTags(); return tagRepository.countUsedTags();
} }
@Transactional(readOnly = true)
public List<Tag> findTagsUsedByCollections() {
return tagRepository.findTagsUsedByCollections();
}
private void validateTagForCreate(Tag tag) { private void validateTagForCreate(Tag tag) {
if (existsByName(tag.getName())) { if (existsByName(tag.getName())) {
throw new DuplicateResourceException("Tag", tag.getName()); throw new DuplicateResourceException("Tag", tag.getName());

View File

@@ -32,12 +32,15 @@ public class TypesenseService {
private final Client typesenseClient; private final Client typesenseClient;
private final CollectionStoryRepository collectionStoryRepository; private final CollectionStoryRepository collectionStoryRepository;
private final ReadingTimeService readingTimeService;
@Autowired @Autowired
public TypesenseService(Client typesenseClient, public TypesenseService(Client typesenseClient,
@Autowired(required = false) CollectionStoryRepository collectionStoryRepository) { @Autowired(required = false) CollectionStoryRepository collectionStoryRepository,
ReadingTimeService readingTimeService) {
this.typesenseClient = typesenseClient; this.typesenseClient = typesenseClient;
this.collectionStoryRepository = collectionStoryRepository; this.collectionStoryRepository = collectionStoryRepository;
this.readingTimeService = readingTimeService;
} }
@PostConstruct @PostConstruct
@@ -65,19 +68,19 @@ public class TypesenseService {
private void createStoriesCollection() throws Exception { private void createStoriesCollection() throws Exception {
List<Field> fields = Arrays.asList( List<Field> fields = Arrays.asList(
new Field().name("id").type("string").facet(false), 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("summary").type("string").facet(false).optional(true),
new Field().name("description").type("string").facet(false), new Field().name("description").type("string").facet(false),
new Field().name("contentPlain").type("string").facet(false), new Field().name("contentPlain").type("string").facet(false),
new Field().name("authorId").type("string").facet(true), 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("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("tagNames").type("string[]").facet(true).optional(true),
new Field().name("rating").type("int32").facet(true).optional(true), new Field().name("rating").type("int32").facet(true).sort(true).optional(true),
new Field().name("wordCount").type("int32").facet(true).optional(true), new Field().name("wordCount").type("int32").facet(true).sort(true).optional(true),
new Field().name("volume").type("int32").facet(true).optional(true), new Field().name("volume").type("int32").facet(true).sort(true).optional(true),
new Field().name("createdAt").type("int64").facet(false), new Field().name("createdAt").type("int64").facet(false).sort(true),
new Field().name("sourceUrl").type("string").facet(false).optional(true), new Field().name("sourceUrl").type("string").facet(false).optional(true),
new Field().name("coverPath").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 * Force recreate the authors collection, deleting it first if it exists
*/ */
@@ -220,16 +243,14 @@ public class TypesenseService {
if (tagFilters != null && !tagFilters.isEmpty()) { if (tagFilters != null && !tagFilters.isEmpty()) {
logger.info("SEARCH DEBUG: Processing {} tag filters: {}", tagFilters.size(), tagFilters); logger.info("SEARCH DEBUG: Processing {} tag filters: {}", tagFilters.size(), tagFilters);
String tagFilter = tagFilters.stream() // Use AND logic for multiple tags - items must have ALL selected tags
.map(tag -> { for (String tag : tagFilters) {
String escaped = escapeTypesenseValue(tag); String escaped = escapeTypesenseValue(tag);
String condition = "tagNames:=" + escaped; String condition = "tagNames:=" + escaped;
logger.info("SEARCH DEBUG: Tag '{}' -> escaped '{}' -> condition '{}'", tag, escaped, condition); logger.info("SEARCH DEBUG: Tag '{}' -> escaped '{}' -> condition '{}'", tag, escaped, condition);
return condition; filterConditions.add(condition);
}) }
.collect(Collectors.joining(" || ")); logger.info("SEARCH DEBUG: Added {} individual tag filter conditions", tagFilters.size());
logger.info("SEARCH DEBUG: Final tag filter condition: '{}'", tagFilter);
filterConditions.add("(" + tagFilter + ")");
} }
if (minRating != null) { if (minRating != null) {
@@ -294,15 +315,8 @@ public class TypesenseService {
public void reindexAllStories(List<Story> stories) { public void reindexAllStories(List<Story> stories) {
try { try {
// Clear existing collection // Force recreate collection with proper schema
try { recreateStoriesCollection();
typesenseClient.collections(STORIES_COLLECTION).delete();
} catch (Exception e) {
logger.debug("Collection didn't exist for deletion: {}", e.getMessage());
}
// Recreate collection
createStoriesCollection();
// Bulk index all stories // Bulk index all stories
bulkIndexStories(stories); bulkIndexStories(stories);
@@ -1007,10 +1021,11 @@ public class TypesenseService {
} }
if (tags != null && !tags.isEmpty()) { if (tags != null && !tags.isEmpty()) {
String tagFilter = tags.stream() // Use AND logic for multiple tags - collections must have ALL selected tags
.map(tag -> "tags:=" + escapeTypesenseValue(tag)) for (String tag : tags) {
.collect(Collectors.joining(" || ")); String condition = "tags:=" + escapeTypesenseValue(tag);
filterConditions.add("(" + tagFilter + ")"); filterConditions.add(condition);
}
} }
if (!filterConditions.isEmpty()) { if (!filterConditions.isEmpty()) {
@@ -1197,6 +1212,15 @@ public class TypesenseService {
collection.setCoverImagePath((String) doc.get("cover_image_path")); collection.setCoverImagePath((String) doc.get("cover_image_path"));
collection.setIsArchived((Boolean) doc.get("is_archived")); collection.setIsArchived((Boolean) doc.get("is_archived"));
// Set tags from Typesense document
if (doc.get("tags") != null) {
@SuppressWarnings("unchecked")
List<String> tagNames = (List<String>) 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 // Set timestamps
if (doc.get("created_at") != null) { if (doc.get("created_at") != null) {
long createdAtSeconds = ((Number) doc.get("created_at")).longValue(); 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 // For list/search views, we create a special lightweight collection that stores
// the calculated values directly to avoid lazy loading issues // the calculated values directly to avoid lazy loading issues
CollectionSearchResult searchCollection = new CollectionSearchResult(collection); CollectionSearchResult searchCollection = new CollectionSearchResult(collection);
searchCollection.setWordsPerMinute(readingTimeService.getWordsPerMinute());
// Set the calculated statistics from the Typesense document // Set the calculated statistics from the Typesense document
if (doc.get("story_count") != null) { if (doc.get("story_count") != null) {

View File

@@ -1,14 +1,15 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '../../contexts/AuthContext';
import AppLayout from '../../components/layout/AppLayout'; import AppLayout from '../../components/layout/AppLayout';
import { Input, Textarea } from '../../components/ui/Input'; import { Input, Textarea } from '../../components/ui/Input';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
import TagInput from '../../components/stories/TagInput'; import TagInput from '../../components/stories/TagInput';
import RichTextEditor from '../../components/stories/RichTextEditor'; import RichTextEditor from '../../components/stories/RichTextEditor';
import ImageUpload from '../../components/ui/ImageUpload'; import ImageUpload from '../../components/ui/ImageUpload';
import { storyApi } from '../../lib/api'; import { storyApi, authorApi } from '../../lib/api';
export default function AddStoryPage() { export default function AddStoryPage() {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -25,8 +26,84 @@ export default function AddStoryPage() {
const [coverImage, setCoverImage] = useState<File | null>(null); const [coverImage, setCoverImage] = useState<File | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
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 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) => ( const handleInputChange = (field: string) => (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
@@ -150,6 +227,46 @@ export default function AddStoryPage() {
required required
/> />
{/* Duplicate Warning */}
{duplicateWarning.show && (
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex items-start gap-3">
<div className="text-yellow-600 dark:text-yellow-400 mt-0.5">
</div>
<div>
<h4 className="font-medium text-yellow-800 dark:text-yellow-200">
Potential Duplicate Detected
</h4>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
Found {duplicateWarning.count} existing {duplicateWarning.count === 1 ? 'story' : 'stories'} with the same title and author:
</p>
<ul className="mt-2 space-y-1">
{duplicateWarning.duplicates.map((duplicate, index) => (
<li key={duplicate.id} className="text-sm text-yellow-700 dark:text-yellow-300">
<span className="font-medium">{duplicate.title}</span> by {duplicate.authorName}
<span className="text-xs ml-2">
(added {new Date(duplicate.createdAt).toLocaleDateString()})
</span>
</li>
))}
</ul>
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-2">
You can still create this story if it's different from the existing ones.
</p>
</div>
</div>
</div>
)}
{/* Checking indicator */}
{checkingDuplicates && (
<div className="flex items-center gap-2 text-sm theme-text">
<div className="animate-spin w-4 h-4 border-2 border-theme-accent border-t-transparent rounded-full"></div>
Checking for duplicates...
</div>
)}
{/* Summary */} {/* Summary */}
<div> <div>
<label className="block text-sm font-medium theme-header mb-2"> <label className="block text-sm font-medium theme-header mb-2">

View File

@@ -207,9 +207,14 @@ export default function AuthorDetailPage() {
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold theme-header">Stories</h2> <h2 className="text-2xl font-semibold theme-header">Stories</h2>
<div className="flex items-center gap-4">
<p className="theme-text"> <p className="theme-text">
{stories.length} {stories.length === 1 ? 'story' : 'stories'} {stories.length} {stories.length === 1 ? 'story' : 'stories'}
</p> </p>
<Button href={`/add-story?authorId=${authorId}`}>
Add Story
</Button>
</div>
</div> </div>
{stories.length === 0 ? ( {stories.length === 0 ? (

View File

@@ -26,19 +26,27 @@ export default function CollectionsPage() {
const [totalCollections, setTotalCollections] = useState(0); const [totalCollections, setTotalCollections] = useState(0);
const [refreshTrigger, setRefreshTrigger] = 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(); // Extract tags from current collection results with counts
}, []); const extractTagsFromResults = (collections: Collection[]): Tag[] => {
const tagCounts: { [key: string]: number } = {};
collections.forEach(collection => {
collection.tagNames?.forEach(tagName => {
if (tagCounts[tagName]) {
tagCounts[tagName]++;
} else {
tagCounts[tagName] = 1;
}
});
});
return Object.entries(tagCounts).map(([tagName, count]) => ({
id: tagName, // Use tag name as ID since we don't have actual IDs from search results
name: tagName,
collectionCount: count
}));
};
// Load collections with search and filters // Load collections with search and filters
useEffect(() => { useEffect(() => {
@@ -55,9 +63,14 @@ export default function CollectionsPage() {
archived: showArchived, archived: showArchived,
}); });
setCollections(result?.results || []); const currentCollections = result?.results || [];
setCollections(currentCollections);
setTotalPages(Math.ceil((result?.totalHits || 0) / pageSize)); setTotalPages(Math.ceil((result?.totalHits || 0) / pageSize));
setTotalCollections(result?.totalHits || 0); setTotalCollections(result?.totalHits || 0);
// Always update tags based on current search results (including initial wildcard search)
const resultTags = extractTagsFromResults(currentCollections);
setTags(resultTags);
} catch (error) { } catch (error) {
console.error('Failed to load collections:', error); console.error('Failed to load collections:', error);
setCollections([]); setCollections([]);
@@ -223,6 +236,7 @@ export default function CollectionsPage() {
tags={tags} tags={tags}
selectedTags={selectedTags} selectedTags={selectedTags}
onTagToggle={handleTagToggle} onTagToggle={handleTagToggle}
showCollectionCount={true}
/> />
</div> </div>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { searchApi, tagApi } from '../../lib/api'; import { searchApi } from '../../lib/api';
import { Story, Tag } from '../../types/api'; 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';
@@ -11,7 +11,7 @@ import TagFilter from '../../components/stories/TagFilter';
import LoadingSpinner from '../../components/ui/LoadingSpinner'; import LoadingSpinner from '../../components/ui/LoadingSpinner';
type ViewMode = 'grid' | 'list'; type ViewMode = 'grid' | 'list';
type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating'; type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount';
export default function LibraryPage() { export default function LibraryPage() {
const [stories, setStories] = useState<Story[]>([]); const [stories, setStories] = useState<Story[]>([]);
@@ -28,19 +28,27 @@ export default function LibraryPage() {
const [refreshTrigger, setRefreshTrigger] = 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(); // Extract tags from current search results with counts
}, []); const extractTagsFromResults = (stories: Story[]): Tag[] => {
const tagCounts: { [key: string]: number } = {};
stories.forEach(story => {
story.tagNames?.forEach(tagName => {
if (tagCounts[tagName]) {
tagCounts[tagName]++;
} else {
tagCounts[tagName] = 1;
}
});
});
return Object.entries(tagCounts).map(([tagName, count]) => ({
id: tagName, // Use tag name as ID since we don't have actual IDs from search results
name: tagName,
storyCount: count
}));
};
// Debounce search to avoid too many API calls // Debounce search to avoid too many API calls
useEffect(() => { useEffect(() => {
@@ -59,9 +67,14 @@ export default function LibraryPage() {
sortDir: sortDirection, sortDir: sortDirection,
}); });
setStories(result?.results || []); const currentStories = result?.results || [];
setStories(currentStories);
setTotalPages(Math.ceil((result?.totalHits || 0) / 20)); setTotalPages(Math.ceil((result?.totalHits || 0) / 20));
setTotalElements(result?.totalHits || 0); setTotalElements(result?.totalHits || 0);
// Always update tags based on current search results (including initial wildcard search)
const resultTags = extractTagsFromResults(currentStories);
setTags(resultTags);
} catch (error) { } catch (error) {
console.error('Failed to load stories:', error); console.error('Failed to load stories:', error);
setStories([]); setStories([]);
@@ -99,16 +112,21 @@ export default function LibraryPage() {
}; };
const handleSortChange = (newSortOption: SortOption) => { const handleSortChange = (newSortOption: SortOption) => {
if (newSortOption === sortOption) {
// Toggle direction if same option
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortOption(newSortOption); setSortOption(newSortOption);
setSortDirection('desc'); // Default to desc for new sort option // Set appropriate default direction for the sort option
if (newSortOption === 'title' || newSortOption === 'authorName') {
setSortDirection('asc'); // Alphabetical fields default to ascending
} else {
setSortDirection('desc'); // Numeric/date fields default to descending
} }
resetPage(); resetPage();
}; };
const toggleSortDirection = () => {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
resetPage();
};
const clearFilters = () => { const clearFilters = () => {
setSearchQuery(''); setSearchQuery('');
setSelectedTags([]); setSelectedTags([]);
@@ -203,7 +221,18 @@ export default function LibraryPage() {
<option value="title">Title</option> <option value="title">Title</option>
<option value="authorName">Author</option> <option value="authorName">Author</option>
<option value="rating">Rating</option> <option value="rating">Rating</option>
<option value="wordCount">Word Count</option>
</select> </select>
{/* Sort Direction Toggle */}
<button
onClick={toggleSortDirection}
className="p-2 rounded-lg theme-card theme-text hover:bg-opacity-80 transition-colors border theme-border"
title={`Sort ${sortDirection === 'asc' ? 'Ascending' : 'Descending'}`}
aria-label={`Toggle sort direction - currently ${sortDirection === 'asc' ? 'ascending' : 'descending'}`}
>
{sortDirection === 'asc' ? '↑' : '↓'}
</button>
</div> </div>
{/* Clear Filters */} {/* Clear Filters */}

View File

@@ -15,6 +15,7 @@ interface Settings {
fontFamily: FontFamily; fontFamily: FontFamily;
fontSize: FontSize; fontSize: FontSize;
readingWidth: ReadingWidth; readingWidth: ReadingWidth;
readingSpeed: number; // words per minute
} }
const defaultSettings: Settings = { const defaultSettings: Settings = {
@@ -22,6 +23,7 @@ const defaultSettings: Settings = {
fontFamily: 'serif', fontFamily: 'serif',
fontSize: 'medium', fontSize: 'medium',
readingWidth: 'medium', readingWidth: 'medium',
readingSpeed: 200,
}; };
export default function SettingsPage() { export default function SettingsPage() {
@@ -288,6 +290,33 @@ export default function SettingsPage() {
))} ))}
</div> </div>
</div> </div>
{/* Reading Speed */}
<div>
<label className="block text-sm font-medium theme-header mb-2">
Reading Speed (words per minute)
</label>
<div className="flex items-center gap-4">
<input
type="range"
min="100"
max="400"
step="25"
value={settings.readingSpeed}
onChange={(e) => updateSetting('readingSpeed', parseInt(e.target.value))}
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
<div className="min-w-[80px] text-center">
<span className="text-lg font-medium theme-header">{settings.readingSpeed}</span>
<div className="text-xs theme-text">WPM</div>
</div>
</div>
<div className="flex justify-between text-xs theme-text mt-1">
<span>Slow (100)</span>
<span>Average (200)</span>
<span>Fast (400)</span>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -9,6 +9,7 @@ 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';
import { calculateReadingTime } from '../../../../lib/settings';
export default function StoryDetailPage() { export default function StoryDetailPage() {
const params = useParams(); const params = useParams();
@@ -73,9 +74,7 @@ export default function StoryDetailPage() {
}; };
const estimateReadingTime = (wordCount: number) => { const estimateReadingTime = (wordCount: number) => {
const wordsPerMinute = 200; // Average reading speed return calculateReadingTime(wordCount);
const minutes = Math.ceil(wordCount / wordsPerMinute);
return minutes;
}; };
if (loading) { if (loading) {

View File

@@ -23,6 +23,62 @@ export default function RichTextEditor({
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const visualTextareaRef = useRef<HTMLTextAreaElement>(null); const visualTextareaRef = useRef<HTMLTextAreaElement>(null);
const visualDivRef = useRef<HTMLDivElement>(null); const visualDivRef = useRef<HTMLDivElement>(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 // Preload sanitization config
useEffect(() => { useEffect(() => {
@@ -38,9 +94,17 @@ export default function RichTextEditor({
const div = visualDivRef.current; const div = visualDivRef.current;
if (div) { if (div) {
const newHtml = div.innerHTML; const newHtml = div.innerHTML;
setIsUserTyping(true);
// Only call onChange if content actually changed
if (newHtml !== value) {
onChange(newHtml); onChange(newHtml);
setHtmlValue(newHtml); setHtmlValue(newHtml);
} }
// Reset typing state after a short delay
setTimeout(() => setIsUserTyping(false), 100);
}
}; };
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement | HTMLDivElement>) => { const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement | HTMLDivElement>) => {
@@ -155,8 +219,10 @@ export default function RichTextEditor({
} }
// Update the state // Update the state
setIsUserTyping(true);
onChange(visualDiv.innerHTML); onChange(visualDiv.innerHTML);
setHtmlValue(visualDiv.innerHTML); setHtmlValue(visualDiv.innerHTML);
setTimeout(() => setIsUserTyping(false), 100);
} else if (textarea) { } else if (textarea) {
// Fallback for textarea mode (shouldn't happen in visual mode but good to have) // Fallback for textarea mode (shouldn't happen in visual mode but good to have)
const start = textarea.selectionStart; const start = textarea.selectionStart;
@@ -213,8 +279,10 @@ export default function RichTextEditor({
visualDiv.innerHTML += textAsHtml; visualDiv.innerHTML += textAsHtml;
} }
setIsUserTyping(true);
onChange(visualDiv.innerHTML); onChange(visualDiv.innerHTML);
setHtmlValue(visualDiv.innerHTML); setHtmlValue(visualDiv.innerHTML);
setTimeout(() => setIsUserTyping(false), 100);
} }
} else { } else {
console.log('No usable clipboard content found'); console.log('No usable clipboard content found');
@@ -229,8 +297,10 @@ export default function RichTextEditor({
.filter(paragraph => paragraph.trim()) .filter(paragraph => paragraph.trim())
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`) .map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
.join('\n'); .join('\n');
setIsUserTyping(true);
onChange(value + textAsHtml); onChange(value + textAsHtml);
setHtmlValue(value + textAsHtml); setHtmlValue(value + textAsHtml);
setTimeout(() => setIsUserTyping(false), 100);
} }
} }
}; };
@@ -293,8 +363,10 @@ export default function RichTextEditor({
} }
// Update the state // Update the state
setIsUserTyping(true);
onChange(visualDiv.innerHTML); onChange(visualDiv.innerHTML);
setHtmlValue(visualDiv.innerHTML); setHtmlValue(visualDiv.innerHTML);
setTimeout(() => setIsUserTyping(false), 100);
} }
} else { } else {
// HTML mode - existing logic with improvements // HTML mode - existing logic with improvements
@@ -434,6 +506,7 @@ export default function RichTextEditor({
{/* Editor */} {/* Editor */}
<div className="border theme-border rounded-b-lg overflow-hidden"> <div className="border theme-border rounded-b-lg overflow-hidden">
{viewMode === 'visual' ? ( {viewMode === 'visual' ? (
<div className="relative">
<div <div
ref={visualDivRef} ref={visualDivRef}
contentEditable contentEditable
@@ -441,9 +514,17 @@ export default function RichTextEditor({
onPaste={handlePaste} onPaste={handlePaste}
className="p-3 min-h-[300px] focus:outline-none focus:ring-0 whitespace-pre-wrap" className="p-3 min-h-[300px] focus:outline-none focus:ring-0 whitespace-pre-wrap"
style={{ minHeight: '300px' }} style={{ minHeight: '300px' }}
dangerouslySetInnerHTML={{ __html: value || `<p>${placeholder}</p>` }}
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
/> />
{!value && (
<div
className="absolute top-3 left-3 text-gray-500 dark:text-gray-400 pointer-events-none select-none"
style={{ minHeight: '300px' }}
>
{placeholder}
</div>
)}
</div>
) : ( ) : (
<Textarea <Textarea
value={htmlValue} value={htmlValue}

View File

@@ -6,17 +6,21 @@ interface TagFilterProps {
tags: Tag[]; tags: Tag[];
selectedTags: string[]; selectedTags: string[];
onTagToggle: (tagName: string) => void; onTagToggle: (tagName: string) => void;
showCollectionCount?: boolean;
} }
export default function TagFilter({ tags, selectedTags, onTagToggle }: TagFilterProps) { export default function TagFilter({ tags, selectedTags, onTagToggle, showCollectionCount = false }: TagFilterProps) {
if (!Array.isArray(tags) || tags.length === 0) return null; if (!Array.isArray(tags) || tags.length === 0) return null;
// Filter out tags with no stories, then sort by usage count (descending) and then alphabetically // Filter out tags with no count, then sort by usage count (descending) and then alphabetically
const sortedTags = [...tags] const sortedTags = [...tags]
.filter(tag => (tag.storyCount || 0) > 0) .filter(tag => {
const count = showCollectionCount ? (tag.collectionCount || 0) : (tag.storyCount || 0);
return count > 0;
})
.sort((a, b) => { .sort((a, b) => {
const aCount = a.storyCount || 0; const aCount = showCollectionCount ? (a.collectionCount || 0) : (a.storyCount || 0);
const bCount = b.storyCount || 0; const bCount = showCollectionCount ? (b.collectionCount || 0) : (b.storyCount || 0);
if (bCount !== aCount) { if (bCount !== aCount) {
return bCount - aCount; return bCount - aCount;
} }
@@ -40,7 +44,7 @@ export default function TagFilter({ tags, selectedTags, onTagToggle }: TagFilter
: 'theme-card theme-text theme-border hover:border-gray-400' : 'theme-card theme-text theme-border hover:border-gray-400'
}`} }`}
> >
{tag.name} ({tag.storyCount || 0}) {tag.name} ({showCollectionCount ? (tag.collectionCount || 0) : (tag.storyCount || 0)})
</button> </button>
); );
})} })}

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, useContext, useEffect, useState } from 'react';
import { authApi } from '../lib/api'; import { useRouter } from 'next/navigation';
import { authApi, setGlobalAuthFailureHandler } from '../lib/api';
import { preloadSanitizationConfig } from '../lib/sanitization'; import { preloadSanitizationConfig } from '../lib/sanitization';
interface AuthContextType { interface AuthContextType {
@@ -16,8 +17,18 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const router = useRouter();
// Handle authentication failures from API calls
const handleAuthFailure = () => {
console.log('Authentication token expired, logging out user');
setIsAuthenticated(false);
router.push('/login');
};
useEffect(() => { useEffect(() => {
// Register the auth failure handler for API interceptor
setGlobalAuthFailureHandler(handleAuthFailure);
// Check if user is already authenticated on app load // Check if user is already authenticated on app load
const checkAuth = async () => { const checkAuth = async () => {
try { try {
@@ -42,7 +53,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
checkAuth(); checkAuth();
loadSanitizationConfig(); loadSanitizationConfig();
}, []); }, [router]);
const login = async (password: string) => { const login = async (password: string) => {
try { try {
@@ -57,6 +68,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const logout = () => { const logout = () => {
authApi.logout(); authApi.logout();
setIsAuthenticated(false); setIsAuthenticated(false);
router.push('/login');
}; };
return ( return (

View File

@@ -21,15 +21,36 @@ api.interceptors.request.use((config) => {
return config; return config;
}); });
// Global auth failure handler - can be set by AuthContext
let globalAuthFailureHandler: (() => void) | null = null;
export const setGlobalAuthFailureHandler = (handler: () => void) => {
globalAuthFailureHandler = handler;
};
// Response interceptor to handle auth errors // Response interceptor to handle auth errors
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response?.status === 401) { // Handle authentication failures
// Clear invalid token and redirect to login if (error.response?.status === 401 || error.response?.status === 403) {
console.warn('Authentication failed, token may be expired or invalid');
// Clear invalid token
localStorage.removeItem('auth-token'); localStorage.removeItem('auth-token');
// Use global handler if available (from AuthContext), otherwise fallback to direct redirect
if (globalAuthFailureHandler) {
globalAuthFailureHandler();
} else {
// Fallback for cases where AuthContext isn't available
window.location.href = '/login'; window.location.href = '/login';
} }
// Return a more specific error for components to handle gracefully
return Promise.reject(new Error('Authentication required'));
}
return Promise.reject(error); return Promise.reject(error);
} }
); );
@@ -150,6 +171,22 @@ export const storyApi = {
const response = await api.post('/stories/recreate-typesense-collection'); const response = await api.post('/stories/recreate-typesense-collection');
return response.data; return response.data;
}, },
checkDuplicate: async (title: string, authorName: string): Promise<{
hasDuplicates: boolean;
count: number;
duplicates: Array<{
id: string;
title: string;
authorName: string;
createdAt: string;
}>;
}> => {
const response = await api.get('/stories/check-duplicate', {
params: { title, authorName }
});
return response.data;
},
}; };
// Author endpoints // Author endpoints
@@ -240,6 +277,11 @@ export const tagApi = {
// Backend returns TagDto[], extract just the names // Backend returns TagDto[], extract just the names
return response.data.map((tag: Tag) => tag.name); return response.data.map((tag: Tag) => tag.name);
}, },
getCollectionTags: async (): Promise<Tag[]> => {
const response = await api.get('/tags/collections');
return response.data;
},
}; };
// Series endpoints // Series endpoints

View File

@@ -0,0 +1,33 @@
interface Settings {
theme: 'light' | 'dark';
fontFamily: 'serif' | 'sans' | 'mono';
fontSize: 'small' | 'medium' | 'large' | 'extra-large';
readingWidth: 'narrow' | 'medium' | 'wide';
readingSpeed: number; // words per minute
}
const defaultSettings: Settings = {
theme: 'light',
fontFamily: 'serif',
fontSize: 'medium',
readingWidth: 'medium',
readingSpeed: 200,
};
export const getReadingSpeed = (): number => {
try {
const savedSettings = localStorage.getItem('storycove-settings');
if (savedSettings) {
const parsed = JSON.parse(savedSettings);
return parsed.readingSpeed || defaultSettings.readingSpeed;
}
} catch (error) {
console.error('Failed to parse saved settings:', error);
}
return defaultSettings.readingSpeed;
};
export const calculateReadingTime = (wordCount: number): number => {
const wordsPerMinute = getReadingSpeed();
return Math.max(1, Math.round(wordCount / wordsPerMinute));
};

View File

@@ -14,6 +14,7 @@ export interface Story {
rating?: number; rating?: number;
coverPath?: string; coverPath?: string;
tags: Tag[]; tags: Tag[];
tagNames?: string[] | null; // Used in search results
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -41,6 +42,7 @@ export interface Tag {
id: string; id: string;
name: string; name: string;
storyCount?: number; storyCount?: number;
collectionCount?: number;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
@@ -85,6 +87,7 @@ export interface Collection {
coverImagePath?: string; coverImagePath?: string;
isArchived: boolean; isArchived: boolean;
tags: Tag[]; tags: Tag[];
tagNames?: string[] | null; // Used in search results
collectionStories?: CollectionStory[]; collectionStories?: CollectionStory[];
stories?: CollectionStory[]; // For compatibility stories?: CollectionStory[]; // For compatibility
storyCount: number; storyCount: number;

File diff suppressed because one or more lines are too long