MVP Version
This commit is contained in:
@@ -2,8 +2,10 @@ package com.storycove;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
public class StoryCoveApplication {
|
public class StoryCoveApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
package com.storycove.controller;
|
package com.storycove.controller;
|
||||||
|
|
||||||
import com.storycove.dto.AuthorDto;
|
import com.storycove.dto.AuthorDto;
|
||||||
|
import com.storycove.dto.AuthorSearchDto;
|
||||||
|
import com.storycove.dto.SearchResultDto;
|
||||||
import com.storycove.entity.Author;
|
import com.storycove.entity.Author;
|
||||||
import com.storycove.service.AuthorService;
|
import com.storycove.service.AuthorService;
|
||||||
import com.storycove.service.ImageService;
|
import com.storycove.service.ImageService;
|
||||||
|
import com.storycove.service.TypesenseService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -14,6 +20,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -23,12 +30,16 @@ import java.util.stream.Collectors;
|
|||||||
@RequestMapping("/api/authors")
|
@RequestMapping("/api/authors")
|
||||||
public class AuthorController {
|
public class AuthorController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(AuthorController.class);
|
||||||
|
|
||||||
private final AuthorService authorService;
|
private final AuthorService authorService;
|
||||||
private final ImageService imageService;
|
private final ImageService imageService;
|
||||||
|
private final TypesenseService typesenseService;
|
||||||
|
|
||||||
public AuthorController(AuthorService authorService, ImageService imageService) {
|
public AuthorController(AuthorService authorService, ImageService imageService, TypesenseService typesenseService) {
|
||||||
this.authorService = authorService;
|
this.authorService = authorService;
|
||||||
this.imageService = imageService;
|
this.imageService = imageService;
|
||||||
|
this.typesenseService = typesenseService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -63,9 +74,65 @@ public class AuthorController {
|
|||||||
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedAuthor));
|
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedAuthor));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping(value = "/{id}", consumes = "multipart/form-data")
|
||||||
public ResponseEntity<AuthorDto> updateAuthor(@PathVariable UUID id,
|
public ResponseEntity<AuthorDto> updateAuthorMultipart(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@RequestParam(required = false) String name,
|
||||||
|
@RequestParam(required = false) String notes,
|
||||||
|
@RequestParam(required = false) List<String> urls,
|
||||||
|
@RequestParam(required = false, name = "authorRating") Integer rating,
|
||||||
|
@RequestParam(required = false, name = "avatar") MultipartFile avatarFile) {
|
||||||
|
|
||||||
|
System.out.println("DEBUG: MULTIPART PUT called with:");
|
||||||
|
System.out.println(" - name: " + name);
|
||||||
|
System.out.println(" - notes: " + notes);
|
||||||
|
System.out.println(" - urls: " + urls);
|
||||||
|
System.out.println(" - rating: " + rating);
|
||||||
|
System.out.println(" - avatar: " + (avatarFile != null ? avatarFile.getOriginalFilename() : "null"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
Author existingAuthor = authorService.findById(id);
|
||||||
|
|
||||||
|
// Update basic fields
|
||||||
|
if (name != null && !name.trim().isEmpty()) {
|
||||||
|
existingAuthor.setName(name.trim());
|
||||||
|
}
|
||||||
|
if (notes != null) {
|
||||||
|
existingAuthor.setNotes(notes);
|
||||||
|
}
|
||||||
|
if (urls != null) {
|
||||||
|
existingAuthor.setUrls(urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle rating update
|
||||||
|
if (rating != null) {
|
||||||
|
System.out.println("DEBUG: Setting author rating via PUT: " + rating);
|
||||||
|
existingAuthor.setAuthorRating(rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle avatar upload if provided
|
||||||
|
if (avatarFile != null && !avatarFile.isEmpty()) {
|
||||||
|
String imagePath = imageService.uploadImage(avatarFile, ImageService.ImageType.AVATAR);
|
||||||
|
existingAuthor.setAvatarImagePath(imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
Author updatedAuthor = authorService.update(id, existingAuthor);
|
||||||
|
return ResponseEntity.ok(convertToDto(updatedAuthor));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.badRequest().body(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping(value = "/{id}", consumes = "application/json")
|
||||||
|
public ResponseEntity<AuthorDto> updateAuthorJson(@PathVariable UUID id,
|
||||||
@Valid @RequestBody UpdateAuthorRequest request) {
|
@Valid @RequestBody UpdateAuthorRequest request) {
|
||||||
|
System.out.println("DEBUG: JSON PUT called with:");
|
||||||
|
System.out.println(" - name: " + request.getName());
|
||||||
|
System.out.println(" - notes: " + request.getNotes());
|
||||||
|
System.out.println(" - urls: " + request.getUrls());
|
||||||
|
System.out.println(" - rating: " + request.getRating());
|
||||||
|
|
||||||
Author existingAuthor = authorService.findById(id);
|
Author existingAuthor = authorService.findById(id);
|
||||||
updateAuthorFromRequest(existingAuthor, request);
|
updateAuthorFromRequest(existingAuthor, request);
|
||||||
|
|
||||||
@@ -73,6 +140,15 @@ public class AuthorController {
|
|||||||
return ResponseEntity.ok(convertToDto(updatedAuthor));
|
return ResponseEntity.ok(convertToDto(updatedAuthor));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<String> updateAuthorGeneric(@PathVariable UUID id, HttpServletRequest request) {
|
||||||
|
System.out.println("DEBUG: GENERIC PUT called!");
|
||||||
|
System.out.println(" - Content-Type: " + request.getContentType());
|
||||||
|
System.out.println(" - Method: " + request.getMethod());
|
||||||
|
|
||||||
|
return ResponseEntity.status(415).body("Unsupported Media Type. Expected multipart/form-data or application/json");
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public ResponseEntity<?> deleteAuthor(@PathVariable UUID id) {
|
public ResponseEntity<?> deleteAuthor(@PathVariable UUID id) {
|
||||||
authorService.delete(id);
|
authorService.delete(id);
|
||||||
@@ -103,8 +179,79 @@ public class AuthorController {
|
|||||||
|
|
||||||
@PostMapping("/{id}/rating")
|
@PostMapping("/{id}/rating")
|
||||||
public ResponseEntity<AuthorDto> rateAuthor(@PathVariable UUID id, @RequestBody RatingRequest request) {
|
public ResponseEntity<AuthorDto> rateAuthor(@PathVariable UUID id, @RequestBody RatingRequest request) {
|
||||||
|
System.out.println("DEBUG: Rating author " + id + " with rating " + request.getRating());
|
||||||
Author author = authorService.setRating(id, request.getRating());
|
Author author = authorService.setRating(id, request.getRating());
|
||||||
return ResponseEntity.ok(convertToDto(author));
|
System.out.println("DEBUG: After setRating, author rating is: " + author.getAuthorRating());
|
||||||
|
AuthorDto dto = convertToDto(author);
|
||||||
|
System.out.println("DEBUG: Final DTO rating is: " + dto.getAuthorRating());
|
||||||
|
return ResponseEntity.ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/debug")
|
||||||
|
public ResponseEntity<Map<String, Object>> debugAuthor(@PathVariable UUID id) {
|
||||||
|
Author author = authorService.findById(id);
|
||||||
|
Integer directDbRating = null;
|
||||||
|
String dbError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
directDbRating = authorService.getAuthorRatingFromDb(id);
|
||||||
|
} catch (Exception e) {
|
||||||
|
dbError = e.getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> debug = Map.of(
|
||||||
|
"authorId", author.getId(),
|
||||||
|
"authorName", author.getName(),
|
||||||
|
"authorRating_entity", author.getAuthorRating(),
|
||||||
|
"authorRating_direct_db", directDbRating,
|
||||||
|
"db_error", dbError,
|
||||||
|
"toString", author.toString()
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/test-rating/{rating}")
|
||||||
|
public ResponseEntity<Map<String, Object>> testSetRating(@PathVariable UUID id, @PathVariable Integer rating) {
|
||||||
|
try {
|
||||||
|
System.out.println("DEBUG: Test setting rating " + rating + " for author " + id);
|
||||||
|
Author author = authorService.setRating(id, rating);
|
||||||
|
System.out.println("DEBUG: After test setRating, got: " + author.getAuthorRating());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"authorRating", author.getAuthorRating(),
|
||||||
|
"message", "Rating set successfully"
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"error", e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/test-put-rating")
|
||||||
|
public ResponseEntity<Map<String, Object>> testPutWithRating(@PathVariable UUID id, @RequestParam Integer rating) {
|
||||||
|
try {
|
||||||
|
System.out.println("DEBUG: Test PUT with rating " + rating + " for author " + id);
|
||||||
|
|
||||||
|
Author existingAuthor = authorService.findById(id);
|
||||||
|
existingAuthor.setAuthorRating(rating);
|
||||||
|
Author updatedAuthor = authorService.update(id, existingAuthor);
|
||||||
|
|
||||||
|
System.out.println("DEBUG: After PUT update, rating is: " + updatedAuthor.getAuthorRating());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"authorRating", updatedAuthor.getAuthorRating(),
|
||||||
|
"message", "Rating updated via PUT successfully"
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"error", e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
@@ -120,6 +267,91 @@ public class AuthorController {
|
|||||||
return ResponseEntity.ok(authorDtos);
|
return ResponseEntity.ok(authorDtos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/search-typesense")
|
||||||
|
public ResponseEntity<SearchResultDto<AuthorDto>> searchAuthorsTypesense(
|
||||||
|
@RequestParam(defaultValue = "*") String q,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@RequestParam(defaultValue = "name") String sortBy,
|
||||||
|
@RequestParam(defaultValue = "asc") String sortOrder) {
|
||||||
|
|
||||||
|
SearchResultDto<AuthorSearchDto> searchResults = typesenseService.searchAuthors(q, page, size, sortBy, sortOrder);
|
||||||
|
|
||||||
|
// Convert AuthorSearchDto results to AuthorDto
|
||||||
|
SearchResultDto<AuthorDto> results = new SearchResultDto<>();
|
||||||
|
results.setQuery(searchResults.getQuery());
|
||||||
|
results.setPage(searchResults.getPage());
|
||||||
|
results.setPerPage(searchResults.getPerPage());
|
||||||
|
results.setTotalHits(searchResults.getTotalHits());
|
||||||
|
results.setSearchTimeMs(searchResults.getSearchTimeMs());
|
||||||
|
|
||||||
|
// Handle null results gracefully
|
||||||
|
List<AuthorDto> authorDtos = searchResults.getResults() != null
|
||||||
|
? searchResults.getResults().stream()
|
||||||
|
.map(this::convertSearchDtoToDto)
|
||||||
|
.collect(Collectors.toList())
|
||||||
|
: new ArrayList<>();
|
||||||
|
results.setResults(authorDtos);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/reindex-typesense")
|
||||||
|
public ResponseEntity<Map<String, Object>> reindexAuthorsTypesense() {
|
||||||
|
try {
|
||||||
|
List<Author> allAuthors = authorService.findAllWithStories();
|
||||||
|
typesenseService.reindexAllAuthors(allAuthors);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Reindexed " + allAuthors.size() + " authors",
|
||||||
|
"count", allAuthors.size()
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to reindex authors", e);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"error", e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/recreate-typesense-collection")
|
||||||
|
public ResponseEntity<Map<String, Object>> recreateAuthorsCollection() {
|
||||||
|
try {
|
||||||
|
// This will delete the existing collection and recreate it with correct schema
|
||||||
|
List<Author> allAuthors = authorService.findAllWithStories();
|
||||||
|
typesenseService.reindexAllAuthors(allAuthors);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Recreated authors collection and indexed " + allAuthors.size() + " authors",
|
||||||
|
"count", allAuthors.size()
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to recreate authors collection", e);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"error", e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/typesense-schema")
|
||||||
|
public ResponseEntity<Map<String, Object>> getAuthorsTypesenseSchema() {
|
||||||
|
try {
|
||||||
|
Map<String, Object> schema = typesenseService.getAuthorsCollectionSchema();
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"schema", schema
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to get authors schema", e);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"error", e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/top-rated")
|
@GetMapping("/top-rated")
|
||||||
public ResponseEntity<List<AuthorDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
|
public ResponseEntity<List<AuthorDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
|
||||||
Pageable pageable = PageRequest.of(0, limit);
|
Pageable pageable = PageRequest.of(0, limit);
|
||||||
@@ -158,6 +390,10 @@ public class AuthorController {
|
|||||||
if (updateReq.getUrls() != null) {
|
if (updateReq.getUrls() != null) {
|
||||||
author.setUrls(updateReq.getUrls());
|
author.setUrls(updateReq.getUrls());
|
||||||
}
|
}
|
||||||
|
if (updateReq.getRating() != null) {
|
||||||
|
System.out.println("DEBUG: Setting author rating via JSON: " + updateReq.getRating());
|
||||||
|
author.setAuthorRating(updateReq.getRating());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,12 +403,38 @@ public class AuthorController {
|
|||||||
dto.setName(author.getName());
|
dto.setName(author.getName());
|
||||||
dto.setNotes(author.getNotes());
|
dto.setNotes(author.getNotes());
|
||||||
dto.setAvatarImagePath(author.getAvatarImagePath());
|
dto.setAvatarImagePath(author.getAvatarImagePath());
|
||||||
|
|
||||||
|
// Debug logging for author rating
|
||||||
|
System.out.println("DEBUG: Converting author " + author.getName() +
|
||||||
|
" with rating: " + author.getAuthorRating());
|
||||||
|
|
||||||
dto.setAuthorRating(author.getAuthorRating());
|
dto.setAuthorRating(author.getAuthorRating());
|
||||||
dto.setUrls(author.getUrls());
|
dto.setUrls(author.getUrls());
|
||||||
dto.setStoryCount(author.getStories() != null ? author.getStories().size() : 0);
|
dto.setStoryCount(author.getStories() != null ? author.getStories().size() : 0);
|
||||||
dto.setCreatedAt(author.getCreatedAt());
|
dto.setCreatedAt(author.getCreatedAt());
|
||||||
dto.setUpdatedAt(author.getUpdatedAt());
|
dto.setUpdatedAt(author.getUpdatedAt());
|
||||||
|
|
||||||
|
// Calculate and set average story rating
|
||||||
|
dto.setAverageStoryRating(authorService.calculateAverageStoryRating(author.getId()));
|
||||||
|
|
||||||
|
System.out.println("DEBUG: DTO authorRating set to: " + dto.getAuthorRating());
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthorDto convertSearchDtoToDto(AuthorSearchDto searchDto) {
|
||||||
|
AuthorDto dto = new AuthorDto();
|
||||||
|
dto.setId(searchDto.getId());
|
||||||
|
dto.setName(searchDto.getName());
|
||||||
|
dto.setNotes(searchDto.getNotes());
|
||||||
|
dto.setAuthorRating(searchDto.getAuthorRating());
|
||||||
|
dto.setAverageStoryRating(searchDto.getAverageStoryRating());
|
||||||
|
dto.setStoryCount(searchDto.getStoryCount());
|
||||||
|
dto.setUrls(searchDto.getUrls());
|
||||||
|
dto.setAvatarImagePath(searchDto.getAvatarImagePath());
|
||||||
|
dto.setCreatedAt(searchDto.getCreatedAt());
|
||||||
|
dto.setUpdatedAt(searchDto.getUpdatedAt());
|
||||||
|
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +457,7 @@ public class AuthorController {
|
|||||||
private String name;
|
private String name;
|
||||||
private String notes;
|
private String notes;
|
||||||
private List<String> urls;
|
private List<String> urls;
|
||||||
|
private Integer rating;
|
||||||
|
|
||||||
// Getters and setters
|
// Getters and setters
|
||||||
public String getName() { return name; }
|
public String getName() { return name; }
|
||||||
@@ -203,6 +466,8 @@ public class AuthorController {
|
|||||||
public void setNotes(String notes) { this.notes = notes; }
|
public void setNotes(String notes) { this.notes = notes; }
|
||||||
public List<String> getUrls() { return urls; }
|
public List<String> getUrls() { return urls; }
|
||||||
public void setUrls(List<String> urls) { this.urls = urls; }
|
public void setUrls(List<String> urls) { this.urls = urls; }
|
||||||
|
public Integer getRating() { return rating; }
|
||||||
|
public void setRating(Integer rating) { this.rating = rating; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class RatingRequest {
|
public static class RatingRequest {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import com.storycove.dto.SearchResultDto;
|
|||||||
import com.storycove.dto.StorySearchDto;
|
import com.storycove.dto.StorySearchDto;
|
||||||
import com.storycove.service.*;
|
import com.storycove.service.*;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@@ -32,6 +34,8 @@ import java.util.stream.Collectors;
|
|||||||
@RequestMapping("/api/stories")
|
@RequestMapping("/api/stories")
|
||||||
public class StoryController {
|
public class StoryController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(StoryController.class);
|
||||||
|
|
||||||
private final StoryService storyService;
|
private final StoryService storyService;
|
||||||
private final AuthorService authorService;
|
private final AuthorService authorService;
|
||||||
private final SeriesService seriesService;
|
private final SeriesService seriesService;
|
||||||
@@ -141,6 +145,60 @@ public class StoryController {
|
|||||||
return ResponseEntity.ok(convertToDto(story));
|
return ResponseEntity.ok(convertToDto(story));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/reindex")
|
||||||
|
public ResponseEntity<String> manualReindex() {
|
||||||
|
if (typesenseService == null) {
|
||||||
|
return ResponseEntity.ok("Typesense is not enabled, no reindexing performed");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<Story> allStories = storyService.findAllWithAssociations();
|
||||||
|
typesenseService.reindexAllStories(allStories);
|
||||||
|
return ResponseEntity.ok("Successfully reindexed " + allStories.size() + " stories");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.status(500).body("Failed to reindex stories: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/reindex-typesense")
|
||||||
|
public ResponseEntity<Map<String, Object>> reindexStoriesTypesense() {
|
||||||
|
try {
|
||||||
|
List<Story> allStories = storyService.findAllWithAssociations();
|
||||||
|
typesenseService.reindexAllStories(allStories);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Reindexed " + allStories.size() + " stories",
|
||||||
|
"count", allStories.size()
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to reindex stories", e);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"error", e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/recreate-typesense-collection")
|
||||||
|
public ResponseEntity<Map<String, Object>> recreateStoriesCollection() {
|
||||||
|
try {
|
||||||
|
// This will delete the existing collection and recreate it with correct schema
|
||||||
|
List<Story> allStories = storyService.findAllWithAssociations();
|
||||||
|
typesenseService.reindexAllStories(allStories);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Recreated stories collection and indexed " + allStories.size() + " stories",
|
||||||
|
"count", allStories.size()
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to recreate stories collection", e);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"error", e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
public ResponseEntity<SearchResultDto<StorySearchDto>> searchStories(
|
public ResponseEntity<SearchResultDto<StorySearchDto>> searchStories(
|
||||||
@RequestParam String query,
|
@RequestParam String query,
|
||||||
@@ -149,11 +207,13 @@ public class StoryController {
|
|||||||
@RequestParam(required = false) List<String> authors,
|
@RequestParam(required = false) List<String> authors,
|
||||||
@RequestParam(required = false) List<String> tags,
|
@RequestParam(required = false) List<String> tags,
|
||||||
@RequestParam(required = false) Integer minRating,
|
@RequestParam(required = false) Integer minRating,
|
||||||
@RequestParam(required = false) Integer maxRating) {
|
@RequestParam(required = false) Integer maxRating,
|
||||||
|
@RequestParam(required = false) String sortBy,
|
||||||
|
@RequestParam(required = false) String sortDir) {
|
||||||
|
|
||||||
if (typesenseService != null) {
|
if (typesenseService != null) {
|
||||||
SearchResultDto<StorySearchDto> results = typesenseService.searchStories(
|
SearchResultDto<StorySearchDto> results = typesenseService.searchStories(
|
||||||
query, page, size, authors, tags, minRating, maxRating);
|
query, page, size, authors, tags, minRating, maxRating, sortBy, sortDir);
|
||||||
return ResponseEntity.ok(results);
|
return ResponseEntity.ok(results);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to basic search if Typesense is not available
|
// Fallback to basic search if Typesense is not available
|
||||||
@@ -256,9 +316,13 @@ public class StoryController {
|
|||||||
story.setAuthor(author);
|
story.setAuthor(author);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle series - either by ID or by name
|
||||||
if (createReq.getSeriesId() != null) {
|
if (createReq.getSeriesId() != null) {
|
||||||
Series series = seriesService.findById(createReq.getSeriesId());
|
Series series = seriesService.findById(createReq.getSeriesId());
|
||||||
story.setSeries(series);
|
story.setSeries(series);
|
||||||
|
} else if (createReq.getSeriesName() != null && !createReq.getSeriesName().trim().isEmpty()) {
|
||||||
|
Series series = seriesService.findOrCreate(createReq.getSeriesName().trim());
|
||||||
|
story.setSeries(series);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tags
|
// Handle tags
|
||||||
@@ -298,9 +362,13 @@ public class StoryController {
|
|||||||
Author author = authorService.findById(updateReq.getAuthorId());
|
Author author = authorService.findById(updateReq.getAuthorId());
|
||||||
story.setAuthor(author);
|
story.setAuthor(author);
|
||||||
}
|
}
|
||||||
|
// Handle series - either by ID or by name
|
||||||
if (updateReq.getSeriesId() != null) {
|
if (updateReq.getSeriesId() != null) {
|
||||||
Series series = seriesService.findById(updateReq.getSeriesId());
|
Series series = seriesService.findById(updateReq.getSeriesId());
|
||||||
story.setSeries(series);
|
story.setSeries(series);
|
||||||
|
} else if (updateReq.getSeriesName() != null && !updateReq.getSeriesName().trim().isEmpty()) {
|
||||||
|
Series series = seriesService.findOrCreate(updateReq.getSeriesName().trim());
|
||||||
|
story.setSeries(series);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Tags are now handled in StoryService.updateWithTagNames()
|
// Note: Tags are now handled in StoryService.updateWithTagNames()
|
||||||
@@ -360,6 +428,7 @@ public class StoryController {
|
|||||||
private UUID authorId;
|
private UUID authorId;
|
||||||
private String authorName;
|
private String authorName;
|
||||||
private UUID seriesId;
|
private UUID seriesId;
|
||||||
|
private String seriesName;
|
||||||
private List<String> tagNames;
|
private List<String> tagNames;
|
||||||
|
|
||||||
// Getters and setters
|
// Getters and setters
|
||||||
@@ -381,6 +450,8 @@ public class StoryController {
|
|||||||
public void setAuthorName(String authorName) { this.authorName = authorName; }
|
public void setAuthorName(String authorName) { this.authorName = authorName; }
|
||||||
public UUID getSeriesId() { return seriesId; }
|
public UUID getSeriesId() { return seriesId; }
|
||||||
public void setSeriesId(UUID seriesId) { this.seriesId = seriesId; }
|
public void setSeriesId(UUID seriesId) { this.seriesId = seriesId; }
|
||||||
|
public String getSeriesName() { return seriesName; }
|
||||||
|
public void setSeriesName(String seriesName) { this.seriesName = seriesName; }
|
||||||
public List<String> getTagNames() { return tagNames; }
|
public List<String> getTagNames() { return tagNames; }
|
||||||
public void setTagNames(List<String> tagNames) { this.tagNames = tagNames; }
|
public void setTagNames(List<String> tagNames) { this.tagNames = tagNames; }
|
||||||
}
|
}
|
||||||
@@ -394,6 +465,7 @@ public class StoryController {
|
|||||||
private Integer volume;
|
private Integer volume;
|
||||||
private UUID authorId;
|
private UUID authorId;
|
||||||
private UUID seriesId;
|
private UUID seriesId;
|
||||||
|
private String seriesName;
|
||||||
private List<String> tagNames;
|
private List<String> tagNames;
|
||||||
|
|
||||||
// Getters and setters
|
// Getters and setters
|
||||||
@@ -413,6 +485,8 @@ public class StoryController {
|
|||||||
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
|
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
|
||||||
public UUID getSeriesId() { return seriesId; }
|
public UUID getSeriesId() { return seriesId; }
|
||||||
public void setSeriesId(UUID seriesId) { this.seriesId = seriesId; }
|
public void setSeriesId(UUID seriesId) { this.seriesId = seriesId; }
|
||||||
|
public String getSeriesName() { return seriesName; }
|
||||||
|
public void setSeriesName(String seriesName) { this.seriesName = seriesName; }
|
||||||
public List<String> getTagNames() { return tagNames; }
|
public List<String> getTagNames() { return tagNames; }
|
||||||
public void setTagNames(List<String> tagNames) { this.tagNames = tagNames; }
|
public void setTagNames(List<String> tagNames) { this.tagNames = tagNames; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class AuthorDto {
|
|||||||
|
|
||||||
private String avatarImagePath;
|
private String avatarImagePath;
|
||||||
private Integer authorRating;
|
private Integer authorRating;
|
||||||
|
private Double averageStoryRating;
|
||||||
private List<String> urls;
|
private List<String> urls;
|
||||||
private Integer storyCount;
|
private Integer storyCount;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
@@ -71,6 +72,14 @@ public class AuthorDto {
|
|||||||
this.authorRating = authorRating;
|
this.authorRating = authorRating;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Double getAverageStoryRating() {
|
||||||
|
return averageStoryRating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAverageStoryRating(Double averageStoryRating) {
|
||||||
|
this.averageStoryRating = averageStoryRating;
|
||||||
|
}
|
||||||
|
|
||||||
public List<String> getUrls() {
|
public List<String> getUrls() {
|
||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|||||||
58
backend/src/main/java/com/storycove/dto/AuthorSearchDto.java
Normal file
58
backend/src/main/java/com/storycove/dto/AuthorSearchDto.java
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class AuthorSearchDto {
|
||||||
|
|
||||||
|
private UUID id;
|
||||||
|
private String name;
|
||||||
|
private String notes;
|
||||||
|
private Integer authorRating;
|
||||||
|
private Double averageStoryRating;
|
||||||
|
private Integer storyCount;
|
||||||
|
private List<String> urls;
|
||||||
|
private String avatarImagePath;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
// Search-specific fields
|
||||||
|
private Long searchScore;
|
||||||
|
|
||||||
|
public AuthorSearchDto() {}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public void setId(UUID id) { this.id = id; }
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public String getNotes() { return notes; }
|
||||||
|
public void setNotes(String notes) { this.notes = notes; }
|
||||||
|
|
||||||
|
public Integer getAuthorRating() { return authorRating; }
|
||||||
|
public void setAuthorRating(Integer authorRating) { this.authorRating = authorRating; }
|
||||||
|
|
||||||
|
public Double getAverageStoryRating() { return averageStoryRating; }
|
||||||
|
public void setAverageStoryRating(Double averageStoryRating) { this.averageStoryRating = averageStoryRating; }
|
||||||
|
|
||||||
|
public Integer getStoryCount() { return storyCount; }
|
||||||
|
public void setStoryCount(Integer storyCount) { this.storyCount = storyCount; }
|
||||||
|
|
||||||
|
public List<String> getUrls() { return urls; }
|
||||||
|
public void setUrls(List<String> urls) { this.urls = urls; }
|
||||||
|
|
||||||
|
public String getAvatarImagePath() { return avatarImagePath; }
|
||||||
|
public void setAvatarImagePath(String avatarImagePath) { this.avatarImagePath = avatarImagePath; }
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
|
||||||
|
public Long getSearchScore() { return searchScore; }
|
||||||
|
public void setSearchScore(Long searchScore) { this.searchScore = searchScore; }
|
||||||
|
}
|
||||||
@@ -49,4 +49,7 @@ public interface AuthorRepository extends JpaRepository<Author, UUID> {
|
|||||||
|
|
||||||
@Query("SELECT COUNT(a) FROM Author a WHERE a.createdAt >= :cutoffDate")
|
@Query("SELECT COUNT(a) FROM Author a WHERE a.createdAt >= :cutoffDate")
|
||||||
long countRecentAuthors(@Param("cutoffDate") java.time.LocalDateTime cutoffDate);
|
long countRecentAuthors(@Param("cutoffDate") java.time.LocalDateTime cutoffDate);
|
||||||
|
|
||||||
|
@Query(value = "SELECT author_rating FROM authors WHERE id = :id", nativeQuery = true)
|
||||||
|
Integer findAuthorRatingById(@Param("id") UUID id);
|
||||||
}
|
}
|
||||||
@@ -108,4 +108,10 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
|
|||||||
|
|
||||||
@Query("SELECT s FROM Story s WHERE s.createdAt >= :since ORDER BY s.createdAt DESC")
|
@Query("SELECT s FROM Story s WHERE s.createdAt >= :since ORDER BY s.createdAt DESC")
|
||||||
List<Story> findRecentlyRead(@Param("since") LocalDateTime since);
|
List<Story> findRecentlyRead(@Param("since") LocalDateTime since);
|
||||||
|
|
||||||
|
@Query("SELECT DISTINCT s FROM Story s " +
|
||||||
|
"LEFT JOIN FETCH s.author " +
|
||||||
|
"LEFT JOIN FETCH s.series " +
|
||||||
|
"LEFT JOIN FETCH s.tags")
|
||||||
|
List<Story> findAllWithAssociations();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.storycove.scheduled;
|
||||||
|
|
||||||
|
import com.storycove.entity.Story;
|
||||||
|
import com.storycove.service.StoryService;
|
||||||
|
import com.storycove.service.TypesenseService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled task to periodically reindex all stories in Typesense
|
||||||
|
* to ensure search index stays synchronized with database changes.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "storycove.typesense.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class TypesenseIndexScheduler {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(TypesenseIndexScheduler.class);
|
||||||
|
|
||||||
|
private final StoryService storyService;
|
||||||
|
private final TypesenseService typesenseService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public TypesenseIndexScheduler(StoryService storyService,
|
||||||
|
@Autowired(required = false) TypesenseService typesenseService) {
|
||||||
|
this.storyService = storyService;
|
||||||
|
this.typesenseService = typesenseService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled task that runs periodically to reindex all stories in Typesense.
|
||||||
|
* This ensures the search index stays synchronized with any database changes
|
||||||
|
* that might have occurred outside of the normal story update flow.
|
||||||
|
*
|
||||||
|
* Interval is configurable via storycove.typesense.reindex-interval property (default: 1 hour).
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedRateString = "${storycove.typesense.reindex-interval:3600000}")
|
||||||
|
public void reindexAllStories() {
|
||||||
|
if (typesenseService == null) {
|
||||||
|
logger.debug("TypesenseService is not available, skipping scheduled reindexing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Starting scheduled Typesense reindexing at {}", LocalDateTime.now());
|
||||||
|
|
||||||
|
try {
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
// Get all stories from database with eagerly loaded associations
|
||||||
|
List<Story> allStories = storyService.findAllWithAssociations();
|
||||||
|
|
||||||
|
if (allStories.isEmpty()) {
|
||||||
|
logger.info("No stories found in database, skipping reindexing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform full reindex
|
||||||
|
typesenseService.reindexAllStories(allStories);
|
||||||
|
|
||||||
|
long endTime = System.currentTimeMillis();
|
||||||
|
long duration = endTime - startTime;
|
||||||
|
|
||||||
|
logger.info("Completed scheduled Typesense reindexing of {} stories in {}ms",
|
||||||
|
allStories.size(), duration);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to complete scheduled Typesense reindexing", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual trigger for reindexing - can be called from other services or endpoints if needed
|
||||||
|
*/
|
||||||
|
public void triggerManualReindex() {
|
||||||
|
logger.info("Manual Typesense reindexing triggered");
|
||||||
|
reindexAllStories();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.storycove.service;
|
||||||
|
|
||||||
|
import com.storycove.entity.Author;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "storycove.typesense.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class AuthorIndexScheduler {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(AuthorIndexScheduler.class);
|
||||||
|
|
||||||
|
private final AuthorService authorService;
|
||||||
|
private final TypesenseService typesenseService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public AuthorIndexScheduler(AuthorService authorService, TypesenseService typesenseService) {
|
||||||
|
this.authorService = authorService;
|
||||||
|
this.typesenseService = typesenseService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedRateString = "${storycove.typesense.author-reindex-interval:7200000}") // 2 hours default
|
||||||
|
public void reindexAllAuthors() {
|
||||||
|
try {
|
||||||
|
logger.info("Starting scheduled author reindexing...");
|
||||||
|
|
||||||
|
List<Author> allAuthors = authorService.findAllWithStories();
|
||||||
|
logger.info("Found {} authors to reindex", allAuthors.size());
|
||||||
|
|
||||||
|
if (!allAuthors.isEmpty()) {
|
||||||
|
typesenseService.reindexAllAuthors(allAuthors);
|
||||||
|
logger.info("Successfully completed scheduled author reindexing");
|
||||||
|
} else {
|
||||||
|
logger.info("No authors found to reindex");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to complete scheduled author reindexing", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual reindex endpoint can be added to AuthorController if needed
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package com.storycove.service;
|
package com.storycove.service;
|
||||||
|
|
||||||
import com.storycove.entity.Author;
|
import com.storycove.entity.Author;
|
||||||
|
import com.storycove.entity.Story;
|
||||||
import com.storycove.repository.AuthorRepository;
|
import com.storycove.repository.AuthorRepository;
|
||||||
import com.storycove.service.exception.DuplicateResourceException;
|
import com.storycove.service.exception.DuplicateResourceException;
|
||||||
import com.storycove.service.exception.ResourceNotFoundException;
|
import com.storycove.service.exception.ResourceNotFoundException;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -21,11 +24,15 @@ import java.util.UUID;
|
|||||||
@Transactional
|
@Transactional
|
||||||
public class AuthorService {
|
public class AuthorService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(AuthorService.class);
|
||||||
|
|
||||||
private final AuthorRepository authorRepository;
|
private final AuthorRepository authorRepository;
|
||||||
|
private final TypesenseService typesenseService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public AuthorService(AuthorRepository authorRepository) {
|
public AuthorService(AuthorRepository authorRepository, TypesenseService typesenseService) {
|
||||||
this.authorRepository = authorRepository;
|
this.authorRepository = authorRepository;
|
||||||
|
this.typesenseService = typesenseService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -33,6 +40,21 @@ public class AuthorService {
|
|||||||
return authorRepository.findAll();
|
return authorRepository.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Author> findAllWithStories() {
|
||||||
|
List<Author> authors = authorRepository.findAll();
|
||||||
|
// Force load lazy collections within transaction to avoid lazy loading issues
|
||||||
|
authors.forEach(author -> {
|
||||||
|
if (author.getStories() != null) {
|
||||||
|
author.getStories().size(); // Force initialization
|
||||||
|
}
|
||||||
|
if (author.getUrls() != null) {
|
||||||
|
author.getUrls().size(); // Force initialization
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return authors;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public Page<Author> findAll(Pageable pageable) {
|
public Page<Author> findAll(Pageable pageable) {
|
||||||
return authorRepository.findAll(pageable);
|
return authorRepository.findAll(pageable);
|
||||||
@@ -107,7 +129,16 @@ public class AuthorService {
|
|||||||
|
|
||||||
public Author create(@Valid Author author) {
|
public Author create(@Valid Author author) {
|
||||||
validateAuthorForCreate(author);
|
validateAuthorForCreate(author);
|
||||||
return authorRepository.save(author);
|
Author savedAuthor = authorRepository.save(author);
|
||||||
|
|
||||||
|
// Index in Typesense
|
||||||
|
try {
|
||||||
|
typesenseService.indexAuthor(savedAuthor);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to index author in Typesense: " + savedAuthor.getName(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedAuthor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Author update(UUID id, @Valid Author authorUpdates) {
|
public Author update(UUID id, @Valid Author authorUpdates) {
|
||||||
@@ -120,7 +151,16 @@ public class AuthorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateAuthorFields(existingAuthor, authorUpdates);
|
updateAuthorFields(existingAuthor, authorUpdates);
|
||||||
return authorRepository.save(existingAuthor);
|
Author savedAuthor = authorRepository.save(existingAuthor);
|
||||||
|
|
||||||
|
// Update in Typesense
|
||||||
|
try {
|
||||||
|
typesenseService.updateAuthor(savedAuthor);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to update author in Typesense: " + savedAuthor.getName(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedAuthor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void delete(UUID id) {
|
public void delete(UUID id) {
|
||||||
@@ -132,18 +172,43 @@ public class AuthorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authorRepository.delete(author);
|
authorRepository.delete(author);
|
||||||
|
|
||||||
|
// Remove from Typesense
|
||||||
|
try {
|
||||||
|
typesenseService.deleteAuthor(id.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to delete author from Typesense: " + author.getName(), e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Author addUrl(UUID id, String url) {
|
public Author addUrl(UUID id, String url) {
|
||||||
Author author = findById(id);
|
Author author = findById(id);
|
||||||
author.addUrl(url);
|
author.addUrl(url);
|
||||||
return authorRepository.save(author);
|
Author savedAuthor = authorRepository.save(author);
|
||||||
|
|
||||||
|
// Update in Typesense
|
||||||
|
try {
|
||||||
|
typesenseService.updateAuthor(savedAuthor);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to update author in Typesense after adding URL: " + savedAuthor.getName(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedAuthor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Author removeUrl(UUID id, String url) {
|
public Author removeUrl(UUID id, String url) {
|
||||||
Author author = findById(id);
|
Author author = findById(id);
|
||||||
author.removeUrl(url);
|
author.removeUrl(url);
|
||||||
return authorRepository.save(author);
|
Author savedAuthor = authorRepository.save(author);
|
||||||
|
|
||||||
|
// Update in Typesense
|
||||||
|
try {
|
||||||
|
typesenseService.updateAuthor(savedAuthor);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to update author in Typesense after removing URL: " + savedAuthor.getName(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedAuthor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Author setDirectRating(UUID id, int rating) {
|
public Author setDirectRating(UUID id, int rating) {
|
||||||
@@ -162,8 +227,32 @@ public class AuthorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Author author = findById(id);
|
Author author = findById(id);
|
||||||
|
logger.debug("Setting author rating: {} for author: {} (current rating: {})",
|
||||||
|
rating, author.getName(), author.getAuthorRating());
|
||||||
|
|
||||||
author.setAuthorRating(rating);
|
author.setAuthorRating(rating);
|
||||||
return authorRepository.save(author);
|
Author savedAuthor = authorRepository.save(author);
|
||||||
|
|
||||||
|
// Flush and refresh to ensure the entity is up-to-date
|
||||||
|
authorRepository.flush();
|
||||||
|
Author refreshedAuthor = findById(id);
|
||||||
|
|
||||||
|
logger.debug("Saved author rating: {} for author: {}",
|
||||||
|
refreshedAuthor.getAuthorRating(), refreshedAuthor.getName());
|
||||||
|
|
||||||
|
// Update in Typesense
|
||||||
|
try {
|
||||||
|
typesenseService.updateAuthor(refreshedAuthor);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to update author in Typesense after rating: " + refreshedAuthor.getName(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshedAuthor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Integer getAuthorRatingFromDb(UUID id) {
|
||||||
|
return authorRepository.findAuthorRatingById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -171,17 +260,57 @@ public class AuthorService {
|
|||||||
return authorRepository.findTopRatedAuthors(pageable).getContent();
|
return authorRepository.findTopRatedAuthors(pageable).getContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Double calculateAverageStoryRating(UUID authorId) {
|
||||||
|
Author author = findById(authorId);
|
||||||
|
|
||||||
|
if (author.getStories() == null || author.getStories().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Integer> ratings = author.getStories().stream()
|
||||||
|
.map(Story::getRating)
|
||||||
|
.filter(rating -> rating != null)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (ratings.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ratings.stream()
|
||||||
|
.mapToInt(Integer::intValue)
|
||||||
|
.average()
|
||||||
|
.orElse(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
public Author setAvatar(UUID id, String avatarPath) {
|
public Author setAvatar(UUID id, String avatarPath) {
|
||||||
Author author = findById(id);
|
Author author = findById(id);
|
||||||
author.setAvatarImagePath(avatarPath);
|
author.setAvatarImagePath(avatarPath);
|
||||||
return authorRepository.save(author);
|
Author savedAuthor = authorRepository.save(author);
|
||||||
|
|
||||||
|
// Update in Typesense
|
||||||
|
try {
|
||||||
|
typesenseService.updateAuthor(savedAuthor);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to update author in Typesense after setting avatar: " + savedAuthor.getName(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedAuthor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Author removeAvatar(UUID id) {
|
public Author removeAvatar(UUID id) {
|
||||||
Author author = findById(id);
|
Author author = findById(id);
|
||||||
author.setAvatarImagePath(null);
|
author.setAvatarImagePath(null);
|
||||||
return authorRepository.save(author);
|
Author savedAuthor = authorRepository.save(author);
|
||||||
|
|
||||||
|
// Update in Typesense
|
||||||
|
try {
|
||||||
|
typesenseService.updateAuthor(savedAuthor);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to update author in Typesense after removing avatar: " + savedAuthor.getName(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedAuthor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ public class StoryService {
|
|||||||
return storyRepository.findAll();
|
return storyRepository.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Story> findAllWithAssociations() {
|
||||||
|
return storyRepository.findAllWithAssociations();
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public Page<Story> findAll(Pageable pageable) {
|
public Page<Story> findAll(Pageable pageable) {
|
||||||
return storyRepository.findAll(pageable);
|
return storyRepository.findAll(pageable);
|
||||||
@@ -221,7 +226,14 @@ public class StoryService {
|
|||||||
.orElseThrow(() -> new ResourceNotFoundException("Tag not found with id: " + tagId));
|
.orElseThrow(() -> new ResourceNotFoundException("Tag not found with id: " + tagId));
|
||||||
|
|
||||||
story.addTag(tag);
|
story.addTag(tag);
|
||||||
return storyRepository.save(story);
|
Story savedStory = storyRepository.save(story);
|
||||||
|
|
||||||
|
// Update Typesense index with new tag information
|
||||||
|
if (typesenseService != null) {
|
||||||
|
typesenseService.updateStory(savedStory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedStory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -231,7 +243,14 @@ public class StoryService {
|
|||||||
.orElseThrow(() -> new ResourceNotFoundException("Tag not found with id: " + tagId));
|
.orElseThrow(() -> new ResourceNotFoundException("Tag not found with id: " + tagId));
|
||||||
|
|
||||||
story.removeTag(tag);
|
story.removeTag(tag);
|
||||||
return storyRepository.save(story);
|
Story savedStory = storyRepository.save(story);
|
||||||
|
|
||||||
|
// Update Typesense index with updated tag information
|
||||||
|
if (typesenseService != null) {
|
||||||
|
typesenseService.updateStory(savedStory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedStory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -242,7 +261,14 @@ public class StoryService {
|
|||||||
|
|
||||||
Story story = findById(id);
|
Story story = findById(id);
|
||||||
story.setRating(rating);
|
story.setRating(rating);
|
||||||
return storyRepository.save(story);
|
Story savedStory = storyRepository.save(story);
|
||||||
|
|
||||||
|
// Update Typesense index with new rating
|
||||||
|
if (typesenseService != null) {
|
||||||
|
typesenseService.updateStory(savedStory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedStory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.storycove.service;
|
package com.storycove.service;
|
||||||
|
|
||||||
|
import com.storycove.dto.AuthorSearchDto;
|
||||||
import com.storycove.dto.SearchResultDto;
|
import com.storycove.dto.SearchResultDto;
|
||||||
import com.storycove.dto.StorySearchDto;
|
import com.storycove.dto.StorySearchDto;
|
||||||
|
import com.storycove.entity.Author;
|
||||||
import com.storycove.entity.Story;
|
import com.storycove.entity.Story;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -21,6 +23,7 @@ public class TypesenseService {
|
|||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(TypesenseService.class);
|
private static final Logger logger = LoggerFactory.getLogger(TypesenseService.class);
|
||||||
private static final String STORIES_COLLECTION = "stories";
|
private static final String STORIES_COLLECTION = "stories";
|
||||||
|
private static final String AUTHORS_COLLECTION = "authors";
|
||||||
|
|
||||||
private final Client typesenseClient;
|
private final Client typesenseClient;
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ public class TypesenseService {
|
|||||||
public void initializeCollections() {
|
public void initializeCollections() {
|
||||||
try {
|
try {
|
||||||
createStoriesCollectionIfNotExists();
|
createStoriesCollectionIfNotExists();
|
||||||
|
createAuthorsCollectionIfNotExists();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Failed to initialize Typesense collections", e);
|
logger.error("Failed to initialize Typesense collections", e);
|
||||||
}
|
}
|
||||||
@@ -77,6 +81,59 @@ public class TypesenseService {
|
|||||||
logger.info("Stories collection created successfully");
|
logger.info("Stories collection created successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void createAuthorsCollectionIfNotExists() throws Exception {
|
||||||
|
try {
|
||||||
|
// Check if collection already exists
|
||||||
|
typesenseClient.collections(AUTHORS_COLLECTION).retrieve();
|
||||||
|
logger.info("Authors collection already exists");
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.info("Creating authors collection...");
|
||||||
|
createAuthorsCollection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force recreate the authors collection, deleting it first if it exists
|
||||||
|
*/
|
||||||
|
public void recreateAuthorsCollection() throws Exception {
|
||||||
|
try {
|
||||||
|
logger.info("Force deleting authors collection for recreation...");
|
||||||
|
typesenseClient.collections(AUTHORS_COLLECTION).delete();
|
||||||
|
logger.info("Successfully deleted authors collection");
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.debug("Authors collection didn't exist for deletion: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a brief moment to ensure deletion is complete
|
||||||
|
Thread.sleep(100);
|
||||||
|
|
||||||
|
logger.info("Creating authors collection with fresh schema...");
|
||||||
|
createAuthorsCollection();
|
||||||
|
logger.info("Successfully created authors collection");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createAuthorsCollection() throws Exception {
|
||||||
|
List<Field> fields = Arrays.asList(
|
||||||
|
new Field().name("id").type("string").facet(false),
|
||||||
|
new Field().name("name").type("string").facet(true).sort(true), // Enable both faceting and sorting
|
||||||
|
new Field().name("notes").type("string").facet(false).optional(true),
|
||||||
|
new Field().name("authorRating").type("int32").facet(true).sort(true).optional(true), // Enable both faceting and sorting
|
||||||
|
new Field().name("averageStoryRating").type("float").facet(true).sort(true).optional(true), // Enable both faceting and sorting
|
||||||
|
new Field().name("storyCount").type("int32").facet(true).sort(true), // Enable both faceting and sorting
|
||||||
|
new Field().name("urls").type("string[]").facet(false).optional(true),
|
||||||
|
new Field().name("avatarImagePath").type("string").facet(false).optional(true),
|
||||||
|
new Field().name("createdAt").type("int64").facet(true).sort(true), // Enable both faceting and sorting
|
||||||
|
new Field().name("updatedAt").type("int64").facet(true).sort(true) // Enable both faceting and sorting
|
||||||
|
);
|
||||||
|
|
||||||
|
CollectionSchema collectionSchema = new CollectionSchema()
|
||||||
|
.name(AUTHORS_COLLECTION)
|
||||||
|
.fields(fields);
|
||||||
|
|
||||||
|
typesenseClient.collections().create(collectionSchema);
|
||||||
|
logger.info("Authors collection created successfully");
|
||||||
|
}
|
||||||
|
|
||||||
public void indexStory(Story story) {
|
public void indexStory(Story story) {
|
||||||
try {
|
try {
|
||||||
Map<String, Object> document = createStoryDocument(story);
|
Map<String, Object> document = createStoryDocument(story);
|
||||||
@@ -113,7 +170,9 @@ public class TypesenseService {
|
|||||||
List<String> authorFilters,
|
List<String> authorFilters,
|
||||||
List<String> tagFilters,
|
List<String> tagFilters,
|
||||||
Integer minRating,
|
Integer minRating,
|
||||||
Integer maxRating) {
|
Integer maxRating,
|
||||||
|
String sortBy,
|
||||||
|
String sortDir) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
long startTime = System.currentTimeMillis();
|
long startTime = System.currentTimeMillis();
|
||||||
@@ -121,15 +180,21 @@ public class TypesenseService {
|
|||||||
// Convert 0-based page (frontend/backend) to 1-based page (Typesense)
|
// Convert 0-based page (frontend/backend) to 1-based page (Typesense)
|
||||||
int typesensePage = page + 1;
|
int typesensePage = page + 1;
|
||||||
|
|
||||||
|
// Normalize query - treat empty, null, or "*" as wildcard
|
||||||
|
String normalizedQuery = query;
|
||||||
|
if (query == null || query.trim().isEmpty() || "*".equals(query.trim())) {
|
||||||
|
normalizedQuery = "*";
|
||||||
|
}
|
||||||
|
|
||||||
SearchParameters searchParameters = new SearchParameters()
|
SearchParameters searchParameters = new SearchParameters()
|
||||||
.q(query.isEmpty() ? "*" : query)
|
.q(normalizedQuery)
|
||||||
.queryBy("title,description,contentPlain,authorName,seriesName,tagNames")
|
.queryBy("title,description,contentPlain,authorName,seriesName,tagNames")
|
||||||
.page(typesensePage)
|
.page(typesensePage)
|
||||||
.perPage(perPage)
|
.perPage(perPage)
|
||||||
.highlightFields("title,description")
|
.highlightFields("title,description")
|
||||||
.highlightStartTag("<mark>")
|
.highlightStartTag("<mark>")
|
||||||
.highlightEndTag("</mark>")
|
.highlightEndTag("</mark>")
|
||||||
.sortBy("_text_match:desc,createdAt:desc");
|
.sortBy(buildSortParameter(normalizedQuery, sortBy, sortDir));
|
||||||
|
|
||||||
// Add filters
|
// Add filters
|
||||||
List<String> filterConditions = new ArrayList<>();
|
List<String> filterConditions = new ArrayList<>();
|
||||||
@@ -254,7 +319,7 @@ public class TypesenseService {
|
|||||||
document.put("description", story.getDescription() != null ? story.getDescription() : "");
|
document.put("description", story.getDescription() != null ? story.getDescription() : "");
|
||||||
document.put("contentPlain", story.getContentPlain() != null ? story.getContentPlain() : "");
|
document.put("contentPlain", story.getContentPlain() != null ? story.getContentPlain() : "");
|
||||||
|
|
||||||
// Required fields - always include even if null
|
// Author fields - required in schema, use empty string for missing values
|
||||||
if (story.getAuthor() != null) {
|
if (story.getAuthor() != null) {
|
||||||
document.put("authorId", story.getAuthor().getId().toString());
|
document.put("authorId", story.getAuthor().getId().toString());
|
||||||
document.put("authorName", story.getAuthor().getName());
|
document.put("authorName", story.getAuthor().getName());
|
||||||
@@ -263,6 +328,7 @@ public class TypesenseService {
|
|||||||
document.put("authorName", "");
|
document.put("authorName", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Series fields - optional
|
||||||
if (story.getSeries() != null) {
|
if (story.getSeries() != null) {
|
||||||
document.put("seriesId", story.getSeries().getId().toString());
|
document.put("seriesId", story.getSeries().getId().toString());
|
||||||
document.put("seriesName", story.getSeries().getName());
|
document.put("seriesName", story.getSeries().getName());
|
||||||
@@ -305,13 +371,15 @@ public class TypesenseService {
|
|||||||
dto.setDescription((String) doc.get("description"));
|
dto.setDescription((String) doc.get("description"));
|
||||||
dto.setContentPlain((String) doc.get("contentPlain"));
|
dto.setContentPlain((String) doc.get("contentPlain"));
|
||||||
|
|
||||||
if (doc.get("authorId") != null) {
|
String authorId = (String) doc.get("authorId");
|
||||||
dto.setAuthorId(UUID.fromString((String) doc.get("authorId")));
|
if (authorId != null && !authorId.trim().isEmpty()) {
|
||||||
|
dto.setAuthorId(UUID.fromString(authorId));
|
||||||
dto.setAuthorName((String) doc.get("authorName"));
|
dto.setAuthorName((String) doc.get("authorName"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (doc.get("seriesId") != null) {
|
String seriesId = (String) doc.get("seriesId");
|
||||||
dto.setSeriesId(UUID.fromString((String) doc.get("seriesId")));
|
if (seriesId != null && !seriesId.trim().isEmpty()) {
|
||||||
|
dto.setSeriesId(UUID.fromString(seriesId));
|
||||||
dto.setSeriesName((String) doc.get("seriesName"));
|
dto.setSeriesName((String) doc.get("seriesName"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,8 +409,9 @@ public class TypesenseService {
|
|||||||
timestamp, 0, java.time.ZoneOffset.UTC));
|
timestamp, 0, java.time.ZoneOffset.UTC));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set search-specific fields
|
// Set search-specific fields - handle null for wildcard queries
|
||||||
dto.setSearchScore(hit.getTextMatch());
|
Long textMatch = hit.getTextMatch();
|
||||||
|
dto.setSearchScore(textMatch != null ? textMatch : 0L);
|
||||||
|
|
||||||
// Extract highlights from the Typesense response with multiple fallback approaches
|
// Extract highlights from the Typesense response with multiple fallback approaches
|
||||||
List<String> highlights = extractHighlights(hit, dto.getTitle());
|
List<String> highlights = extractHighlights(hit, dto.getTitle());
|
||||||
@@ -441,4 +510,384 @@ public class TypesenseService {
|
|||||||
|
|
||||||
return highlights;
|
return highlights;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build sort parameter for Typesense search.
|
||||||
|
* Maps frontend sort fields to Typesense document fields and handles direction.
|
||||||
|
*/
|
||||||
|
private String buildSortParameter(String query, String sortBy, String sortDir) {
|
||||||
|
// If it's a wildcard query, use default sort without text match scoring
|
||||||
|
boolean isWildcardQuery = query == null || query.trim().isEmpty() || "*".equals(query.trim());
|
||||||
|
|
||||||
|
// If no sort parameters provided, use appropriate default
|
||||||
|
if (sortBy == null || sortBy.trim().isEmpty()) {
|
||||||
|
return isWildcardQuery ? "createdAt:desc" : "_text_match:desc,createdAt:desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map frontend sort fields to Typesense fields
|
||||||
|
String typesenseField = mapSortField(sortBy);
|
||||||
|
String direction = (sortDir != null && sortDir.equalsIgnoreCase("asc")) ? "asc" : "desc";
|
||||||
|
|
||||||
|
String sortParameter = typesenseField + ":" + direction;
|
||||||
|
|
||||||
|
// For text queries (not wildcard), include text match scoring if not already sorting by a text-based field
|
||||||
|
if (!isWildcardQuery && !isTextBasedSort(sortBy)) {
|
||||||
|
sortParameter = "_text_match:desc," + sortParameter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always include createdAt as a tie-breaker unless we're already sorting by it
|
||||||
|
if (!"createdAt".equals(sortBy)) {
|
||||||
|
sortParameter = sortParameter + ",createdAt:desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortParameter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map frontend sort field names to Typesense document field names
|
||||||
|
*/
|
||||||
|
private String mapSortField(String frontendField) {
|
||||||
|
switch (frontendField.toLowerCase()) {
|
||||||
|
case "title":
|
||||||
|
return "title";
|
||||||
|
case "author":
|
||||||
|
case "authorname":
|
||||||
|
return "authorName";
|
||||||
|
case "createdat":
|
||||||
|
case "created_at":
|
||||||
|
case "date":
|
||||||
|
return "createdAt";
|
||||||
|
case "rating":
|
||||||
|
return "rating";
|
||||||
|
case "wordcount":
|
||||||
|
case "word_count":
|
||||||
|
return "wordCount";
|
||||||
|
case "volume":
|
||||||
|
return "volume";
|
||||||
|
case "series":
|
||||||
|
case "seriesname":
|
||||||
|
return "seriesName";
|
||||||
|
default:
|
||||||
|
// Fallback to createdAt for unknown fields
|
||||||
|
return "createdAt";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the sort field is text-based (title, author) where text match scoring is less relevant
|
||||||
|
*/
|
||||||
|
private boolean isTextBasedSort(String sortBy) {
|
||||||
|
return sortBy != null && (
|
||||||
|
sortBy.equalsIgnoreCase("title") ||
|
||||||
|
sortBy.equalsIgnoreCase("author") ||
|
||||||
|
sortBy.equalsIgnoreCase("authorname") ||
|
||||||
|
sortBy.equalsIgnoreCase("series") ||
|
||||||
|
sortBy.equalsIgnoreCase("seriesname")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author indexing methods
|
||||||
|
|
||||||
|
public void indexAuthor(Author author) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> document = createAuthorDocument(author);
|
||||||
|
typesenseClient.collections(AUTHORS_COLLECTION).documents().create(document);
|
||||||
|
logger.debug("Indexed author: {}", author.getName());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to index author: " + author.getName(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateAuthor(Author author) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> document = createAuthorDocument(author);
|
||||||
|
typesenseClient.collections(AUTHORS_COLLECTION).documents(author.getId().toString()).update(document);
|
||||||
|
logger.debug("Updated author index: {}", author.getName());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to update author index: " + author.getName(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteAuthor(String authorId) {
|
||||||
|
try {
|
||||||
|
typesenseClient.collections(AUTHORS_COLLECTION).documents(authorId).delete();
|
||||||
|
logger.debug("Deleted author from index: {}", authorId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to delete author from index: " + authorId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void bulkIndexAuthors(List<Author> authors) {
|
||||||
|
if (authors.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Index authors one by one for now (can optimize later)
|
||||||
|
for (Author author : authors) {
|
||||||
|
indexAuthor(author);
|
||||||
|
}
|
||||||
|
logger.info("Bulk indexed {} authors", authors.size());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to bulk index authors", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reindexAllAuthors(List<Author> authors) {
|
||||||
|
try {
|
||||||
|
// Force recreate collection with proper schema
|
||||||
|
recreateAuthorsCollection();
|
||||||
|
|
||||||
|
// Bulk index all authors
|
||||||
|
bulkIndexAuthors(authors);
|
||||||
|
|
||||||
|
logger.info("Reindexed all {} authors", authors.size());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to reindex all authors", e);
|
||||||
|
throw new RuntimeException("Failed to reindex all authors", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> createAuthorDocument(Author author) {
|
||||||
|
Map<String, Object> document = new HashMap<>();
|
||||||
|
document.put("id", author.getId().toString());
|
||||||
|
document.put("name", author.getName());
|
||||||
|
document.put("notes", author.getNotes() != null ? author.getNotes() : "");
|
||||||
|
document.put("authorRating", author.getAuthorRating());
|
||||||
|
|
||||||
|
// Safely handle potentially lazy-loaded stories collection
|
||||||
|
int storyCount = 0;
|
||||||
|
Double averageStoryRating = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (author.getStories() != null && !author.getStories().isEmpty()) {
|
||||||
|
storyCount = author.getStories().size();
|
||||||
|
|
||||||
|
// Calculate average story rating
|
||||||
|
double avgRating = author.getStories().stream()
|
||||||
|
.filter(story -> story.getRating() != null)
|
||||||
|
.mapToInt(story -> story.getRating())
|
||||||
|
.average()
|
||||||
|
.orElse(0.0);
|
||||||
|
averageStoryRating = avgRating > 0 ? avgRating : null;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// If stories can't be loaded (lazy loading issue), set defaults
|
||||||
|
logger.debug("Could not load stories for author {}, using defaults: {}", author.getName(), e.getMessage());
|
||||||
|
storyCount = 0;
|
||||||
|
averageStoryRating = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.put("storyCount", storyCount);
|
||||||
|
document.put("averageStoryRating", averageStoryRating);
|
||||||
|
|
||||||
|
// Safely handle potentially lazy-loaded URLs collection
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
if (author.getUrls() != null) {
|
||||||
|
urls = author.getUrls();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.debug("Could not load URLs for author {}, using empty list: {}", author.getName(), e.getMessage());
|
||||||
|
}
|
||||||
|
document.put("urls", urls);
|
||||||
|
document.put("avatarImagePath", author.getAvatarImagePath());
|
||||||
|
document.put("createdAt", author.getCreatedAt() != null ?
|
||||||
|
author.getCreatedAt().toEpochSecond(java.time.ZoneOffset.UTC) : 0);
|
||||||
|
document.put("updatedAt", author.getUpdatedAt() != null ?
|
||||||
|
author.getUpdatedAt().toEpochSecond(java.time.ZoneOffset.UTC) : 0);
|
||||||
|
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SearchResultDto<AuthorSearchDto> searchAuthors(String query, int page, int perPage, String sortBy, String sortOrder) {
|
||||||
|
try {
|
||||||
|
logger.info("AUTHORS SEARCH DEBUG: Searching collection '{}' with query='{}', sortBy='{}', sortOrder='{}'",
|
||||||
|
AUTHORS_COLLECTION, query, sortBy, sortOrder);
|
||||||
|
SearchParameters searchParameters = new SearchParameters()
|
||||||
|
.q(query != null && !query.trim().isEmpty() ? query : "*")
|
||||||
|
.queryBy("name,notes")
|
||||||
|
.page(page + 1) // Typesense pages are 1-indexed
|
||||||
|
.perPage(perPage);
|
||||||
|
|
||||||
|
// Add sorting if specified, with fallback if sorting fails
|
||||||
|
if (sortBy != null && !sortBy.trim().isEmpty()) {
|
||||||
|
String sortDirection = "desc".equalsIgnoreCase(sortOrder) ? "desc" : "asc";
|
||||||
|
String sortField = mapAuthorSortField(sortBy);
|
||||||
|
String sortString = sortField + ":" + sortDirection;
|
||||||
|
logger.info("AUTHORS SEARCH DEBUG: Original sortBy='{}', mapped to='{}', full sort string='{}'",
|
||||||
|
sortBy, sortField, sortString);
|
||||||
|
searchParameters.sortBy(sortString);
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchResult searchResult;
|
||||||
|
try {
|
||||||
|
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
|
||||||
|
.documents()
|
||||||
|
.search(searchParameters);
|
||||||
|
} catch (Exception sortException) {
|
||||||
|
// If sorting fails (likely due to schema issues), retry without sorting
|
||||||
|
logger.error("SORTING ERROR DEBUG: Full exception details", sortException);
|
||||||
|
logger.warn("Sorting failed for authors search, retrying without sort: " + sortException.getMessage());
|
||||||
|
|
||||||
|
// Try to get collection info for debugging
|
||||||
|
try {
|
||||||
|
CollectionResponse collection = typesenseClient.collections(AUTHORS_COLLECTION).retrieve();
|
||||||
|
logger.error("COLLECTION DEBUG: Collection '{}' exists with {} documents and {} fields",
|
||||||
|
collection.getName(), collection.getNumDocuments(), collection.getFields().size());
|
||||||
|
logger.error("COLLECTION DEBUG: Fields: {}", collection.getFields());
|
||||||
|
} catch (Exception debugException) {
|
||||||
|
logger.error("COLLECTION DEBUG: Failed to retrieve collection info", debugException);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchParameters = new SearchParameters()
|
||||||
|
.q(query != null && !query.trim().isEmpty() ? query : "*")
|
||||||
|
.queryBy("name,notes")
|
||||||
|
.page(page + 1)
|
||||||
|
.perPage(perPage);
|
||||||
|
|
||||||
|
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
|
||||||
|
.documents()
|
||||||
|
.search(searchParameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertAuthorSearchResult(searchResult, query, page, perPage);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to search authors with query: " + query, e);
|
||||||
|
SearchResultDto<AuthorSearchDto> emptyResult = new SearchResultDto<>();
|
||||||
|
emptyResult.setQuery(query);
|
||||||
|
emptyResult.setPage(page);
|
||||||
|
emptyResult.setPerPage(perPage);
|
||||||
|
emptyResult.setTotalHits(0);
|
||||||
|
emptyResult.setSearchTimeMs(0);
|
||||||
|
emptyResult.setResults(new ArrayList<>());
|
||||||
|
return emptyResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mapAuthorSortField(String sortBy) {
|
||||||
|
switch (sortBy.toLowerCase()) {
|
||||||
|
case "name":
|
||||||
|
return "name";
|
||||||
|
case "rating":
|
||||||
|
case "authorrating":
|
||||||
|
return "authorRating";
|
||||||
|
case "stories":
|
||||||
|
case "storycount":
|
||||||
|
return "storyCount";
|
||||||
|
case "avgrating":
|
||||||
|
case "averagestoryrating":
|
||||||
|
return "averageStoryRating";
|
||||||
|
case "created":
|
||||||
|
case "createdat":
|
||||||
|
return "createdAt";
|
||||||
|
default:
|
||||||
|
return "name";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SearchResultDto<AuthorSearchDto> convertAuthorSearchResult(SearchResult searchResult, String query, int page, int perPage) {
|
||||||
|
SearchResultDto<AuthorSearchDto> result = new SearchResultDto<>();
|
||||||
|
result.setQuery(query);
|
||||||
|
result.setPage(page);
|
||||||
|
result.setPerPage(perPage);
|
||||||
|
result.setTotalHits(searchResult.getFound().intValue());
|
||||||
|
result.setSearchTimeMs(searchResult.getSearchTimeMs().intValue());
|
||||||
|
|
||||||
|
List<AuthorSearchDto> authors = searchResult.getHits().stream()
|
||||||
|
.map(this::convertAuthorHit)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
result.setResults(authors);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthorSearchDto convertAuthorHit(SearchResultHit hit) {
|
||||||
|
Map<String, Object> doc = hit.getDocument();
|
||||||
|
|
||||||
|
AuthorSearchDto dto = new AuthorSearchDto();
|
||||||
|
dto.setId(UUID.fromString((String) doc.get("id")));
|
||||||
|
dto.setName((String) doc.get("name"));
|
||||||
|
dto.setNotes((String) doc.get("notes"));
|
||||||
|
|
||||||
|
// Handle numeric fields safely
|
||||||
|
Object authorRating = doc.get("authorRating");
|
||||||
|
if (authorRating instanceof Number) {
|
||||||
|
dto.setAuthorRating(((Number) authorRating).intValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
Object storyCount = doc.get("storyCount");
|
||||||
|
if (storyCount instanceof Number) {
|
||||||
|
dto.setStoryCount(((Number) storyCount).intValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
Object avgRating = doc.get("averageStoryRating");
|
||||||
|
if (avgRating instanceof Number) {
|
||||||
|
dto.setAverageStoryRating(((Number) avgRating).doubleValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.setAvatarImagePath((String) doc.get("avatarImagePath"));
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> urls = (List<String>) doc.get("urls");
|
||||||
|
dto.setUrls(urls);
|
||||||
|
|
||||||
|
// Convert timestamps back to LocalDateTime
|
||||||
|
Object createdAt = doc.get("createdAt");
|
||||||
|
if (createdAt instanceof Number) {
|
||||||
|
dto.setCreatedAt(java.time.LocalDateTime.ofEpochSecond(
|
||||||
|
((Number) createdAt).longValue(), 0, java.time.ZoneOffset.UTC));
|
||||||
|
}
|
||||||
|
|
||||||
|
Object updatedAt = doc.get("updatedAt");
|
||||||
|
if (updatedAt instanceof Number) {
|
||||||
|
dto.setUpdatedAt(java.time.LocalDateTime.ofEpochSecond(
|
||||||
|
((Number) updatedAt).longValue(), 0, java.time.ZoneOffset.UTC));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set search score
|
||||||
|
Long textMatch = hit.getTextMatch();
|
||||||
|
dto.setSearchScore(textMatch != null ? textMatch : 0L);
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current schema information for the authors collection
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getAuthorsCollectionSchema() {
|
||||||
|
try {
|
||||||
|
CollectionResponse collection = typesenseClient.collections(AUTHORS_COLLECTION).retrieve();
|
||||||
|
return Map.of(
|
||||||
|
"name", collection.getName(),
|
||||||
|
"num_documents", collection.getNumDocuments(),
|
||||||
|
"fields", collection.getFields(),
|
||||||
|
"created_at", collection.getCreatedAt()
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to retrieve authors collection schema", e);
|
||||||
|
return Map.of("error", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current schema information for the stories collection
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getStoriesCollectionSchema() {
|
||||||
|
try {
|
||||||
|
CollectionResponse collection = typesenseClient.collections(STORIES_COLLECTION).retrieve();
|
||||||
|
return Map.of(
|
||||||
|
"name", collection.getName(),
|
||||||
|
"num_documents", collection.getNumDocuments(),
|
||||||
|
"fields", collection.getFields(),
|
||||||
|
"created_at", collection.getCreatedAt()
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to retrieve stories collection schema", e);
|
||||||
|
return Map.of("error", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,8 @@ storycove:
|
|||||||
api-key: ${TYPESENSE_API_KEY:xyz}
|
api-key: ${TYPESENSE_API_KEY:xyz}
|
||||||
host: ${TYPESENSE_HOST:localhost}
|
host: ${TYPESENSE_HOST:localhost}
|
||||||
port: ${TYPESENSE_PORT:8108}
|
port: ${TYPESENSE_PORT:8108}
|
||||||
|
enabled: ${TYPESENSE_ENABLED:true}
|
||||||
|
reindex-interval: ${TYPESENSE_REINDEX_INTERVAL:3600000} # 1 hour in milliseconds
|
||||||
images:
|
images:
|
||||||
storage-path: ${IMAGE_STORAGE_PATH:/app/images}
|
storage-path: ${IMAGE_STORAGE_PATH:/app/images}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export default function AddStoryPage() {
|
|||||||
contentHtml: formData.contentHtml,
|
contentHtml: formData.contentHtml,
|
||||||
sourceUrl: formData.sourceUrl || undefined,
|
sourceUrl: formData.sourceUrl || undefined,
|
||||||
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
||||||
|
seriesName: formData.seriesName || undefined,
|
||||||
authorName: formData.authorName || undefined,
|
authorName: formData.authorName || undefined,
|
||||||
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
|
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -122,6 +122,29 @@ export default function AuthorDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Average Story Rating */}
|
||||||
|
{author.averageStoryRating && (
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<div className="flex">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<span
|
||||||
|
key={star}
|
||||||
|
className={`text-sm ${
|
||||||
|
star <= Math.round(author.averageStoryRating || 0)
|
||||||
|
? 'text-blue-400'
|
||||||
|
: 'text-gray-300 dark:text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs theme-text ml-1">
|
||||||
|
Avg Story Rating: {author.averageStoryRating.toFixed(1)}/5
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { authorApi, getImageUrl } from '../../lib/api';
|
|||||||
import { Author } from '../../types/api';
|
import { Author } from '../../types/api';
|
||||||
import AppLayout from '../../components/layout/AppLayout';
|
import AppLayout from '../../components/layout/AppLayout';
|
||||||
import { Input } from '../../components/ui/Input';
|
import { Input } from '../../components/ui/Input';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
||||||
|
|
||||||
export default function AuthorsPage() {
|
export default function AuthorsPage() {
|
||||||
@@ -14,41 +15,75 @@ export default function AuthorsPage() {
|
|||||||
const [filteredAuthors, setFilteredAuthors] = useState<Author[]>([]);
|
const [filteredAuthors, setFilteredAuthors] = useState<Author[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
|
const [sortBy, setSortBy] = useState('name');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||||
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
|
const [totalHits, setTotalHits] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const ITEMS_PER_PAGE = 50; // Safe limit under Typesense's 250 limit
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadAuthors = async () => {
|
const loadAuthors = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const authorsResult = await authorApi.getAuthors({ page: 0, size: 1000 }); // Get all authors
|
const searchResults = await authorApi.searchAuthorsTypesense({
|
||||||
setAuthors(authorsResult.content || []);
|
q: searchQuery || '*',
|
||||||
setFilteredAuthors(authorsResult.content || []);
|
page: currentPage,
|
||||||
|
size: ITEMS_PER_PAGE,
|
||||||
|
sortBy: sortBy,
|
||||||
|
sortOrder: sortOrder
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentPage === 0) {
|
||||||
|
// First page - replace all results
|
||||||
|
setAuthors(searchResults.results || []);
|
||||||
|
setFilteredAuthors(searchResults.results || []);
|
||||||
|
} else {
|
||||||
|
// Subsequent pages - append results
|
||||||
|
setAuthors(prev => [...prev, ...(searchResults.results || [])]);
|
||||||
|
setFilteredAuthors(prev => [...prev, ...(searchResults.results || [])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTotalHits(searchResults.totalHits);
|
||||||
|
setHasMore(searchResults.results.length === ITEMS_PER_PAGE && (currentPage + 1) * ITEMS_PER_PAGE < searchResults.totalHits);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load authors:', error);
|
console.error('Failed to load authors:', error);
|
||||||
|
// Fallback to regular API if Typesense fails (only for first page)
|
||||||
|
if (currentPage === 0) {
|
||||||
|
try {
|
||||||
|
const authorsResult = await authorApi.getAuthors({ page: 0, size: ITEMS_PER_PAGE });
|
||||||
|
setAuthors(authorsResult.content || []);
|
||||||
|
setFilteredAuthors(authorsResult.content || []);
|
||||||
|
setTotalHits(authorsResult.totalElements || 0);
|
||||||
|
setHasMore(authorsResult.content.length === ITEMS_PER_PAGE);
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('Fallback also failed:', fallbackError);
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadAuthors();
|
loadAuthors();
|
||||||
}, []);
|
}, [searchQuery, sortBy, sortOrder, currentPage]);
|
||||||
|
|
||||||
|
// Reset pagination when search or sort changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Array.isArray(authors)) {
|
if (currentPage !== 0) {
|
||||||
setFilteredAuthors([]);
|
setCurrentPage(0);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [searchQuery, sortBy, sortOrder]);
|
||||||
|
|
||||||
if (searchQuery) {
|
const loadMore = () => {
|
||||||
const query = searchQuery.toLowerCase();
|
if (hasMore && !loading) {
|
||||||
const filtered = authors.filter(author =>
|
setCurrentPage(prev => prev + 1);
|
||||||
author.name.toLowerCase().includes(query) ||
|
|
||||||
(author.notes && author.notes.toLowerCase().includes(query))
|
|
||||||
);
|
|
||||||
setFilteredAuthors(filtered);
|
|
||||||
} else {
|
|
||||||
setFilteredAuthors(authors);
|
|
||||||
}
|
}
|
||||||
}, [searchQuery, authors]);
|
};
|
||||||
|
|
||||||
|
// Client-side filtering no longer needed since we use Typesense
|
||||||
|
|
||||||
// Note: We no longer have individual story ratings in the author list
|
// Note: We no longer have individual story ratings in the author list
|
||||||
// Average rating would need to be calculated on backend if needed
|
// Average rating would need to be calculated on backend if needed
|
||||||
@@ -71,14 +106,34 @@ export default function AuthorsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold theme-header">Authors</h1>
|
<h1 className="text-3xl font-bold theme-header">Authors</h1>
|
||||||
<p className="theme-text mt-1">
|
<p className="theme-text mt-1">
|
||||||
{filteredAuthors.length} {filteredAuthors.length === 1 ? 'author' : 'authors'}
|
{filteredAuthors.length} of {totalHits} {totalHits === 1 ? 'author' : 'authors'}
|
||||||
{searchQuery ? ` found` : ` in your library`}
|
{searchQuery ? ` found` : ` in your library`}
|
||||||
|
{hasMore && ` (showing first ${filteredAuthors.length})`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className="px-3 py-2"
|
||||||
|
>
|
||||||
|
⊞ Grid
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'list' ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className="px-3 py-2"
|
||||||
|
>
|
||||||
|
☰ List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search and Sort Controls */}
|
||||||
<div className="max-w-md">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="flex-1 max-w-md">
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search authors..."
|
placeholder="Search authors..."
|
||||||
@@ -87,7 +142,29 @@ export default function AuthorsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Authors Grid */}
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
className="px-3 py-2 border rounded-lg theme-card border-gray-300 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="name">Name</option>
|
||||||
|
<option value="rating">Author Rating</option>
|
||||||
|
<option value="storycount">Story Count</option>
|
||||||
|
<option value="avgrating">Avg Story Rating</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||||
|
className="px-3 py-2"
|
||||||
|
>
|
||||||
|
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Authors Display */}
|
||||||
{filteredAuthors.length === 0 ? (
|
{filteredAuthors.length === 0 ? (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
<div className="theme-text text-lg mb-4">
|
<div className="theme-text text-lg mb-4">
|
||||||
@@ -109,12 +186,43 @@ export default function AuthorsPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : viewMode === 'grid' ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{filteredAuthors.map((author) => {
|
{filteredAuthors.map((author) => (
|
||||||
|
<AuthorGridCard key={author.id} author={author} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredAuthors.map((author) => (
|
||||||
|
<AuthorListItem key={author.id} author={author} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Load More Button */}
|
||||||
|
{hasMore && (
|
||||||
|
<div className="flex justify-center pt-8">
|
||||||
|
<Button
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loading}
|
||||||
|
variant="ghost"
|
||||||
|
className="px-8 py-3"
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Loading...' : `Load More Authors (${totalHits - filteredAuthors.length} remaining)`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author Grid Card Component
|
||||||
|
function AuthorGridCard({ author }: { author: Author }) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={author.id}
|
|
||||||
href={`/authors/${author.id}`}
|
href={`/authors/${author.id}`}
|
||||||
className="theme-card theme-shadow rounded-lg p-6 hover:shadow-lg transition-shadow group"
|
className="theme-card theme-shadow rounded-lg p-6 hover:shadow-lg transition-shadow group"
|
||||||
>
|
>
|
||||||
@@ -165,6 +273,29 @@ export default function AuthorsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Average Story Rating */}
|
||||||
|
{author.averageStoryRating && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<span
|
||||||
|
key={star}
|
||||||
|
className={`text-xs ${
|
||||||
|
star <= Math.round(author.averageStoryRating || 0)
|
||||||
|
? 'text-blue-400'
|
||||||
|
: 'text-gray-300 dark:text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs theme-text">
|
||||||
|
({author.averageStoryRating.toFixed(1)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +309,7 @@ export default function AuthorsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{author.urls.length > 0 && (
|
{author.urls && author.urls.length > 0 && (
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="theme-text">Links:</span>
|
<span className="theme-text">Links:</span>
|
||||||
<span className="font-medium theme-header">
|
<span className="font-medium theme-header">
|
||||||
@@ -196,13 +327,105 @@ export default function AuthorsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
// Author List Item Component
|
||||||
|
function AuthorListItem({ author }: { author: Author }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/authors/${author.id}`}
|
||||||
|
className="theme-card theme-shadow rounded-lg p-4 hover:shadow-lg transition-shadow group flex items-center gap-4"
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="w-12 h-12 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0">
|
||||||
|
{author.avatarImagePath ? (
|
||||||
|
<Image
|
||||||
|
src={getImageUrl(author.avatarImagePath)}
|
||||||
|
alt={author.name}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-xl theme-text">
|
||||||
|
👤
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
|
||||||
|
{/* Author Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold theme-header group-hover:theme-accent transition-colors truncate">
|
||||||
|
{author.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mt-1">
|
||||||
|
{/* Ratings */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{author.authorRating && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<span
|
||||||
|
key={star}
|
||||||
|
className={`text-sm ${
|
||||||
|
star <= (author.authorRating || 0)
|
||||||
|
? 'text-yellow-400'
|
||||||
|
: 'text-gray-300 dark:text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm theme-text">
|
||||||
|
({author.authorRating})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{author.averageStoryRating && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<span
|
||||||
|
key={star}
|
||||||
|
className={`text-xs ${
|
||||||
|
star <= Math.round(author.averageStoryRating || 0)
|
||||||
|
? 'text-blue-400'
|
||||||
|
: 'text-gray-300 dark:text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs theme-text">
|
||||||
|
Avg: {author.averageStoryRating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-4 text-sm theme-text">
|
||||||
|
<span>{author.storyCount || 0} stories</span>
|
||||||
|
{author.urls && author.urls.length > 0 && (
|
||||||
|
<span>{author.urls.length} links</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes Preview */}
|
||||||
|
{author.notes && (
|
||||||
|
<p className="text-sm theme-text mt-2 line-clamp-2">
|
||||||
|
{author.notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { storyApi, searchApi, tagApi } from '../../lib/api';
|
import { searchApi, tagApi } 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';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import AppLayout from '../../components/layout/AppLayout';
|
import AppLayout from '../../components/layout/AppLayout';
|
||||||
import { useTheme } from '../../lib/theme';
|
import { useTheme } from '../../lib/theme';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
|
import { storyApi, authorApi } from '../../lib/api';
|
||||||
|
|
||||||
type FontFamily = 'serif' | 'sans' | 'mono';
|
type FontFamily = 'serif' | 'sans' | 'mono';
|
||||||
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
|
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
|
||||||
@@ -27,6 +28,15 @@ export default function SettingsPage() {
|
|||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [typesenseStatus, setTypesenseStatus] = useState<{
|
||||||
|
stories: { loading: boolean; message: string; success?: boolean };
|
||||||
|
authors: { loading: boolean; message: string; success?: boolean };
|
||||||
|
}>({
|
||||||
|
stories: { loading: false, message: '' },
|
||||||
|
authors: { loading: false, message: '' }
|
||||||
|
});
|
||||||
|
const [authorsSchema, setAuthorsSchema] = useState<any>(null);
|
||||||
|
const [showSchema, setShowSchema] = useState(false);
|
||||||
|
|
||||||
// Load settings from localStorage on mount
|
// Load settings from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,6 +95,66 @@ export default function SettingsPage() {
|
|||||||
setSettings(prev => ({ ...prev, [key]: value }));
|
setSettings(prev => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTypesenseOperation = async (
|
||||||
|
type: 'stories' | 'authors',
|
||||||
|
operation: 'reindex' | 'recreate',
|
||||||
|
apiCall: () => Promise<{ success: boolean; message: string; count?: number; error?: string }>
|
||||||
|
) => {
|
||||||
|
setTypesenseStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
[type]: { loading: true, message: 'Processing...', success: undefined }
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiCall();
|
||||||
|
setTypesenseStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
[type]: {
|
||||||
|
loading: false,
|
||||||
|
message: result.success ? result.message : result.error || 'Operation failed',
|
||||||
|
success: result.success
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear message after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setTypesenseStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
[type]: { loading: false, message: '', success: undefined }
|
||||||
|
}));
|
||||||
|
}, 5000);
|
||||||
|
} catch (error) {
|
||||||
|
setTypesenseStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
[type]: {
|
||||||
|
loading: false,
|
||||||
|
message: 'Network error occurred',
|
||||||
|
success: false
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setTypesenseStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
[type]: { loading: false, message: '', success: undefined }
|
||||||
|
}));
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAuthorsSchema = async () => {
|
||||||
|
try {
|
||||||
|
const result = await authorApi.getTypesenseSchema();
|
||||||
|
if (result.success) {
|
||||||
|
setAuthorsSchema(result.schema);
|
||||||
|
} else {
|
||||||
|
setAuthorsSchema({ error: result.error });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setAuthorsSchema({ error: 'Failed to fetch schema' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div className="max-w-2xl mx-auto space-y-8">
|
<div className="max-w-2xl mx-auto space-y-8">
|
||||||
@@ -251,6 +321,119 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Typesense Search Management */}
|
||||||
|
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-xl font-semibold theme-header mb-4">Search Index Management</h2>
|
||||||
|
<p className="theme-text mb-6">
|
||||||
|
Manage the Typesense search indexes for stories and authors. Use these tools if search functionality isn't working properly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stories Section */}
|
||||||
|
<div className="border theme-border rounded-lg p-4">
|
||||||
|
<h3 className="text-lg font-semibold theme-header mb-3">Stories Index</h3>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 mb-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleTypesenseOperation('stories', 'reindex', storyApi.reindexTypesense)}
|
||||||
|
disabled={typesenseStatus.stories.loading}
|
||||||
|
loading={typesenseStatus.stories.loading}
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{typesenseStatus.stories.loading ? 'Reindexing...' : 'Reindex Stories'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleTypesenseOperation('stories', 'recreate', storyApi.recreateTypesenseCollection)}
|
||||||
|
disabled={typesenseStatus.stories.loading}
|
||||||
|
loading={typesenseStatus.stories.loading}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{typesenseStatus.stories.loading ? 'Recreating...' : 'Recreate Collection'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{typesenseStatus.stories.message && (
|
||||||
|
<div className={`text-sm p-2 rounded ${
|
||||||
|
typesenseStatus.stories.success
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||||
|
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||||
|
}`}>
|
||||||
|
{typesenseStatus.stories.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Authors Section */}
|
||||||
|
<div className="border theme-border rounded-lg p-4">
|
||||||
|
<h3 className="text-lg font-semibold theme-header mb-3">Authors Index</h3>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 mb-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleTypesenseOperation('authors', 'reindex', authorApi.reindexTypesense)}
|
||||||
|
disabled={typesenseStatus.authors.loading}
|
||||||
|
loading={typesenseStatus.authors.loading}
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{typesenseStatus.authors.loading ? 'Reindexing...' : 'Reindex Authors'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleTypesenseOperation('authors', 'recreate', authorApi.recreateTypesenseCollection)}
|
||||||
|
disabled={typesenseStatus.authors.loading}
|
||||||
|
loading={typesenseStatus.authors.loading}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{typesenseStatus.authors.loading ? 'Recreating...' : 'Recreate Collection'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{typesenseStatus.authors.message && (
|
||||||
|
<div className={`text-sm p-2 rounded ${
|
||||||
|
typesenseStatus.authors.success
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||||
|
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||||
|
}`}>
|
||||||
|
{typesenseStatus.authors.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Debug Schema Section */}
|
||||||
|
<div className="border-t theme-border pt-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Button
|
||||||
|
onClick={fetchAuthorsSchema}
|
||||||
|
variant="ghost"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Inspect Schema
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowSchema(!showSchema)}
|
||||||
|
variant="ghost"
|
||||||
|
className="text-xs"
|
||||||
|
disabled={!authorsSchema}
|
||||||
|
>
|
||||||
|
{showSchema ? 'Hide' : 'Show'} Schema
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSchema && authorsSchema && (
|
||||||
|
<div className="text-xs theme-text bg-gray-50 dark:bg-gray-800 p-3 rounded border overflow-auto max-h-48">
|
||||||
|
<pre>{JSON.stringify(authorsSchema, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm theme-text bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
||||||
|
<p className="font-medium mb-1">When to use these tools:</p>
|
||||||
|
<ul className="text-xs space-y-1 ml-4">
|
||||||
|
<li>• <strong>Reindex:</strong> Refresh search data while keeping the existing schema</li>
|
||||||
|
<li>• <strong>Recreate Collection:</strong> Delete and rebuild the entire search index (fixes schema issues)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ export default function EditStoryPage() {
|
|||||||
contentHtml: formData.contentHtml,
|
contentHtml: formData.contentHtml,
|
||||||
sourceUrl: formData.sourceUrl || undefined,
|
sourceUrl: formData.sourceUrl || undefined,
|
||||||
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
||||||
|
seriesName: formData.seriesName || undefined,
|
||||||
authorId: story.authorId, // Keep existing author ID
|
authorId: story.authorId, // Keep existing author ID
|
||||||
seriesId: story.seriesId, // Keep existing series ID for now
|
|
||||||
tagNames: formData.tags,
|
tagNames: formData.tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -298,11 +298,7 @@ export default function EditStoryPage() {
|
|||||||
onChange={handleInputChange('seriesName')}
|
onChange={handleInputChange('seriesName')}
|
||||||
placeholder="Enter series name if part of a series"
|
placeholder="Enter series name if part of a series"
|
||||||
error={errors.seriesName}
|
error={errors.seriesName}
|
||||||
disabled
|
|
||||||
/>
|
/>
|
||||||
<p className="text-sm theme-text mt-1">
|
|
||||||
Series changes not yet supported in edit mode
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export const storyApi = {
|
|||||||
authorId?: string;
|
authorId?: string;
|
||||||
authorName?: string;
|
authorName?: string;
|
||||||
seriesId?: string;
|
seriesId?: string;
|
||||||
|
seriesName?: string;
|
||||||
tagNames?: string[];
|
tagNames?: string[];
|
||||||
}): Promise<Story> => {
|
}): Promise<Story> => {
|
||||||
const response = await api.post('/stories', storyData);
|
const response = await api.post('/stories', storyData);
|
||||||
@@ -97,6 +98,7 @@ export const storyApi = {
|
|||||||
volume?: number;
|
volume?: number;
|
||||||
authorId?: string;
|
authorId?: string;
|
||||||
seriesId?: string;
|
seriesId?: string;
|
||||||
|
seriesName?: string;
|
||||||
tagNames?: string[];
|
tagNames?: string[];
|
||||||
}): Promise<Story> => {
|
}): Promise<Story> => {
|
||||||
const response = await api.put(`/stories/${id}`, storyData);
|
const response = await api.put(`/stories/${id}`, storyData);
|
||||||
@@ -133,6 +135,16 @@ export const storyApi = {
|
|||||||
const response = await api.delete(`/stories/${storyId}/tags/${tagId}`);
|
const response = await api.delete(`/stories/${storyId}/tags/${tagId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reindexTypesense: async (): Promise<{ success: boolean; message: string; count?: number; error?: string }> => {
|
||||||
|
const response = await api.post('/stories/reindex-typesense');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
recreateTypesenseCollection: async (): Promise<{ success: boolean; message: string; count?: number; error?: string }> => {
|
||||||
|
const response = await api.post('/stories/recreate-typesense-collection');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Author endpoints
|
// Author endpoints
|
||||||
@@ -171,6 +183,39 @@ export const authorApi = {
|
|||||||
removeAvatar: async (id: string): Promise<void> => {
|
removeAvatar: async (id: string): Promise<void> => {
|
||||||
await api.delete(`/authors/${id}/avatar`);
|
await api.delete(`/authors/${id}/avatar`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
searchAuthorsTypesense: async (params?: {
|
||||||
|
q?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: string;
|
||||||
|
}): Promise<{
|
||||||
|
results: Author[];
|
||||||
|
totalHits: number;
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
query: string;
|
||||||
|
searchTimeMs: number;
|
||||||
|
}> => {
|
||||||
|
const response = await api.get('/authors/search-typesense', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
reindexTypesense: async (): Promise<{ success: boolean; message: string; count?: number; error?: string }> => {
|
||||||
|
const response = await api.post('/authors/reindex-typesense');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
recreateTypesenseCollection: async (): Promise<{ success: boolean; message: string; count?: number; error?: string }> => {
|
||||||
|
const response = await api.post('/authors/recreate-typesense-collection');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTypesenseSchema: async (): Promise<{ success: boolean; schema?: any; error?: string }> => {
|
||||||
|
const response = await api.get('/authors/typesense-schema');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tag endpoints
|
// Tag endpoints
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface Author {
|
|||||||
name: string;
|
name: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
authorRating?: number;
|
authorRating?: number;
|
||||||
|
averageStoryRating?: number;
|
||||||
avatarImagePath?: string;
|
avatarImagePath?: string;
|
||||||
urls: string[];
|
urls: string[];
|
||||||
storyCount: number;
|
storyCount: number;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user