Advanced Filters - Build optimizations
This commit is contained in:
@@ -2,15 +2,15 @@ FROM openjdk:17-jdk-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pom.xml .
|
# Install Maven
|
||||||
COPY src ./src
|
RUN apt-get update && apt-get install -y maven && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y maven && \
|
# Copy source code
|
||||||
mvn clean package -DskipTests && \
|
COPY . .
|
||||||
apt-get remove -y maven && \
|
|
||||||
apt-get autoremove -y && \
|
# Build the application
|
||||||
rm -rf /var/lib/apt/lists/*
|
RUN mvn clean package -DskipTests
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
CMD ["java", "-jar", "target/storycove-backend-0.0.1-SNAPSHOT.jar"]
|
ENTRYPOINT ["java", "-jar", "target/storycove-backend-0.0.1-SNAPSHOT.jar"]
|
||||||
@@ -90,12 +90,33 @@ public class StoryController {
|
|||||||
public ResponseEntity<StorySummaryDto> getRandomStory(
|
public ResponseEntity<StorySummaryDto> getRandomStory(
|
||||||
@RequestParam(required = false) String searchQuery,
|
@RequestParam(required = false) String searchQuery,
|
||||||
@RequestParam(required = false) List<String> tags,
|
@RequestParam(required = false) List<String> tags,
|
||||||
@RequestParam(required = false) Long seed) {
|
@RequestParam(required = false) Long seed,
|
||||||
|
// Advanced filters
|
||||||
|
@RequestParam(required = false) Integer minWordCount,
|
||||||
|
@RequestParam(required = false) Integer maxWordCount,
|
||||||
|
@RequestParam(required = false) String createdAfter,
|
||||||
|
@RequestParam(required = false) String createdBefore,
|
||||||
|
@RequestParam(required = false) String lastReadAfter,
|
||||||
|
@RequestParam(required = false) String lastReadBefore,
|
||||||
|
@RequestParam(required = false) Integer minRating,
|
||||||
|
@RequestParam(required = false) Integer maxRating,
|
||||||
|
@RequestParam(required = false) Boolean unratedOnly,
|
||||||
|
@RequestParam(required = false) String readingStatus,
|
||||||
|
@RequestParam(required = false) Boolean hasReadingProgress,
|
||||||
|
@RequestParam(required = false) Boolean hasCoverImage,
|
||||||
|
@RequestParam(required = false) String sourceDomain,
|
||||||
|
@RequestParam(required = false) String seriesFilter,
|
||||||
|
@RequestParam(required = false) Integer minTagCount,
|
||||||
|
@RequestParam(required = false) Boolean popularOnly,
|
||||||
|
@RequestParam(required = false) Boolean hiddenGemsOnly) {
|
||||||
|
|
||||||
logger.info("Getting random story with filters - searchQuery: {}, tags: {}, seed: {}",
|
logger.info("Getting random story with filters - searchQuery: {}, tags: {}, seed: {}",
|
||||||
searchQuery, tags, seed);
|
searchQuery, tags, seed);
|
||||||
|
|
||||||
Optional<Story> randomStory = storyService.findRandomStory(searchQuery, tags, seed);
|
Optional<Story> randomStory = storyService.findRandomStory(searchQuery, tags, seed,
|
||||||
|
minWordCount, maxWordCount, createdAfter, createdBefore, lastReadAfter, lastReadBefore,
|
||||||
|
minRating, maxRating, unratedOnly, readingStatus, hasReadingProgress, hasCoverImage,
|
||||||
|
sourceDomain, seriesFilter, minTagCount, popularOnly, hiddenGemsOnly);
|
||||||
|
|
||||||
if (randomStory.isPresent()) {
|
if (randomStory.isPresent()) {
|
||||||
StorySummaryDto storyDto = convertToSummaryDto(randomStory.get());
|
StorySummaryDto storyDto = convertToSummaryDto(randomStory.get());
|
||||||
@@ -273,12 +294,31 @@ public class StoryController {
|
|||||||
@RequestParam(required = false) Integer maxRating,
|
@RequestParam(required = false) Integer maxRating,
|
||||||
@RequestParam(required = false) String sortBy,
|
@RequestParam(required = false) String sortBy,
|
||||||
@RequestParam(required = false) String sortDir,
|
@RequestParam(required = false) String sortDir,
|
||||||
@RequestParam(required = false) String facetBy) {
|
@RequestParam(required = false) String facetBy,
|
||||||
|
// Advanced filters
|
||||||
|
@RequestParam(required = false) Integer minWordCount,
|
||||||
|
@RequestParam(required = false) Integer maxWordCount,
|
||||||
|
@RequestParam(required = false) String createdAfter,
|
||||||
|
@RequestParam(required = false) String createdBefore,
|
||||||
|
@RequestParam(required = false) String lastReadAfter,
|
||||||
|
@RequestParam(required = false) String lastReadBefore,
|
||||||
|
@RequestParam(required = false) Boolean unratedOnly,
|
||||||
|
@RequestParam(required = false) String readingStatus,
|
||||||
|
@RequestParam(required = false) Boolean hasReadingProgress,
|
||||||
|
@RequestParam(required = false) Boolean hasCoverImage,
|
||||||
|
@RequestParam(required = false) String sourceDomain,
|
||||||
|
@RequestParam(required = false) String seriesFilter,
|
||||||
|
@RequestParam(required = false) Integer minTagCount,
|
||||||
|
@RequestParam(required = false) Boolean popularOnly,
|
||||||
|
@RequestParam(required = false) Boolean hiddenGemsOnly) {
|
||||||
|
|
||||||
|
|
||||||
if (typesenseService != null) {
|
if (typesenseService != null) {
|
||||||
SearchResultDto<StorySearchDto> results = typesenseService.searchStories(
|
SearchResultDto<StorySearchDto> results = typesenseService.searchStories(
|
||||||
query, page, size, authors, tags, minRating, maxRating, sortBy, sortDir, facetBy);
|
query, page, size, authors, tags, minRating, maxRating, sortBy, sortDir, facetBy,
|
||||||
|
minWordCount, maxWordCount, createdAfter, createdBefore, lastReadAfter, lastReadBefore,
|
||||||
|
unratedOnly, readingStatus, hasReadingProgress, hasCoverImage, sourceDomain, seriesFilter,
|
||||||
|
minTagCount, popularOnly, hiddenGemsOnly);
|
||||||
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
|
||||||
|
|||||||
@@ -682,7 +682,13 @@ public class StoryService {
|
|||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public Optional<Story> findRandomStory(String searchQuery, List<String> tags) {
|
public Optional<Story> findRandomStory(String searchQuery, List<String> tags) {
|
||||||
return findRandomStory(searchQuery, tags, null);
|
return findRandomStory(searchQuery, tags, null, null, null, null, null, null, null,
|
||||||
|
null, null, null, null, null, null, null, null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Story> findRandomStory(String searchQuery, List<String> tags, Long seed) {
|
||||||
|
return findRandomStory(searchQuery, tags, seed, null, null, null, null, null, null,
|
||||||
|
null, null, null, null, null, null, null, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -695,12 +701,23 @@ public class StoryService {
|
|||||||
* @return Optional containing the random story if found
|
* @return Optional containing the random story if found
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public Optional<Story> findRandomStory(String searchQuery, List<String> tags, Long seed) {
|
public Optional<Story> findRandomStory(String searchQuery, List<String> tags, Long seed,
|
||||||
|
Integer minWordCount, Integer maxWordCount,
|
||||||
|
String createdAfter, String createdBefore,
|
||||||
|
String lastReadAfter, String lastReadBefore,
|
||||||
|
Integer minRating, Integer maxRating, Boolean unratedOnly,
|
||||||
|
String readingStatus, Boolean hasReadingProgress,
|
||||||
|
Boolean hasCoverImage, String sourceDomain,
|
||||||
|
String seriesFilter, Integer minTagCount,
|
||||||
|
Boolean popularOnly, Boolean hiddenGemsOnly) {
|
||||||
|
|
||||||
// Use Typesense if available for consistency with Library search
|
// Use Typesense if available for consistency with Library search
|
||||||
if (typesenseService != null) {
|
if (typesenseService != null) {
|
||||||
try {
|
try {
|
||||||
Optional<UUID> randomStoryId = typesenseService.getRandomStoryId(searchQuery, tags, seed);
|
Optional<UUID> randomStoryId = typesenseService.getRandomStoryId(searchQuery, tags, seed,
|
||||||
|
minWordCount, maxWordCount, createdAfter, createdBefore, lastReadAfter, lastReadBefore,
|
||||||
|
minRating, maxRating, unratedOnly, readingStatus, hasReadingProgress, hasCoverImage,
|
||||||
|
sourceDomain, seriesFilter, minTagCount, popularOnly, hiddenGemsOnly);
|
||||||
if (randomStoryId.isPresent()) {
|
if (randomStoryId.isPresent()) {
|
||||||
return storyRepository.findById(randomStoryId.get());
|
return storyRepository.findById(randomStoryId.get());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,9 +124,16 @@ public class TypesenseService {
|
|||||||
new Field().name("wordCount").type("int32").facet(true).sort(true).optional(true),
|
new Field().name("wordCount").type("int32").facet(true).sort(true).optional(true),
|
||||||
new Field().name("volume").type("int32").facet(true).sort(true).optional(true),
|
new Field().name("volume").type("int32").facet(true).sort(true).optional(true),
|
||||||
new Field().name("createdAt").type("int64").facet(false).sort(true),
|
new Field().name("createdAt").type("int64").facet(false).sort(true),
|
||||||
new Field().name("lastReadAt").type("int64").facet(false).sort(true),
|
new Field().name("lastReadAt").type("int64").facet(false).sort(true).optional(true),
|
||||||
new Field().name("sourceUrl").type("string").facet(false).optional(true),
|
new Field().name("sourceUrl").type("string").facet(false).optional(true),
|
||||||
new Field().name("coverPath").type("string").facet(false).optional(true)
|
new Field().name("coverPath").type("string").facet(false).optional(true),
|
||||||
|
// New advanced filter fields
|
||||||
|
new Field().name("isRead").type("bool").facet(true).optional(true),
|
||||||
|
new Field().name("readingPosition").type("int32").facet(true).sort(true).optional(true),
|
||||||
|
new Field().name("hasCover").type("bool").facet(true).optional(true),
|
||||||
|
new Field().name("sourceDomain").type("string").facet(true).optional(true),
|
||||||
|
new Field().name("isPartOfSeries").type("bool").facet(true).optional(true),
|
||||||
|
new Field().name("tagCount").type("int32").facet(true).sort(true).optional(true)
|
||||||
);
|
);
|
||||||
|
|
||||||
CollectionSchema collectionSchema = new CollectionSchema()
|
CollectionSchema collectionSchema = new CollectionSchema()
|
||||||
@@ -249,7 +256,23 @@ public class TypesenseService {
|
|||||||
Integer maxRating,
|
Integer maxRating,
|
||||||
String sortBy,
|
String sortBy,
|
||||||
String sortDir,
|
String sortDir,
|
||||||
String facetBy) {
|
String facetBy,
|
||||||
|
// Advanced filters
|
||||||
|
Integer minWordCount,
|
||||||
|
Integer maxWordCount,
|
||||||
|
String createdAfter,
|
||||||
|
String createdBefore,
|
||||||
|
String lastReadAfter,
|
||||||
|
String lastReadBefore,
|
||||||
|
Boolean unratedOnly,
|
||||||
|
String readingStatus,
|
||||||
|
Boolean hasReadingProgress,
|
||||||
|
Boolean hasCoverImage,
|
||||||
|
String sourceDomain,
|
||||||
|
String seriesFilter,
|
||||||
|
Integer minTagCount,
|
||||||
|
Boolean popularOnly,
|
||||||
|
Boolean hiddenGemsOnly) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
long startTime = System.currentTimeMillis();
|
long startTime = System.currentTimeMillis();
|
||||||
@@ -309,6 +332,99 @@ public class TypesenseService {
|
|||||||
filterConditions.add("rating:<=" + maxRating);
|
filterConditions.add("rating:<=" + maxRating);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advanced filters
|
||||||
|
if (minWordCount != null) {
|
||||||
|
filterConditions.add("wordCount:>=" + minWordCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxWordCount != null) {
|
||||||
|
filterConditions.add("wordCount:<=" + maxWordCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdAfter != null) {
|
||||||
|
long timestamp = convertDateStringToTimestamp(createdAfter + "T00:00:00Z");
|
||||||
|
filterConditions.add("createdAt:>" + timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdBefore != null) {
|
||||||
|
long timestamp = convertDateStringToTimestamp(createdBefore + "T23:59:59Z");
|
||||||
|
filterConditions.add("createdAt:<" + timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastReadAfter != null) {
|
||||||
|
long timestamp = convertDateStringToTimestamp(lastReadAfter + "T00:00:00Z");
|
||||||
|
filterConditions.add("lastReadAt:>" + timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastReadBefore != null) {
|
||||||
|
long timestamp = convertDateStringToTimestamp(lastReadBefore + "T23:59:59Z");
|
||||||
|
filterConditions.add("lastReadAt:<" + timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unratedOnly != null && unratedOnly) {
|
||||||
|
filterConditions.add("rating:=0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readingStatus != null) {
|
||||||
|
switch (readingStatus.toLowerCase()) {
|
||||||
|
case "unread":
|
||||||
|
filterConditions.add("isRead:=false && readingPosition:=0");
|
||||||
|
break;
|
||||||
|
case "started":
|
||||||
|
filterConditions.add("isRead:=false && readingPosition:>0");
|
||||||
|
break;
|
||||||
|
case "completed":
|
||||||
|
filterConditions.add("isRead:=true");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasReadingProgress != null) {
|
||||||
|
if (hasReadingProgress) {
|
||||||
|
filterConditions.add("readingPosition:>0");
|
||||||
|
} else {
|
||||||
|
filterConditions.add("readingPosition:=0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasCoverImage != null) {
|
||||||
|
if (hasCoverImage) {
|
||||||
|
filterConditions.add("hasCover:=true");
|
||||||
|
} else {
|
||||||
|
filterConditions.add("hasCover:=false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceDomain != null && !sourceDomain.trim().isEmpty()) {
|
||||||
|
filterConditions.add("sourceDomain:=" + escapeTypesenseValue(sourceDomain.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesFilter != null) {
|
||||||
|
switch (seriesFilter.toLowerCase()) {
|
||||||
|
case "standalone":
|
||||||
|
filterConditions.add("isPartOfSeries:=false");
|
||||||
|
break;
|
||||||
|
case "series":
|
||||||
|
filterConditions.add("isPartOfSeries:=true");
|
||||||
|
break;
|
||||||
|
case "firstinseries":
|
||||||
|
filterConditions.add("isPartOfSeries:=true && volume:=1");
|
||||||
|
break;
|
||||||
|
case "lastinseries":
|
||||||
|
// This is complex to implement without knowing series lengths
|
||||||
|
// For now, we'll skip this specific filter
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minTagCount != null) {
|
||||||
|
filterConditions.add("tagCount:>=" + minTagCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: popularOnly and hiddenGemsOnly would require calculating average ratings
|
||||||
|
// which is complex for Typesense - these could be implemented as post-processing
|
||||||
|
// or by maintaining aggregate statistics in the index
|
||||||
|
|
||||||
if (!filterConditions.isEmpty()) {
|
if (!filterConditions.isEmpty()) {
|
||||||
String finalFilter = String.join(" && ", filterConditions);
|
String finalFilter = String.join(" && ", filterConditions);
|
||||||
searchParameters.filterBy(finalFilter);
|
searchParameters.filterBy(finalFilter);
|
||||||
@@ -399,12 +515,26 @@ public class TypesenseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backward compatibility overloaded methods
|
||||||
|
public Optional<UUID> getRandomStoryId(String searchQuery, List<String> tags, Long seed) {
|
||||||
|
return getRandomStoryId(searchQuery, tags, seed, null, null, null, null, null, null,
|
||||||
|
null, null, null, null, null, null, null, null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a random story using the same search logic as the Library view.
|
* Get a random story using the same search logic as the Library view.
|
||||||
* This ensures consistency between Library search results and Random Story functionality.
|
* This ensures consistency between Library search results and Random Story functionality.
|
||||||
* Uses Typesense's native _rand() function for efficient randomization with optional seed support.
|
* Uses Typesense's native _rand() function for efficient randomization with optional seed support.
|
||||||
*/
|
*/
|
||||||
public Optional<UUID> getRandomStoryId(String searchQuery, List<String> tags, Long seed) {
|
public Optional<UUID> getRandomStoryId(String searchQuery, List<String> tags, Long seed,
|
||||||
|
Integer minWordCount, Integer maxWordCount,
|
||||||
|
String createdAfter, String createdBefore,
|
||||||
|
String lastReadAfter, String lastReadBefore,
|
||||||
|
Integer minRating, Integer maxRating, Boolean unratedOnly,
|
||||||
|
String readingStatus, Boolean hasReadingProgress,
|
||||||
|
Boolean hasCoverImage, String sourceDomain,
|
||||||
|
String seriesFilter, Integer minTagCount,
|
||||||
|
Boolean popularOnly, Boolean hiddenGemsOnly) {
|
||||||
try {
|
try {
|
||||||
String normalizedQuery = (searchQuery == null || searchQuery.trim().isEmpty()) ? "*" : searchQuery.trim();
|
String normalizedQuery = (searchQuery == null || searchQuery.trim().isEmpty()) ? "*" : searchQuery.trim();
|
||||||
|
|
||||||
@@ -422,12 +552,111 @@ public class TypesenseService {
|
|||||||
.sortBy(sortBy)
|
.sortBy(sortBy)
|
||||||
.perPage(1); // Only need one random result
|
.perPage(1); // Only need one random result
|
||||||
|
|
||||||
// Add tag filters if provided
|
// Build all filter conditions (reuse logic from searchStories)
|
||||||
|
List<String> filterConditions = new ArrayList<>();
|
||||||
|
|
||||||
if (tags != null && !tags.isEmpty()) {
|
if (tags != null && !tags.isEmpty()) {
|
||||||
String tagFilter = tags.stream()
|
for (String tag : tags) {
|
||||||
.map(tag -> "tagNames:=" + escapeTypesenseValue(tag))
|
String escaped = escapeTypesenseValue(tag);
|
||||||
.collect(Collectors.joining(" && "));
|
filterConditions.add("tagNames:=" + escaped);
|
||||||
searchParameters.filterBy(tagFilter);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add advanced filters (same logic as searchStories method)
|
||||||
|
if (minWordCount != null) {
|
||||||
|
filterConditions.add("wordCount:>=" + minWordCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxWordCount != null) {
|
||||||
|
filterConditions.add("wordCount:<=" + maxWordCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdAfter != null) {
|
||||||
|
filterConditions.add("createdAt:>" + createdAfter + "T00:00:00Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdBefore != null) {
|
||||||
|
filterConditions.add("createdAt:<" + createdBefore + "T23:59:59Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastReadAfter != null) {
|
||||||
|
filterConditions.add("lastReadAt:>" + lastReadAfter + "T00:00:00Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastReadBefore != null) {
|
||||||
|
filterConditions.add("lastReadAt:<" + lastReadBefore + "T23:59:59Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minRating != null) {
|
||||||
|
filterConditions.add("rating:>=" + minRating);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxRating != null) {
|
||||||
|
filterConditions.add("rating:<=" + maxRating);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unratedOnly != null && unratedOnly) {
|
||||||
|
filterConditions.add("rating:=0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readingStatus != null) {
|
||||||
|
switch (readingStatus.toLowerCase()) {
|
||||||
|
case "unread":
|
||||||
|
filterConditions.add("isRead:=false && readingPosition:=0");
|
||||||
|
break;
|
||||||
|
case "started":
|
||||||
|
filterConditions.add("isRead:=false && readingPosition:>0");
|
||||||
|
break;
|
||||||
|
case "completed":
|
||||||
|
filterConditions.add("isRead:=true");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasReadingProgress != null) {
|
||||||
|
if (hasReadingProgress) {
|
||||||
|
filterConditions.add("readingPosition:>0");
|
||||||
|
} else {
|
||||||
|
filterConditions.add("readingPosition:=0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasCoverImage != null) {
|
||||||
|
if (hasCoverImage) {
|
||||||
|
filterConditions.add("hasCover:=true");
|
||||||
|
} else {
|
||||||
|
filterConditions.add("hasCover:=false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceDomain != null && !sourceDomain.trim().isEmpty()) {
|
||||||
|
filterConditions.add("sourceDomain:=" + escapeTypesenseValue(sourceDomain.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesFilter != null) {
|
||||||
|
switch (seriesFilter.toLowerCase()) {
|
||||||
|
case "standalone":
|
||||||
|
filterConditions.add("isPartOfSeries:=false");
|
||||||
|
break;
|
||||||
|
case "series":
|
||||||
|
filterConditions.add("isPartOfSeries:=true");
|
||||||
|
break;
|
||||||
|
case "firstinseries":
|
||||||
|
filterConditions.add("isPartOfSeries:=true && volume:=1");
|
||||||
|
break;
|
||||||
|
case "lastinseries":
|
||||||
|
// Skip for now - complex to implement
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minTagCount != null) {
|
||||||
|
filterConditions.add("tagCount:>=" + minTagCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filterConditions.isEmpty()) {
|
||||||
|
String finalFilter = String.join(" && ", filterConditions);
|
||||||
|
searchParameters.filterBy(finalFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
SearchResult searchResult = libraryService.getCurrentTypesenseClient().collections(getStoriesCollection())
|
SearchResult searchResult = libraryService.getCurrentTypesenseClient().collections(getStoriesCollection())
|
||||||
@@ -449,7 +678,10 @@ public class TypesenseService {
|
|||||||
logger.warn("Failed to use _rand() function, falling back to offset-based randomization: {}", randException.getMessage());
|
logger.warn("Failed to use _rand() function, falling back to offset-based randomization: {}", randException.getMessage());
|
||||||
|
|
||||||
// Fallback to offset-based randomization if _rand() is not supported
|
// Fallback to offset-based randomization if _rand() is not supported
|
||||||
return getRandomStoryIdFallback(normalizedQuery, tags, seed);
|
return getRandomStoryIdFallback(normalizedQuery, tags, seed, minWordCount, maxWordCount,
|
||||||
|
createdAfter, createdBefore, lastReadAfter, lastReadBefore, minRating, maxRating,
|
||||||
|
unratedOnly, readingStatus, hasReadingProgress, hasCoverImage, sourceDomain,
|
||||||
|
seriesFilter, minTagCount, popularOnly, hiddenGemsOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -462,7 +694,15 @@ public class TypesenseService {
|
|||||||
* Fallback method for random story selection using offset-based randomization with seed support.
|
* Fallback method for random story selection using offset-based randomization with seed support.
|
||||||
* Used when Typesense's _rand() function is not available or supported.
|
* Used when Typesense's _rand() function is not available or supported.
|
||||||
*/
|
*/
|
||||||
private Optional<UUID> getRandomStoryIdFallback(String normalizedQuery, List<String> tags, Long seed) {
|
private Optional<UUID> getRandomStoryIdFallback(String normalizedQuery, List<String> tags, Long seed,
|
||||||
|
Integer minWordCount, Integer maxWordCount,
|
||||||
|
String createdAfter, String createdBefore,
|
||||||
|
String lastReadAfter, String lastReadBefore,
|
||||||
|
Integer minRating, Integer maxRating, Boolean unratedOnly,
|
||||||
|
String readingStatus, Boolean hasReadingProgress,
|
||||||
|
Boolean hasCoverImage, String sourceDomain,
|
||||||
|
String seriesFilter, Integer minTagCount,
|
||||||
|
Boolean popularOnly, Boolean hiddenGemsOnly) {
|
||||||
try {
|
try {
|
||||||
// First, get the total count of matching stories
|
// First, get the total count of matching stories
|
||||||
SearchParameters countParameters = new SearchParameters()
|
SearchParameters countParameters = new SearchParameters()
|
||||||
@@ -470,12 +710,115 @@ public class TypesenseService {
|
|||||||
.queryBy("title,description,authorName,seriesName,tagNames")
|
.queryBy("title,description,authorName,seriesName,tagNames")
|
||||||
.perPage(0); // No results, just count
|
.perPage(0); // No results, just count
|
||||||
|
|
||||||
// Add tag filters if provided
|
// Build all filter conditions (same as main method)
|
||||||
|
List<String> filterConditions = new ArrayList<>();
|
||||||
|
|
||||||
if (tags != null && !tags.isEmpty()) {
|
if (tags != null && !tags.isEmpty()) {
|
||||||
String tagFilter = tags.stream()
|
for (String tag : tags) {
|
||||||
.map(tag -> "tagNames:=" + escapeTypesenseValue(tag))
|
String escaped = escapeTypesenseValue(tag);
|
||||||
.collect(Collectors.joining(" && "));
|
filterConditions.add("tagNames:=" + escaped);
|
||||||
countParameters.filterBy(tagFilter);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add advanced filters
|
||||||
|
if (minWordCount != null) {
|
||||||
|
filterConditions.add("wordCount:>=" + minWordCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxWordCount != null) {
|
||||||
|
filterConditions.add("wordCount:<=" + maxWordCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdAfter != null) {
|
||||||
|
long timestamp = convertDateStringToTimestamp(createdAfter + "T00:00:00Z");
|
||||||
|
filterConditions.add("createdAt:>" + timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdBefore != null) {
|
||||||
|
long timestamp = convertDateStringToTimestamp(createdBefore + "T23:59:59Z");
|
||||||
|
filterConditions.add("createdAt:<" + timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastReadAfter != null) {
|
||||||
|
long timestamp = convertDateStringToTimestamp(lastReadAfter + "T00:00:00Z");
|
||||||
|
filterConditions.add("lastReadAt:>" + timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastReadBefore != null) {
|
||||||
|
long timestamp = convertDateStringToTimestamp(lastReadBefore + "T23:59:59Z");
|
||||||
|
filterConditions.add("lastReadAt:<" + timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minRating != null) {
|
||||||
|
filterConditions.add("rating:>=" + minRating);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxRating != null) {
|
||||||
|
filterConditions.add("rating:<=" + maxRating);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unratedOnly != null && unratedOnly) {
|
||||||
|
filterConditions.add("rating:=0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readingStatus != null) {
|
||||||
|
switch (readingStatus.toLowerCase()) {
|
||||||
|
case "unread":
|
||||||
|
filterConditions.add("isRead:=false && readingPosition:=0");
|
||||||
|
break;
|
||||||
|
case "started":
|
||||||
|
filterConditions.add("isRead:=false && readingPosition:>0");
|
||||||
|
break;
|
||||||
|
case "completed":
|
||||||
|
filterConditions.add("isRead:=true");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasReadingProgress != null) {
|
||||||
|
if (hasReadingProgress) {
|
||||||
|
filterConditions.add("readingPosition:>0");
|
||||||
|
} else {
|
||||||
|
filterConditions.add("readingPosition:=0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasCoverImage != null) {
|
||||||
|
if (hasCoverImage) {
|
||||||
|
filterConditions.add("hasCover:=true");
|
||||||
|
} else {
|
||||||
|
filterConditions.add("hasCover:=false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceDomain != null && !sourceDomain.trim().isEmpty()) {
|
||||||
|
filterConditions.add("sourceDomain:=" + escapeTypesenseValue(sourceDomain.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesFilter != null) {
|
||||||
|
switch (seriesFilter.toLowerCase()) {
|
||||||
|
case "standalone":
|
||||||
|
filterConditions.add("isPartOfSeries:=false");
|
||||||
|
break;
|
||||||
|
case "series":
|
||||||
|
filterConditions.add("isPartOfSeries:=true");
|
||||||
|
break;
|
||||||
|
case "firstinseries":
|
||||||
|
filterConditions.add("isPartOfSeries:=true && volume:=1");
|
||||||
|
break;
|
||||||
|
case "lastinseries":
|
||||||
|
// Skip for now - complex to implement
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minTagCount != null) {
|
||||||
|
filterConditions.add("tagCount:>=" + minTagCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filterConditions.isEmpty()) {
|
||||||
|
String finalFilter = String.join(" && ", filterConditions);
|
||||||
|
countParameters.filterBy(finalFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
SearchResult countResult = libraryService.getCurrentTypesenseClient().collections(getStoriesCollection())
|
SearchResult countResult = libraryService.getCurrentTypesenseClient().collections(getStoriesCollection())
|
||||||
@@ -595,6 +938,27 @@ public class TypesenseService {
|
|||||||
document.put("coverPath", story.getCoverPath());
|
document.put("coverPath", story.getCoverPath());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New advanced filter fields
|
||||||
|
document.put("isRead", story.getIsRead() != null ? story.getIsRead() : false);
|
||||||
|
document.put("readingPosition", story.getReadingPosition() != null ? story.getReadingPosition() : 0);
|
||||||
|
document.put("hasCover", story.getCoverPath() != null && !story.getCoverPath().trim().isEmpty());
|
||||||
|
|
||||||
|
// Extract domain from source URL for filtering
|
||||||
|
if (story.getSourceUrl() != null && !story.getSourceUrl().trim().isEmpty()) {
|
||||||
|
try {
|
||||||
|
java.net.URI uri = java.net.URI.create(story.getSourceUrl());
|
||||||
|
String host = uri.getHost();
|
||||||
|
document.put("sourceDomain", host != null ? host.toLowerCase() : "");
|
||||||
|
} catch (Exception e) {
|
||||||
|
document.put("sourceDomain", "");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.put("sourceDomain", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.put("isPartOfSeries", story.getSeries() != null);
|
||||||
|
document.put("tagCount", story.getTags() != null ? story.getTags().size() : 0);
|
||||||
|
|
||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1226,6 +1590,19 @@ public class TypesenseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert date string in ISO format to Unix timestamp for Typesense filtering
|
||||||
|
*/
|
||||||
|
private long convertDateStringToTimestamp(String dateString) {
|
||||||
|
try {
|
||||||
|
java.time.ZonedDateTime zdt = java.time.ZonedDateTime.parse(dateString);
|
||||||
|
return zdt.toEpochSecond();
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to parse date string: {}", dateString, e);
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escape special characters in Typesense filter values.
|
* Escape special characters in Typesense filter values.
|
||||||
* Typesense requires certain characters to be escaped or quoted.
|
* Typesense requires certain characters to be escaped or quoted.
|
||||||
|
|||||||
@@ -1,40 +1,58 @@
|
|||||||
# Use node 18 alpine for smaller image size
|
# Multi-stage build for better layer caching and smaller final image
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dumb-init for proper signal handling
|
# Install dumb-init early
|
||||||
RUN apk add --no-cache dumb-init
|
RUN apk add --no-cache dumb-init
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files first to leverage Docker layer caching
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install all dependencies (including devDependencies needed for build)
|
# Install dependencies with optimized settings
|
||||||
# Set npm config for better CI performance
|
RUN npm ci --prefer-offline --no-audit --frozen-lockfile
|
||||||
RUN npm ci --prefer-offline --no-audit
|
|
||||||
|
|
||||||
# Copy source code
|
# Build stage
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependencies from deps stage
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Set Node.js memory limit for build (helpful in constrained environments)
|
# Set Node.js memory limit for build
|
||||||
ENV NODE_OPTIONS="--max-old-space-size=1024"
|
ENV NODE_OPTIONS="--max-old-space-size=1024"
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Remove devDependencies after build to reduce image size
|
# Production stage
|
||||||
RUN npm prune --omit=dev
|
FROM node:18-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# Install dumb-init for proper signal handling
|
||||||
|
RUN apk add --no-cache dumb-init
|
||||||
|
|
||||||
# Create non-root user for security
|
# Create non-root user for security
|
||||||
RUN addgroup -g 1001 -S nodejs
|
RUN addgroup -g 1001 -S nodejs
|
||||||
RUN adduser -S nextjs -u 1001
|
RUN adduser -S nextjs -u 1001
|
||||||
|
|
||||||
# Change ownership of the app directory
|
# Copy necessary files from builder stage
|
||||||
RUN chown -R nextjs:nodejs /app
|
COPY --from=builder /app/next.config.js* ./
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Use dumb-init to handle signals properly
|
# Use dumb-init to handle signals properly
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
CMD ["npm", "start"]
|
CMD ["node", "server.js"]
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
// Enable standalone output for optimized Docker builds
|
||||||
|
output: 'standalone',
|
||||||
// Removed Next.js rewrites since nginx handles all API routing
|
// Removed Next.js rewrites since nginx handles all API routing
|
||||||
webpack: (config, { isServer }) => {
|
webpack: (config, { isServer }) => {
|
||||||
// Exclude cheerio and its dependencies from client-side bundling
|
// Exclude cheerio and its dependencies from client-side bundling
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { searchApi, storyApi, tagApi } from '../../lib/api';
|
import { searchApi, storyApi, tagApi } from '../../lib/api';
|
||||||
import { Story, Tag, FacetCount } from '../../types/api';
|
import { Story, Tag, FacetCount, AdvancedFilters } from '../../types/api';
|
||||||
import AppLayout from '../../components/layout/AppLayout';
|
import AppLayout from '../../components/layout/AppLayout';
|
||||||
import { Input } from '../../components/ui/Input';
|
import { Input } from '../../components/ui/Input';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
@@ -37,6 +37,7 @@ export default function LibraryPage() {
|
|||||||
const [totalElements, setTotalElements] = useState(0);
|
const [totalElements, setTotalElements] = useState(0);
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
const [urlParamsProcessed, setUrlParamsProcessed] = useState(false);
|
const [urlParamsProcessed, setUrlParamsProcessed] = useState(false);
|
||||||
|
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>({});
|
||||||
|
|
||||||
// Initialize filters from URL parameters
|
// Initialize filters from URL parameters
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -145,6 +146,8 @@ export default function LibraryPage() {
|
|||||||
sortBy: sortOption,
|
sortBy: sortOption,
|
||||||
sortDir: sortDirection,
|
sortDir: sortDirection,
|
||||||
facetBy: ['tagNames'], // Request tag facets for the filter UI
|
facetBy: ['tagNames'], // Request tag facets for the filter UI
|
||||||
|
// Advanced filters
|
||||||
|
...advancedFilters
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Performing search with params:', apiParams);
|
console.log('Performing search with params:', apiParams);
|
||||||
@@ -173,7 +176,7 @@ export default function LibraryPage() {
|
|||||||
}, searchQuery ? 500 : 0); // Debounce search queries, but load immediately for filters/pagination
|
}, searchQuery ? 500 : 0); // Debounce search queries, but load immediately for filters/pagination
|
||||||
|
|
||||||
return () => clearTimeout(debounceTimer);
|
return () => clearTimeout(debounceTimer);
|
||||||
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger, urlParamsProcessed]);
|
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger, urlParamsProcessed, advancedFilters]);
|
||||||
|
|
||||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
@@ -191,7 +194,8 @@ export default function LibraryPage() {
|
|||||||
setRandomLoading(true);
|
setRandomLoading(true);
|
||||||
const randomStory = await storyApi.getRandomStory({
|
const randomStory = await storyApi.getRandomStory({
|
||||||
searchQuery: searchQuery || undefined,
|
searchQuery: searchQuery || undefined,
|
||||||
tags: selectedTags.length > 0 ? selectedTags : undefined
|
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||||
|
...advancedFilters
|
||||||
});
|
});
|
||||||
if (randomStory) {
|
if (randomStory) {
|
||||||
router.push(`/stories/${randomStory.id}`);
|
router.push(`/stories/${randomStory.id}`);
|
||||||
@@ -209,6 +213,7 @@ export default function LibraryPage() {
|
|||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSelectedTags([]);
|
setSelectedTags([]);
|
||||||
|
setAdvancedFilters({});
|
||||||
setPage(0);
|
setPage(0);
|
||||||
setRefreshTrigger(prev => prev + 1);
|
setRefreshTrigger(prev => prev + 1);
|
||||||
};
|
};
|
||||||
@@ -227,6 +232,12 @@ export default function LibraryPage() {
|
|||||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAdvancedFiltersChange = (filters: AdvancedFilters) => {
|
||||||
|
setAdvancedFilters(filters);
|
||||||
|
setPage(0);
|
||||||
|
setRefreshTrigger(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
@@ -250,11 +261,13 @@ export default function LibraryPage() {
|
|||||||
viewMode,
|
viewMode,
|
||||||
sortOption,
|
sortOption,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
|
advancedFilters,
|
||||||
onSearchChange: handleSearchChange,
|
onSearchChange: handleSearchChange,
|
||||||
onTagToggle: handleTagToggle,
|
onTagToggle: handleTagToggle,
|
||||||
onViewModeChange: setViewMode,
|
onViewModeChange: setViewMode,
|
||||||
onSortChange: handleSortChange,
|
onSortChange: handleSortChange,
|
||||||
onSortDirectionToggle: handleSortDirectionToggle,
|
onSortDirectionToggle: handleSortDirectionToggle,
|
||||||
|
onAdvancedFiltersChange: handleAdvancedFiltersChange,
|
||||||
onRandomStory: handleRandomStory,
|
onRandomStory: handleRandomStory,
|
||||||
onClearFilters: clearFilters,
|
onClearFilters: clearFilters,
|
||||||
};
|
};
|
||||||
@@ -264,12 +277,12 @@ export default function LibraryPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="text-center py-12 theme-card theme-shadow rounded-lg">
|
<div className="text-center py-12 theme-card theme-shadow rounded-lg">
|
||||||
<p className="theme-text text-lg mb-4">
|
<p className="theme-text text-lg mb-4">
|
||||||
{searchQuery || selectedTags.length > 0
|
{searchQuery || selectedTags.length > 0 || Object.values(advancedFilters).some(v => v !== undefined && v !== '' && v !== 'all' && v !== false)
|
||||||
? 'No stories match your search criteria.'
|
? 'No stories match your search criteria.'
|
||||||
: 'Your library is empty.'
|
: 'Your library is empty.'
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
{searchQuery || selectedTags.length > 0 ? (
|
{searchQuery || selectedTags.length > 0 || Object.values(advancedFilters).some(v => v !== undefined && v !== '' && v !== 'all' && v !== false) ? (
|
||||||
<Button variant="ghost" onClick={clearFilters}>
|
<Button variant="ghost" onClick={clearFilters}>
|
||||||
Clear Filters
|
Clear Filters
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
554
frontend/src/components/library/AdvancedFilters.tsx
Normal file
554
frontend/src/components/library/AdvancedFilters.tsx
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { AdvancedFilters, FilterPreset } from '../../types/api';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import { Input } from '../ui/Input';
|
||||||
|
|
||||||
|
interface AdvancedFiltersProps {
|
||||||
|
filters: AdvancedFilters;
|
||||||
|
onChange: (filters: AdvancedFilters) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predefined filter presets with both detailed controls and quick buttons
|
||||||
|
const FILTER_PRESETS: FilterPreset[] = [
|
||||||
|
// Length presets
|
||||||
|
{
|
||||||
|
id: 'short-stories',
|
||||||
|
label: '< 5k words',
|
||||||
|
description: 'Short stories under 5,000 words',
|
||||||
|
filters: { maxWordCount: 5000 },
|
||||||
|
category: 'length'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'medium-stories',
|
||||||
|
label: '5k - 20k',
|
||||||
|
description: 'Medium length stories (5k-20k words)',
|
||||||
|
filters: { minWordCount: 5000, maxWordCount: 20000 },
|
||||||
|
category: 'length'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'long-stories',
|
||||||
|
label: '> 20k words',
|
||||||
|
description: 'Long stories over 20,000 words',
|
||||||
|
filters: { minWordCount: 20000 },
|
||||||
|
category: 'length'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'very-long',
|
||||||
|
label: '> 50k words',
|
||||||
|
description: 'Very long stories over 50,000 words',
|
||||||
|
filters: { minWordCount: 50000 },
|
||||||
|
category: 'length'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Date presets
|
||||||
|
{
|
||||||
|
id: 'last-week',
|
||||||
|
label: 'Last 7 days',
|
||||||
|
description: 'Stories added in the last week',
|
||||||
|
filters: { createdAfter: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] },
|
||||||
|
category: 'date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'last-month',
|
||||||
|
label: 'Last 30 days',
|
||||||
|
description: 'Stories added in the last month',
|
||||||
|
filters: { createdAfter: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] },
|
||||||
|
category: 'date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'this-year',
|
||||||
|
label: 'This year',
|
||||||
|
description: 'Stories added this year',
|
||||||
|
filters: { createdAfter: `${new Date().getFullYear()}-01-01` },
|
||||||
|
category: 'date'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reading status presets
|
||||||
|
{
|
||||||
|
id: 'unread',
|
||||||
|
label: 'Unread',
|
||||||
|
description: 'Stories you haven\'t read yet',
|
||||||
|
filters: { readingStatus: 'unread' },
|
||||||
|
category: 'reading'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'in-progress',
|
||||||
|
label: 'Started',
|
||||||
|
description: 'Stories you\'ve started reading',
|
||||||
|
filters: { readingStatus: 'started' },
|
||||||
|
category: 'reading'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'completed',
|
||||||
|
label: 'Finished',
|
||||||
|
description: 'Stories you\'ve completed',
|
||||||
|
filters: { readingStatus: 'completed' },
|
||||||
|
category: 'reading'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rating presets
|
||||||
|
{
|
||||||
|
id: 'highly-rated',
|
||||||
|
label: '4+ stars',
|
||||||
|
description: 'Highly rated stories (4 stars or more)',
|
||||||
|
filters: { minRating: 4 },
|
||||||
|
category: 'rating'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'unrated',
|
||||||
|
label: 'Unrated',
|
||||||
|
description: 'Stories without ratings',
|
||||||
|
filters: { unratedOnly: true },
|
||||||
|
category: 'rating'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Content presets
|
||||||
|
{
|
||||||
|
id: 'with-covers',
|
||||||
|
label: 'Has Cover',
|
||||||
|
description: 'Stories with cover images',
|
||||||
|
filters: { hasCoverImage: true },
|
||||||
|
category: 'content'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'standalone',
|
||||||
|
label: 'Standalone',
|
||||||
|
description: 'Stories not part of a series',
|
||||||
|
filters: { seriesFilter: 'standalone' },
|
||||||
|
category: 'content'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'series-only',
|
||||||
|
label: 'Series',
|
||||||
|
description: 'Stories that are part of a series',
|
||||||
|
filters: { seriesFilter: 'series' },
|
||||||
|
category: 'content'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Organization presets
|
||||||
|
{
|
||||||
|
id: 'well-tagged',
|
||||||
|
label: '3+ tags',
|
||||||
|
description: 'Well-tagged stories with 3 or more tags',
|
||||||
|
filters: { minTagCount: 3 },
|
||||||
|
category: 'organization'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'popular',
|
||||||
|
label: 'Popular',
|
||||||
|
description: 'Stories with above-average ratings',
|
||||||
|
filters: { popularOnly: true },
|
||||||
|
category: 'organization'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hidden-gems',
|
||||||
|
label: 'Hidden Gems',
|
||||||
|
description: 'Underrated or unrated stories to discover',
|
||||||
|
filters: { hiddenGemsOnly: true },
|
||||||
|
category: 'organization'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdvancedFilters({
|
||||||
|
filters,
|
||||||
|
onChange,
|
||||||
|
onReset,
|
||||||
|
className = ''
|
||||||
|
}: AdvancedFiltersProps) {
|
||||||
|
|
||||||
|
// Prevent event bubbling when interacting with the component
|
||||||
|
const handleContainerClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
// Prevent escape key from bubbling up (let parent handle it)
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||||
|
length: false,
|
||||||
|
date: false,
|
||||||
|
rating: false,
|
||||||
|
reading: false,
|
||||||
|
content: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const updateFilter = <K extends keyof AdvancedFilters>(
|
||||||
|
key: K,
|
||||||
|
value: AdvancedFilters[K]
|
||||||
|
) => {
|
||||||
|
onChange({ ...filters, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPreset = (preset: FilterPreset) => {
|
||||||
|
onChange({ ...filters, ...preset.filters });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPresetActive = (preset: FilterPreset) => {
|
||||||
|
return Object.entries(preset.filters).every(([key, value]) =>
|
||||||
|
filters[key as keyof AdvancedFilters] === value
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSection = (section: string) => {
|
||||||
|
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = Object.values(filters).some(value =>
|
||||||
|
value !== undefined && value !== '' && value !== 'all'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group presets by category
|
||||||
|
const presetsByCategory = FILTER_PRESETS.reduce((acc, preset) => {
|
||||||
|
if (!acc[preset.category]) acc[preset.category] = [];
|
||||||
|
acc[preset.category].push(preset);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, FilterPreset[]>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`space-y-4 ${className}`}
|
||||||
|
onClick={handleContainerClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{/* Quick Filter Buttons */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-medium theme-header text-sm">Quick Filters</h4>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onReset}>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.entries(presetsByCategory).map(([category, presets]) => (
|
||||||
|
<div key={category} className="space-y-1">
|
||||||
|
<div className="text-xs font-medium theme-text opacity-75 uppercase tracking-wide">
|
||||||
|
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{presets.map(preset => (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
onClick={() => applyPreset(preset)}
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium transition-all hover:scale-105 ${
|
||||||
|
isPresetActive(preset)
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
|
||||||
|
}`}
|
||||||
|
title={preset.description}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t theme-border pt-4">
|
||||||
|
<h4 className="font-medium theme-header text-sm mb-3">Detailed Controls</h4>
|
||||||
|
|
||||||
|
{/* Word Count Section */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('length')}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`transform transition-transform ${expandedSections.length ? 'rotate-90' : ''}`}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
📏 Story Length
|
||||||
|
{(filters.minWordCount || filters.maxWordCount) && (
|
||||||
|
<span className="text-xs bg-blue-500 text-white px-1 rounded">●</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.length && (
|
||||||
|
<div className="pl-6 space-y-3 bg-gray-50 dark:bg-gray-800 p-3 rounded">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Min Words</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={filters.minWordCount || ''}
|
||||||
|
onChange={(e) => updateFilter('minWordCount', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
placeholder="0"
|
||||||
|
className="text-xs w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Max Words</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={filters.maxWordCount || ''}
|
||||||
|
onChange={(e) => updateFilter('maxWordCount', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
placeholder="∞"
|
||||||
|
className="text-xs w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Word count range display */}
|
||||||
|
{(filters.minWordCount || filters.maxWordCount) && (
|
||||||
|
<div className="text-xs theme-text bg-white dark:bg-gray-700 p-2 rounded">
|
||||||
|
Range: {filters.minWordCount || 0} - {filters.maxWordCount || '∞'} words
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Section */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('date')}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`transform transition-transform ${expandedSections.date ? 'rotate-90' : ''}`}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
📅 Date Added
|
||||||
|
{(filters.createdAfter || filters.createdBefore) && (
|
||||||
|
<span className="text-xs bg-blue-500 text-white px-1 rounded">●</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.date && (
|
||||||
|
<div className="pl-6 space-y-3 bg-gray-50 dark:bg-gray-800 p-3 rounded">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">After Date</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filters.createdAfter || ''}
|
||||||
|
onChange={(e) => updateFilter('createdAfter', e.target.value || undefined)}
|
||||||
|
className="text-xs w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Before Date</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filters.createdBefore || ''}
|
||||||
|
onChange={(e) => updateFilter('createdBefore', e.target.value || undefined)}
|
||||||
|
className="text-xs w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating Section */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('rating')}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`transform transition-transform ${expandedSections.rating ? 'rotate-90' : ''}`}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
⭐ Rating
|
||||||
|
{(filters.minRating || filters.maxRating || filters.unratedOnly) && (
|
||||||
|
<span className="text-xs bg-blue-500 text-white px-1 rounded">●</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.rating && (
|
||||||
|
<div className="pl-6 space-y-3 bg-gray-50 dark:bg-gray-800 p-3 rounded">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.unratedOnly || false}
|
||||||
|
onChange={(e) => updateFilter('unratedOnly', e.target.checked || undefined)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs theme-text">Unrated stories only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!filters.unratedOnly && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Min Rating</label>
|
||||||
|
<select
|
||||||
|
value={filters.minRating || ''}
|
||||||
|
onChange={(e) => updateFilter('minRating', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
className="w-full px-2 py-1 text-xs border rounded theme-card border-gray-300 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">No minimum</option>
|
||||||
|
<option value="1">1 star</option>
|
||||||
|
<option value="2">2 stars</option>
|
||||||
|
<option value="3">3 stars</option>
|
||||||
|
<option value="4">4 stars</option>
|
||||||
|
<option value="5">5 stars</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Max Rating</label>
|
||||||
|
<select
|
||||||
|
value={filters.maxRating || ''}
|
||||||
|
onChange={(e) => updateFilter('maxRating', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
className="w-full px-2 py-1 text-xs border rounded theme-card border-gray-300 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">No maximum</option>
|
||||||
|
<option value="1">1 star</option>
|
||||||
|
<option value="2">2 stars</option>
|
||||||
|
<option value="3">3 stars</option>
|
||||||
|
<option value="4">4 stars</option>
|
||||||
|
<option value="5">5 stars</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reading Status Section */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('reading')}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`transform transition-transform ${expandedSections.reading ? 'rotate-90' : ''}`}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
👁️ Reading Status
|
||||||
|
{(filters.readingStatus && filters.readingStatus !== 'all') && (
|
||||||
|
<span className="text-xs bg-blue-500 text-white px-1 rounded">●</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.reading && (
|
||||||
|
<div className="pl-6 space-y-2 bg-gray-50 dark:bg-gray-800 p-3 rounded">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[
|
||||||
|
{ value: 'all', label: 'All stories' },
|
||||||
|
{ value: 'unread', label: 'Unread' },
|
||||||
|
{ value: 'started', label: 'Started reading' },
|
||||||
|
{ value: 'completed', label: 'Completed' }
|
||||||
|
].map(option => (
|
||||||
|
<label key={option.value} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="readingStatus"
|
||||||
|
value={option.value}
|
||||||
|
checked={(filters.readingStatus || 'all') === option.value}
|
||||||
|
onChange={(e) => updateFilter('readingStatus', e.target.value as any)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs theme-text">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Section */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('content')}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`transform transition-transform ${expandedSections.content ? 'rotate-90' : ''}`}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
📚 Content
|
||||||
|
{(filters.hasCoverImage || filters.seriesFilter !== 'all' || filters.sourceDomain) && (
|
||||||
|
<span className="text-xs bg-blue-500 text-white px-1 rounded">●</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.content && (
|
||||||
|
<div className="pl-6 space-y-3 bg-gray-50 dark:bg-gray-800 p-3 rounded">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.hasCoverImage || false}
|
||||||
|
onChange={(e) => updateFilter('hasCoverImage', e.target.checked || undefined)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs theme-text">Has cover image</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Series Filter</label>
|
||||||
|
<select
|
||||||
|
value={filters.seriesFilter || 'all'}
|
||||||
|
onChange={(e) => updateFilter('seriesFilter', e.target.value as any)}
|
||||||
|
className="w-full px-2 py-1 text-xs border rounded theme-card border-gray-300 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="all">All stories</option>
|
||||||
|
<option value="standalone">Standalone only</option>
|
||||||
|
<option value="series">Series only</option>
|
||||||
|
<option value="firstInSeries">First in series</option>
|
||||||
|
<option value="lastInSeries">Last in series</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Source Domain</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={filters.sourceDomain || ''}
|
||||||
|
onChange={(e) => updateFilter('sourceDomain', e.target.value || undefined)}
|
||||||
|
placeholder="e.g., archiveofourown.org"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Options */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium theme-text opacity-75 uppercase tracking-wide">
|
||||||
|
Advanced
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 bg-gray-50 dark:bg-gray-800 p-3 rounded">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Minimum Tag Count</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={filters.minTagCount || ''}
|
||||||
|
onChange={(e) => updateFilter('minTagCount', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
placeholder="0"
|
||||||
|
className="text-xs"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.popularOnly || false}
|
||||||
|
onChange={(e) => updateFilter('popularOnly', e.target.checked || undefined)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs theme-text">Popular stories only (above average rating)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.hiddenGemsOnly || false}
|
||||||
|
onChange={(e) => updateFilter('hiddenGemsOnly', e.target.checked || undefined)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs theme-text">Hidden gems (underrated/unrated)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import { useState } from 'react';
|
|||||||
import { Input } from '../ui/Input';
|
import { Input } from '../ui/Input';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import TagDisplay from '../tags/TagDisplay';
|
import TagDisplay from '../tags/TagDisplay';
|
||||||
import { Story, Tag } from '../../types/api';
|
import AdvancedFilters from './AdvancedFilters';
|
||||||
|
import type { Story, Tag, AdvancedFilters as AdvancedFiltersType } from '../../types/api';
|
||||||
|
|
||||||
interface MinimalLayoutProps {
|
interface MinimalLayoutProps {
|
||||||
stories: Story[];
|
stories: Story[];
|
||||||
@@ -15,11 +16,13 @@ interface MinimalLayoutProps {
|
|||||||
viewMode: 'grid' | 'list';
|
viewMode: 'grid' | 'list';
|
||||||
sortOption: string;
|
sortOption: string;
|
||||||
sortDirection: 'asc' | 'desc';
|
sortDirection: 'asc' | 'desc';
|
||||||
|
advancedFilters?: AdvancedFiltersType;
|
||||||
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
onTagToggle: (tagName: string) => void;
|
onTagToggle: (tagName: string) => void;
|
||||||
onViewModeChange: (mode: 'grid' | 'list') => void;
|
onViewModeChange: (mode: 'grid' | 'list') => void;
|
||||||
onSortChange: (option: string) => void;
|
onSortChange: (option: string) => void;
|
||||||
onSortDirectionToggle: () => void;
|
onSortDirectionToggle: () => void;
|
||||||
|
onAdvancedFiltersChange?: (filters: AdvancedFiltersType) => void;
|
||||||
onRandomStory: () => void;
|
onRandomStory: () => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -34,16 +37,19 @@ export default function MinimalLayout({
|
|||||||
viewMode,
|
viewMode,
|
||||||
sortOption,
|
sortOption,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
|
advancedFilters = {},
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onTagToggle,
|
onTagToggle,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onSortDirectionToggle,
|
onSortDirectionToggle,
|
||||||
|
onAdvancedFiltersChange,
|
||||||
onRandomStory,
|
onRandomStory,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
children
|
children
|
||||||
}: MinimalLayoutProps) {
|
}: MinimalLayoutProps) {
|
||||||
const [tagBrowserOpen, setTagBrowserOpen] = useState(false);
|
const [tagBrowserOpen, setTagBrowserOpen] = useState(false);
|
||||||
|
const [advancedFiltersOpen, setAdvancedFiltersOpen] = useState(false);
|
||||||
const [tagSearch, setTagSearch] = useState('');
|
const [tagSearch, setTagSearch] = useState('');
|
||||||
|
|
||||||
const popularTags = tags.slice(0, 5);
|
const popularTags = tags.slice(0, 5);
|
||||||
@@ -53,6 +59,11 @@ export default function MinimalLayout({
|
|||||||
? tags.filter(tag => tag.name.toLowerCase().includes(tagSearch.toLowerCase()))
|
? tags.filter(tag => tag.name.toLowerCase().includes(tagSearch.toLowerCase()))
|
||||||
: tags;
|
: tags;
|
||||||
|
|
||||||
|
// Count active advanced filters
|
||||||
|
const activeAdvancedFiltersCount = Object.values(advancedFilters).filter(value =>
|
||||||
|
value !== undefined && value !== '' && value !== 'all' && value !== false
|
||||||
|
).length;
|
||||||
|
|
||||||
const getSortDisplayText = () => {
|
const getSortDisplayText = () => {
|
||||||
const sortLabels: Record<string, string> = {
|
const sortLabels: Record<string, string> = {
|
||||||
lastRead: 'Last Read',
|
lastRead: 'Last Read',
|
||||||
@@ -104,7 +115,28 @@ export default function MinimalLayout({
|
|||||||
{getSortDisplayText()}
|
{getSortDisplayText()}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
{(searchQuery || selectedTags.length > 0) && (
|
|
||||||
|
{/* Advanced Filters Button */}
|
||||||
|
{onAdvancedFiltersChange && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAdvancedFiltersOpen(true)}
|
||||||
|
className={activeAdvancedFiltersCount > 0 ? 'text-blue-600 dark:text-blue-400' : ''}
|
||||||
|
>
|
||||||
|
⚙️ Advanced
|
||||||
|
{activeAdvancedFiltersCount > 0 && (
|
||||||
|
<span className="ml-1 text-xs bg-blue-500 text-white px-1 rounded">
|
||||||
|
{activeAdvancedFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(searchQuery || selectedTags.length > 0 || activeAdvancedFiltersCount > 0) && (
|
||||||
<Button variant="ghost" size="sm" onClick={onClearFilters}>
|
<Button variant="ghost" size="sm" onClick={onClearFilters}>
|
||||||
Clear Filters
|
Clear Filters
|
||||||
</Button>
|
</Button>
|
||||||
@@ -247,6 +279,41 @@ export default function MinimalLayout({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Advanced Filters Modal */}
|
||||||
|
{advancedFiltersOpen && onAdvancedFiltersChange && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-center mb-5">
|
||||||
|
<h3 className="text-xl font-semibold theme-header">Advanced Filters</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setAdvancedFiltersOpen(false)}
|
||||||
|
className="text-2xl theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdvancedFilters
|
||||||
|
filters={advancedFilters}
|
||||||
|
onChange={onAdvancedFiltersChange}
|
||||||
|
onReset={() => onAdvancedFiltersChange({})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button variant="ghost" onClick={() => setAdvancedFiltersOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setAdvancedFiltersOpen(false)}
|
||||||
|
>
|
||||||
|
Apply Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,8 @@ import { useState } from 'react';
|
|||||||
import { Input } from '../ui/Input';
|
import { Input } from '../ui/Input';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import TagDisplay from '../tags/TagDisplay';
|
import TagDisplay from '../tags/TagDisplay';
|
||||||
import { Story, Tag } from '../../types/api';
|
import AdvancedFilters from './AdvancedFilters';
|
||||||
|
import type { Story, Tag, AdvancedFilters as AdvancedFiltersType } from '../../types/api';
|
||||||
|
|
||||||
interface SidebarLayoutProps {
|
interface SidebarLayoutProps {
|
||||||
stories: Story[];
|
stories: Story[];
|
||||||
@@ -15,11 +16,13 @@ interface SidebarLayoutProps {
|
|||||||
viewMode: 'grid' | 'list';
|
viewMode: 'grid' | 'list';
|
||||||
sortOption: string;
|
sortOption: string;
|
||||||
sortDirection: 'asc' | 'desc';
|
sortDirection: 'asc' | 'desc';
|
||||||
|
advancedFilters?: AdvancedFiltersType;
|
||||||
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
onTagToggle: (tagName: string) => void;
|
onTagToggle: (tagName: string) => void;
|
||||||
onViewModeChange: (mode: 'grid' | 'list') => void;
|
onViewModeChange: (mode: 'grid' | 'list') => void;
|
||||||
onSortChange: (option: string) => void;
|
onSortChange: (option: string) => void;
|
||||||
onSortDirectionToggle: () => void;
|
onSortDirectionToggle: () => void;
|
||||||
|
onAdvancedFiltersChange?: (filters: AdvancedFiltersType) => void;
|
||||||
onRandomStory: () => void;
|
onRandomStory: () => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -34,26 +37,34 @@ export default function SidebarLayout({
|
|||||||
viewMode,
|
viewMode,
|
||||||
sortOption,
|
sortOption,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
|
advancedFilters = {},
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onTagToggle,
|
onTagToggle,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onSortDirectionToggle,
|
onSortDirectionToggle,
|
||||||
|
onAdvancedFiltersChange,
|
||||||
onRandomStory,
|
onRandomStory,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
children
|
children
|
||||||
}: SidebarLayoutProps) {
|
}: SidebarLayoutProps) {
|
||||||
const [tagSearch, setTagSearch] = useState('');
|
const [tagSearch, setTagSearch] = useState('');
|
||||||
|
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||||
|
|
||||||
// Filter tags based on search query
|
// Filter tags based on search query
|
||||||
const filteredTags = tags.filter(tag =>
|
const filteredTags = tags.filter(tag =>
|
||||||
tag.name.toLowerCase().includes(tagSearch.toLowerCase())
|
tag.name.toLowerCase().includes(tagSearch.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Count active advanced filters
|
||||||
|
const activeAdvancedFiltersCount = Object.values(advancedFilters).filter(value =>
|
||||||
|
value !== undefined && value !== '' && value !== 'all' && value !== false
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* Left Sidebar */}
|
{/* Left Sidebar */}
|
||||||
<div className="w-80 bg-white dark:bg-gray-800 p-4 border-r theme-border sticky top-0 h-screen overflow-y-auto max-md:w-full max-md:h-auto max-md:static max-md:border-r-0 max-md:border-b max-md:max-h-96">
|
<div className="w-80 min-w-80 max-w-80 bg-white dark:bg-gray-800 p-4 border-r theme-border sticky top-0 h-screen overflow-y-auto overflow-x-hidden max-md:w-full max-md:min-w-full max-md:max-w-full max-md:h-auto max-md:static max-md:border-r-0 max-md:border-b max-md:max-h-96">
|
||||||
{/* Random Story Button */}
|
{/* Random Story Button */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Button
|
<Button
|
||||||
@@ -185,7 +196,7 @@ export default function SidebarLayout({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
<div className="mt-2 space-y-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onClearFilters}
|
onClick={onClearFilters}
|
||||||
@@ -193,7 +204,35 @@ export default function SidebarLayout({
|
|||||||
>
|
>
|
||||||
Clear All
|
Clear All
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Advanced Filters Toggle */}
|
||||||
|
{onAdvancedFiltersChange && (
|
||||||
|
<Button
|
||||||
|
variant={showAdvancedFilters || activeAdvancedFiltersCount > 0 ? "primary" : "ghost"}
|
||||||
|
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||||
|
className={`w-full text-xs py-1 ${showAdvancedFilters || activeAdvancedFiltersCount > 0 ? '' : 'border-dashed border-2'}`}
|
||||||
|
>
|
||||||
|
⚙️ Advanced Filters
|
||||||
|
{activeAdvancedFiltersCount > 0 && (
|
||||||
|
<span className="ml-1 bg-white text-blue-500 px-1 rounded text-xs">
|
||||||
|
{activeAdvancedFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Filters Section */}
|
||||||
|
{showAdvancedFilters && onAdvancedFiltersChange && (
|
||||||
|
<div className="mt-4 pt-4 border-t theme-border">
|
||||||
|
<AdvancedFilters
|
||||||
|
filters={advancedFilters}
|
||||||
|
onChange={onAdvancedFiltersChange}
|
||||||
|
onReset={() => onAdvancedFiltersChange({})}
|
||||||
|
className="space-y-3 max-w-full overflow-hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useState } from 'react';
|
|||||||
import { Input } from '../ui/Input';
|
import { Input } from '../ui/Input';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import TagDisplay from '../tags/TagDisplay';
|
import TagDisplay from '../tags/TagDisplay';
|
||||||
import { Story, Tag } from '../../types/api';
|
import AdvancedFilters from './AdvancedFilters';
|
||||||
|
import { Story, Tag, AdvancedFilters as AdvancedFiltersType } from '../../types/api';
|
||||||
|
|
||||||
interface ToolbarLayoutProps {
|
interface ToolbarLayoutProps {
|
||||||
stories: Story[];
|
stories: Story[];
|
||||||
@@ -15,11 +16,13 @@ interface ToolbarLayoutProps {
|
|||||||
viewMode: 'grid' | 'list';
|
viewMode: 'grid' | 'list';
|
||||||
sortOption: string;
|
sortOption: string;
|
||||||
sortDirection: 'asc' | 'desc';
|
sortDirection: 'asc' | 'desc';
|
||||||
|
advancedFilters?: AdvancedFiltersType;
|
||||||
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
onTagToggle: (tagName: string) => void;
|
onTagToggle: (tagName: string) => void;
|
||||||
onViewModeChange: (mode: 'grid' | 'list') => void;
|
onViewModeChange: (mode: 'grid' | 'list') => void;
|
||||||
onSortChange: (option: string) => void;
|
onSortChange: (option: string) => void;
|
||||||
onSortDirectionToggle: () => void;
|
onSortDirectionToggle: () => void;
|
||||||
|
onAdvancedFiltersChange?: (filters: AdvancedFiltersType) => void;
|
||||||
onRandomStory: () => void;
|
onRandomStory: () => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -34,16 +37,19 @@ export default function ToolbarLayout({
|
|||||||
viewMode,
|
viewMode,
|
||||||
sortOption,
|
sortOption,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
|
advancedFilters = {},
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onTagToggle,
|
onTagToggle,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onSortDirectionToggle,
|
onSortDirectionToggle,
|
||||||
|
onAdvancedFiltersChange,
|
||||||
onRandomStory,
|
onRandomStory,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
children
|
children
|
||||||
}: ToolbarLayoutProps) {
|
}: ToolbarLayoutProps) {
|
||||||
const [tagSearchExpanded, setTagSearchExpanded] = useState(false);
|
const [filterExpanded, setFilterExpanded] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'tags' | 'advanced'>('tags');
|
||||||
const [tagSearch, setTagSearch] = useState('');
|
const [tagSearch, setTagSearch] = useState('');
|
||||||
|
|
||||||
const popularTags = tags.slice(0, 6);
|
const popularTags = tags.slice(0, 6);
|
||||||
@@ -56,6 +62,11 @@ export default function ToolbarLayout({
|
|||||||
|
|
||||||
const remainingTagsCount = Math.max(0, remainingTags.length);
|
const remainingTagsCount = Math.max(0, remainingTags.length);
|
||||||
|
|
||||||
|
// Count active advanced filters
|
||||||
|
const activeAdvancedFiltersCount = Object.values(advancedFilters).filter(value =>
|
||||||
|
value !== undefined && value !== '' && value !== 'all' && value !== false
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto p-6 max-md:p-4">
|
<div className="max-w-7xl mx-auto p-6 max-md:p-4">
|
||||||
{/* Integrated Header */}
|
{/* Integrated Header */}
|
||||||
@@ -138,14 +149,15 @@ export default function ToolbarLayout({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tag Filter Bar */}
|
{/* Filter Section */}
|
||||||
<div className="border-t theme-border pt-5">
|
<div className="border-t theme-border pt-5">
|
||||||
|
{/* Top row - Popular tags and expand button */}
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
<span className="font-medium theme-text text-sm">Popular Tags:</span>
|
<span className="font-medium theme-text text-sm">Popular Tags:</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => onClearFilters()}
|
onClick={() => onClearFilters()}
|
||||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||||
selectedTags.length === 0
|
selectedTags.length === 0 && activeAdvancedFiltersCount === 0
|
||||||
? 'bg-blue-500 text-white'
|
? 'bg-blue-500 text-white'
|
||||||
: 'bg-gray-100 dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
|
: 'bg-gray-100 dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
|
||||||
}`}
|
}`}
|
||||||
@@ -168,64 +180,128 @@ export default function ToolbarLayout({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{remainingTagsCount > 0 && (
|
|
||||||
<button
|
{/* Filter expand button with counts */}
|
||||||
onClick={() => setTagSearchExpanded(!tagSearchExpanded)}
|
<button
|
||||||
className="px-3 py-1 rounded-full text-xs font-medium bg-gray-50 dark:bg-gray-800 theme-text border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-500"
|
onClick={() => setFilterExpanded(!filterExpanded)}
|
||||||
>
|
className={`px-3 py-1 rounded-full text-xs font-medium border-2 border-dashed transition-colors ${
|
||||||
+{remainingTagsCount} more tags
|
filterExpanded || activeAdvancedFiltersCount > 0 || remainingTagsCount > 0
|
||||||
</button>
|
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500 text-blue-700 dark:text-blue-300'
|
||||||
)}
|
: 'bg-gray-50 dark:bg-gray-800 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{remainingTagsCount > 0 && `+${remainingTagsCount} tags`}
|
||||||
|
{remainingTagsCount > 0 && activeAdvancedFiltersCount > 0 && ' • '}
|
||||||
|
{activeAdvancedFiltersCount > 0 && `${activeAdvancedFiltersCount} filters`}
|
||||||
|
{remainingTagsCount === 0 && activeAdvancedFiltersCount === 0 && 'More Filters'}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="ml-auto text-sm theme-text">
|
<div className="ml-auto text-sm theme-text">
|
||||||
Showing {stories.length} of {totalElements} stories
|
Showing {stories.length} of {totalElements} stories
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expandable Tag Search */}
|
{/* Expandable Filter Panel */}
|
||||||
{tagSearchExpanded && (
|
{filterExpanded && (
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border theme-border">
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border theme-border">
|
||||||
<div className="flex gap-3 mb-3">
|
{/* Tab Navigation */}
|
||||||
<Input
|
<div className="flex gap-1 mb-4">
|
||||||
type="text"
|
<button
|
||||||
placeholder="Search from all available tags..."
|
onClick={() => setActiveTab('tags')}
|
||||||
value={tagSearch}
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
onChange={(e) => setTagSearch(e.target.value)}
|
activeTab === 'tags'
|
||||||
className="flex-1"
|
? 'bg-white dark:bg-gray-700 theme-text shadow-sm'
|
||||||
|
: 'theme-text hover:bg-white/50 dark:hover:bg-gray-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
📋 Tags
|
||||||
|
{remainingTagsCount > 0 && (
|
||||||
|
<span className="ml-1 text-xs bg-gray-200 dark:bg-gray-600 px-1 rounded">
|
||||||
|
{remainingTagsCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('advanced')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'advanced'
|
||||||
|
? 'bg-white dark:bg-gray-700 theme-text shadow-sm'
|
||||||
|
: 'theme-text hover:bg-white/50 dark:hover:bg-gray-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
⚙️ Advanced
|
||||||
|
{activeAdvancedFiltersCount > 0 && (
|
||||||
|
<span className="ml-1 text-xs bg-blue-500 text-white px-1 rounded">
|
||||||
|
{activeAdvancedFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{activeTab === 'tags' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search from all available tags..."
|
||||||
|
value={tagSearch}
|
||||||
|
onChange={(e) => setTagSearch(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{tagSearch && (
|
||||||
|
<Button variant="ghost" onClick={() => setTagSearch('')}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2 max-h-40 overflow-y-auto max-md:grid-cols-2">
|
||||||
|
{filteredRemainingTags.length === 0 && tagSearch ? (
|
||||||
|
<div className="col-span-4 text-center text-sm text-gray-500 py-4">
|
||||||
|
No tags match "{tagSearch}"
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredRemainingTags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => onTagToggle(tag.name)}
|
||||||
|
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||||
|
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TagDisplay
|
||||||
|
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
||||||
|
size="sm"
|
||||||
|
clickable={true}
|
||||||
|
className={`w-full ${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : ''}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'advanced' && onAdvancedFiltersChange && (
|
||||||
|
<AdvancedFilters
|
||||||
|
filters={advancedFilters}
|
||||||
|
onChange={onAdvancedFiltersChange}
|
||||||
|
onReset={() => onAdvancedFiltersChange({})}
|
||||||
/>
|
/>
|
||||||
{tagSearch && (
|
)}
|
||||||
<Button variant="ghost" onClick={() => setTagSearch('')}>
|
|
||||||
Clear
|
{/* Action buttons */}
|
||||||
</Button>
|
<div className="flex justify-end gap-3 mt-4 pt-3 border-t theme-border">
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setTagSearchExpanded(false)}
|
onClick={() => setFilterExpanded(false)}
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
{(selectedTags.length > 0 || activeAdvancedFiltersCount > 0) && (
|
||||||
<div className="grid grid-cols-4 gap-2 max-h-40 overflow-y-auto max-md:grid-cols-2">
|
<Button variant="ghost" onClick={onClearFilters}>
|
||||||
{filteredRemainingTags.length === 0 && tagSearch ? (
|
Clear All Filters
|
||||||
<div className="col-span-4 text-center text-sm text-gray-500 py-4">
|
</Button>
|
||||||
No tags match "{tagSearch}"
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredRemainingTags.map((tag) => (
|
|
||||||
<div
|
|
||||||
key={tag.id}
|
|
||||||
onClick={() => onTagToggle(tag.name)}
|
|
||||||
className={`cursor-pointer transition-all hover:scale-105 ${
|
|
||||||
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<TagDisplay
|
|
||||||
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
|
||||||
size="sm"
|
|
||||||
clickable={true}
|
|
||||||
className={`w-full ${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : ''}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -196,6 +196,23 @@ export const storyApi = {
|
|||||||
getRandomStory: async (filters?: {
|
getRandomStory: async (filters?: {
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
minWordCount?: number;
|
||||||
|
maxWordCount?: number;
|
||||||
|
createdAfter?: string;
|
||||||
|
createdBefore?: string;
|
||||||
|
lastReadAfter?: string;
|
||||||
|
lastReadBefore?: string;
|
||||||
|
minRating?: number;
|
||||||
|
maxRating?: number;
|
||||||
|
unratedOnly?: boolean;
|
||||||
|
readingStatus?: string;
|
||||||
|
hasReadingProgress?: boolean;
|
||||||
|
hasCoverImage?: boolean;
|
||||||
|
sourceDomain?: string;
|
||||||
|
seriesFilter?: string;
|
||||||
|
minTagCount?: number;
|
||||||
|
popularOnly?: boolean;
|
||||||
|
hiddenGemsOnly?: boolean;
|
||||||
}): Promise<Story | null> => {
|
}): Promise<Story | null> => {
|
||||||
try {
|
try {
|
||||||
// Create URLSearchParams to properly handle array parameters like tags
|
// Create URLSearchParams to properly handle array parameters like tags
|
||||||
@@ -208,6 +225,25 @@ export const storyApi = {
|
|||||||
filters.tags.forEach(tag => searchParams.append('tags', tag));
|
filters.tags.forEach(tag => searchParams.append('tags', tag));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advanced filters
|
||||||
|
if (filters?.minWordCount !== undefined) searchParams.append('minWordCount', filters.minWordCount.toString());
|
||||||
|
if (filters?.maxWordCount !== undefined) searchParams.append('maxWordCount', filters.maxWordCount.toString());
|
||||||
|
if (filters?.createdAfter) searchParams.append('createdAfter', filters.createdAfter);
|
||||||
|
if (filters?.createdBefore) searchParams.append('createdBefore', filters.createdBefore);
|
||||||
|
if (filters?.lastReadAfter) searchParams.append('lastReadAfter', filters.lastReadAfter);
|
||||||
|
if (filters?.lastReadBefore) searchParams.append('lastReadBefore', filters.lastReadBefore);
|
||||||
|
if (filters?.minRating !== undefined) searchParams.append('minRating', filters.minRating.toString());
|
||||||
|
if (filters?.maxRating !== undefined) searchParams.append('maxRating', filters.maxRating.toString());
|
||||||
|
if (filters?.unratedOnly !== undefined) searchParams.append('unratedOnly', filters.unratedOnly.toString());
|
||||||
|
if (filters?.readingStatus) searchParams.append('readingStatus', filters.readingStatus);
|
||||||
|
if (filters?.hasReadingProgress !== undefined) searchParams.append('hasReadingProgress', filters.hasReadingProgress.toString());
|
||||||
|
if (filters?.hasCoverImage !== undefined) searchParams.append('hasCoverImage', filters.hasCoverImage.toString());
|
||||||
|
if (filters?.sourceDomain) searchParams.append('sourceDomain', filters.sourceDomain);
|
||||||
|
if (filters?.seriesFilter) searchParams.append('seriesFilter', filters.seriesFilter);
|
||||||
|
if (filters?.minTagCount !== undefined) searchParams.append('minTagCount', filters.minTagCount.toString());
|
||||||
|
if (filters?.popularOnly !== undefined) searchParams.append('popularOnly', filters.popularOnly.toString());
|
||||||
|
if (filters?.hiddenGemsOnly !== undefined) searchParams.append('hiddenGemsOnly', filters.hiddenGemsOnly.toString());
|
||||||
|
|
||||||
const response = await api.get(`/stories/random?${searchParams.toString()}`);
|
const response = await api.get(`/stories/random?${searchParams.toString()}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -443,6 +479,22 @@ export const searchApi = {
|
|||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortDir?: string;
|
sortDir?: string;
|
||||||
facetBy?: string[];
|
facetBy?: string[];
|
||||||
|
// Advanced filters
|
||||||
|
minWordCount?: number;
|
||||||
|
maxWordCount?: number;
|
||||||
|
createdAfter?: string;
|
||||||
|
createdBefore?: string;
|
||||||
|
lastReadAfter?: string;
|
||||||
|
lastReadBefore?: string;
|
||||||
|
unratedOnly?: boolean;
|
||||||
|
readingStatus?: string;
|
||||||
|
hasReadingProgress?: boolean;
|
||||||
|
hasCoverImage?: boolean;
|
||||||
|
sourceDomain?: string;
|
||||||
|
seriesFilter?: string;
|
||||||
|
minTagCount?: number;
|
||||||
|
popularOnly?: boolean;
|
||||||
|
hiddenGemsOnly?: boolean;
|
||||||
}): Promise<SearchResult> => {
|
}): Promise<SearchResult> => {
|
||||||
// Resolve tag aliases to canonical names for expanded search
|
// Resolve tag aliases to canonical names for expanded search
|
||||||
let resolvedTags = params.tags;
|
let resolvedTags = params.tags;
|
||||||
@@ -468,6 +520,23 @@ export const searchApi = {
|
|||||||
if (params.sortBy) searchParams.append('sortBy', params.sortBy);
|
if (params.sortBy) searchParams.append('sortBy', params.sortBy);
|
||||||
if (params.sortDir) searchParams.append('sortDir', params.sortDir);
|
if (params.sortDir) searchParams.append('sortDir', params.sortDir);
|
||||||
|
|
||||||
|
// Advanced filters
|
||||||
|
if (params.minWordCount !== undefined) searchParams.append('minWordCount', params.minWordCount.toString());
|
||||||
|
if (params.maxWordCount !== undefined) searchParams.append('maxWordCount', params.maxWordCount.toString());
|
||||||
|
if (params.createdAfter) searchParams.append('createdAfter', params.createdAfter);
|
||||||
|
if (params.createdBefore) searchParams.append('createdBefore', params.createdBefore);
|
||||||
|
if (params.lastReadAfter) searchParams.append('lastReadAfter', params.lastReadAfter);
|
||||||
|
if (params.lastReadBefore) searchParams.append('lastReadBefore', params.lastReadBefore);
|
||||||
|
if (params.unratedOnly !== undefined) searchParams.append('unratedOnly', params.unratedOnly.toString());
|
||||||
|
if (params.readingStatus) searchParams.append('readingStatus', params.readingStatus);
|
||||||
|
if (params.hasReadingProgress !== undefined) searchParams.append('hasReadingProgress', params.hasReadingProgress.toString());
|
||||||
|
if (params.hasCoverImage !== undefined) searchParams.append('hasCoverImage', params.hasCoverImage.toString());
|
||||||
|
if (params.sourceDomain) searchParams.append('sourceDomain', params.sourceDomain);
|
||||||
|
if (params.seriesFilter) searchParams.append('seriesFilter', params.seriesFilter);
|
||||||
|
if (params.minTagCount !== undefined) searchParams.append('minTagCount', params.minTagCount.toString());
|
||||||
|
if (params.popularOnly !== undefined) searchParams.append('popularOnly', params.popularOnly.toString());
|
||||||
|
if (params.hiddenGemsOnly !== undefined) searchParams.append('hiddenGemsOnly', params.hiddenGemsOnly.toString());
|
||||||
|
|
||||||
// Add array parameters - each element gets its own parameter
|
// Add array parameters - each element gets its own parameter
|
||||||
if (params.authors && params.authors.length > 0) {
|
if (params.authors && params.authors.length > 0) {
|
||||||
params.authors.forEach(author => searchParams.append('authors', author));
|
params.authors.forEach(author => searchParams.append('authors', author));
|
||||||
|
|||||||
@@ -160,3 +160,48 @@ export interface CollectionStatistics {
|
|||||||
storyCount: number;
|
storyCount: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advanced filter interfaces
|
||||||
|
export interface AdvancedFilters {
|
||||||
|
// Word count filters
|
||||||
|
minWordCount?: number;
|
||||||
|
maxWordCount?: number;
|
||||||
|
|
||||||
|
// Date filters
|
||||||
|
createdAfter?: string; // ISO date string
|
||||||
|
createdBefore?: string; // ISO date string
|
||||||
|
lastReadAfter?: string;
|
||||||
|
lastReadBefore?: string;
|
||||||
|
|
||||||
|
// Rating filters (extending existing)
|
||||||
|
minRating?: number;
|
||||||
|
maxRating?: number;
|
||||||
|
unratedOnly?: boolean;
|
||||||
|
|
||||||
|
// Reading status filters
|
||||||
|
readingStatus?: 'all' | 'unread' | 'started' | 'completed';
|
||||||
|
hasReadingProgress?: boolean;
|
||||||
|
|
||||||
|
// Content filters
|
||||||
|
hasCoverImage?: boolean;
|
||||||
|
sourceDomain?: string;
|
||||||
|
|
||||||
|
// Series filters
|
||||||
|
seriesFilter?: 'all' | 'standalone' | 'series' | 'firstInSeries' | 'lastInSeries';
|
||||||
|
|
||||||
|
// Organization filters
|
||||||
|
minTagCount?: number;
|
||||||
|
|
||||||
|
// Quality filters
|
||||||
|
popularOnly?: boolean; // Stories with above-average ratings
|
||||||
|
hiddenGemsOnly?: boolean; // Unrated or low-rated stories
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preset filter configurations
|
||||||
|
export interface FilterPreset {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
filters: Partial<AdvancedFilters>;
|
||||||
|
category: 'length' | 'date' | 'rating' | 'reading' | 'content' | 'organization';
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user