Various Fixes and QoL enhancements.
This commit is contained in:
1
backend/backend.log
Normal file
1
backend/backend.log
Normal file
@@ -0,0 +1 @@
|
|||||||
|
(eval):1: no such file or directory: ./mvnw
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
<p className="theme-text">
|
<div className="flex items-center gap-4">
|
||||||
{stories.length} {stories.length === 1 ? 'story' : 'stories'}
|
<p className="theme-text">
|
||||||
</p>
|
{stories.length} {stories.length === 1 ? 'story' : 'stories'}
|
||||||
|
</p>
|
||||||
|
<Button href={`/add-story?authorId=${authorId}`}>
|
||||||
|
Add Story
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stories.length === 0 ? (
|
{stories.length === 0 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
setSortOption(newSortOption);
|
||||||
// Toggle direction if same option
|
// Set appropriate default direction for the sort option
|
||||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
if (newSortOption === 'title' || newSortOption === 'authorName') {
|
||||||
|
setSortDirection('asc'); // Alphabetical fields default to ascending
|
||||||
} else {
|
} else {
|
||||||
setSortOption(newSortOption);
|
setSortDirection('desc'); // Numeric/date fields default to descending
|
||||||
setSortDirection('desc'); // Default to desc for new sort option
|
|
||||||
}
|
}
|
||||||
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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,8 +94,16 @@ export default function RichTextEditor({
|
|||||||
const div = visualDivRef.current;
|
const div = visualDivRef.current;
|
||||||
if (div) {
|
if (div) {
|
||||||
const newHtml = div.innerHTML;
|
const newHtml = div.innerHTML;
|
||||||
onChange(newHtml);
|
setIsUserTyping(true);
|
||||||
setHtmlValue(newHtml);
|
|
||||||
|
// 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
|
// 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,16 +506,25 @@ 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
|
<div className="relative">
|
||||||
ref={visualDivRef}
|
<div
|
||||||
contentEditable
|
ref={visualDivRef}
|
||||||
onInput={handleVisualContentChange}
|
contentEditable
|
||||||
onPaste={handlePaste}
|
onInput={handleVisualContentChange}
|
||||||
className="p-3 min-h-[300px] focus:outline-none focus:ring-0 whitespace-pre-wrap"
|
onPaste={handlePaste}
|
||||||
style={{ minHeight: '300px' }}
|
className="p-3 min-h-[300px] focus:outline-none focus:ring-0 whitespace-pre-wrap"
|
||||||
dangerouslySetInnerHTML={{ __html: value || `<p>${placeholder}</p>` }}
|
style={{ minHeight: '300px' }}
|
||||||
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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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');
|
||||||
window.location.href = '/login';
|
|
||||||
|
// 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|||||||
33
frontend/src/lib/settings.ts
Normal file
33
frontend/src/lib/settings.ts
Normal 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));
|
||||||
|
};
|
||||||
@@ -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
Reference in New Issue
Block a user