Advanced Filters - Build optimizations

This commit is contained in:
Stefan Hardegger
2025-09-04 15:49:24 +02:00
parent 702fcb33c1
commit f92dcc5314
14 changed files with 1426 additions and 109 deletions

View File

@@ -2,15 +2,15 @@ FROM openjdk:17-jdk-slim
WORKDIR /app
COPY pom.xml .
COPY src ./src
# Install Maven
RUN apt-get update && apt-get install -y maven && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y maven && \
mvn clean package -DskipTests && \
apt-get remove -y maven && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
# Copy source code
COPY . .
# Build the application
RUN mvn clean package -DskipTests
EXPOSE 8080
CMD ["java", "-jar", "target/storycove-backend-0.0.1-SNAPSHOT.jar"]
ENTRYPOINT ["java", "-jar", "target/storycove-backend-0.0.1-SNAPSHOT.jar"]

View File

@@ -90,12 +90,33 @@ public class StoryController {
public ResponseEntity<StorySummaryDto> getRandomStory(
@RequestParam(required = false) String searchQuery,
@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: {}",
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()) {
StorySummaryDto storyDto = convertToSummaryDto(randomStory.get());
@@ -273,12 +294,31 @@ public class StoryController {
@RequestParam(required = false) Integer maxRating,
@RequestParam(required = false) String sortBy,
@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) {
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);
} else {
// Fallback to basic search if Typesense is not available

View File

@@ -682,7 +682,13 @@ public class StoryService {
*/
@Transactional(readOnly = true)
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
*/
@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
if (typesenseService != null) {
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()) {
return storyRepository.findById(randomStoryId.get());
}

View File

@@ -124,9 +124,16 @@ public class TypesenseService {
new Field().name("wordCount").type("int32").facet(true).sort(true).optional(true),
new Field().name("volume").type("int32").facet(true).sort(true).optional(true),
new Field().name("createdAt").type("int64").facet(false).sort(true),
new Field().name("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("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()
@@ -249,7 +256,23 @@ public class TypesenseService {
Integer maxRating,
String sortBy,
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 {
long startTime = System.currentTimeMillis();
@@ -309,6 +332,99 @@ public class TypesenseService {
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()) {
String finalFilter = String.join(" && ", filterConditions);
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.
* This ensures consistency between Library search results and Random Story functionality.
* 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 {
String normalizedQuery = (searchQuery == null || searchQuery.trim().isEmpty()) ? "*" : searchQuery.trim();
@@ -422,12 +552,111 @@ public class TypesenseService {
.sortBy(sortBy)
.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()) {
String tagFilter = tags.stream()
.map(tag -> "tagNames:=" + escapeTypesenseValue(tag))
.collect(Collectors.joining(" && "));
searchParameters.filterBy(tagFilter);
for (String tag : tags) {
String escaped = escapeTypesenseValue(tag);
filterConditions.add("tagNames:=" + escaped);
}
}
// 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())
@@ -449,7 +678,10 @@ public class TypesenseService {
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
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) {
@@ -462,7 +694,15 @@ public class TypesenseService {
* Fallback method for random story selection using offset-based randomization with seed support.
* 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 {
// First, get the total count of matching stories
SearchParameters countParameters = new SearchParameters()
@@ -470,12 +710,115 @@ public class TypesenseService {
.queryBy("title,description,authorName,seriesName,tagNames")
.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()) {
String tagFilter = tags.stream()
.map(tag -> "tagNames:=" + escapeTypesenseValue(tag))
.collect(Collectors.joining(" && "));
countParameters.filterBy(tagFilter);
for (String tag : tags) {
String escaped = escapeTypesenseValue(tag);
filterConditions.add("tagNames:=" + escaped);
}
}
// 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())
@@ -595,6 +938,27 @@ public class TypesenseService {
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;
}
@@ -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.
* Typesense requires certain characters to be escaped or quoted.

View File

@@ -1,40 +1,58 @@
# Use node 18 alpine for smaller image size
FROM node:18-alpine
# Multi-stage build for better layer caching and smaller final image
FROM node:18-alpine AS deps
WORKDIR /app
# Install dumb-init for proper signal handling
# Install dumb-init early
RUN apk add --no-cache dumb-init
# Copy package files
# Copy package files first to leverage Docker layer caching
COPY package*.json ./
# Install all dependencies (including devDependencies needed for build)
# Set npm config for better CI performance
RUN npm ci --prefer-offline --no-audit
# Install dependencies with optimized settings
RUN npm ci --prefer-offline --no-audit --frozen-lockfile
# 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 . .
# 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 NEXT_TELEMETRY_DISABLED=1
# Build the application
RUN npm run build
# Remove devDependencies after build to reduce image size
RUN npm prune --omit=dev
# Production stage
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
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
# Change ownership of the app directory
RUN chown -R nextjs:nodejs /app
# Copy necessary files from builder stage
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
EXPOSE 3000
# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
CMD ["npm", "start"]
CMD ["node", "server.js"]

View File

@@ -1,5 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Enable standalone output for optimized Docker builds
output: 'standalone',
// Removed Next.js rewrites since nginx handles all API routing
webpack: (config, { isServer }) => {
// Exclude cheerio and its dependencies from client-side bundling

View File

@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
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 { Input } from '../../components/ui/Input';
import Button from '../../components/ui/Button';
@@ -37,6 +37,7 @@ export default function LibraryPage() {
const [totalElements, setTotalElements] = useState(0);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [urlParamsProcessed, setUrlParamsProcessed] = useState(false);
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>({});
// Initialize filters from URL parameters
useEffect(() => {
@@ -145,6 +146,8 @@ export default function LibraryPage() {
sortBy: sortOption,
sortDir: sortDirection,
facetBy: ['tagNames'], // Request tag facets for the filter UI
// Advanced filters
...advancedFilters
};
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
return () => clearTimeout(debounceTimer);
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger, urlParamsProcessed]);
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger, urlParamsProcessed, advancedFilters]);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
@@ -191,7 +194,8 @@ export default function LibraryPage() {
setRandomLoading(true);
const randomStory = await storyApi.getRandomStory({
searchQuery: searchQuery || undefined,
tags: selectedTags.length > 0 ? selectedTags : undefined
tags: selectedTags.length > 0 ? selectedTags : undefined,
...advancedFilters
});
if (randomStory) {
router.push(`/stories/${randomStory.id}`);
@@ -209,6 +213,7 @@ export default function LibraryPage() {
const clearFilters = () => {
setSearchQuery('');
setSelectedTags([]);
setAdvancedFilters({});
setPage(0);
setRefreshTrigger(prev => prev + 1);
};
@@ -227,6 +232,12 @@ export default function LibraryPage() {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
};
const handleAdvancedFiltersChange = (filters: AdvancedFilters) => {
setAdvancedFilters(filters);
setPage(0);
setRefreshTrigger(prev => prev + 1);
};
if (loading) {
return (
<AppLayout>
@@ -250,11 +261,13 @@ export default function LibraryPage() {
viewMode,
sortOption,
sortDirection,
advancedFilters,
onSearchChange: handleSearchChange,
onTagToggle: handleTagToggle,
onViewModeChange: setViewMode,
onSortChange: handleSortChange,
onSortDirectionToggle: handleSortDirectionToggle,
onAdvancedFiltersChange: handleAdvancedFiltersChange,
onRandomStory: handleRandomStory,
onClearFilters: clearFilters,
};
@@ -264,12 +277,12 @@ export default function LibraryPage() {
return (
<div className="text-center py-12 theme-card theme-shadow rounded-lg">
<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.'
: 'Your library is empty.'
}
</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}>
Clear Filters
</Button>

View 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>
);
}

View File

@@ -4,7 +4,8 @@ import { useState } from 'react';
import { Input } from '../ui/Input';
import Button from '../ui/Button';
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 {
stories: Story[];
@@ -15,11 +16,13 @@ interface MinimalLayoutProps {
viewMode: 'grid' | 'list';
sortOption: string;
sortDirection: 'asc' | 'desc';
advancedFilters?: AdvancedFiltersType;
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onTagToggle: (tagName: string) => void;
onViewModeChange: (mode: 'grid' | 'list') => void;
onSortChange: (option: string) => void;
onSortDirectionToggle: () => void;
onAdvancedFiltersChange?: (filters: AdvancedFiltersType) => void;
onRandomStory: () => void;
onClearFilters: () => void;
children: React.ReactNode;
@@ -34,16 +37,19 @@ export default function MinimalLayout({
viewMode,
sortOption,
sortDirection,
advancedFilters = {},
onSearchChange,
onTagToggle,
onViewModeChange,
onSortChange,
onSortDirectionToggle,
onAdvancedFiltersChange,
onRandomStory,
onClearFilters,
children
}: MinimalLayoutProps) {
const [tagBrowserOpen, setTagBrowserOpen] = useState(false);
const [advancedFiltersOpen, setAdvancedFiltersOpen] = useState(false);
const [tagSearch, setTagSearch] = useState('');
const popularTags = tags.slice(0, 5);
@@ -53,6 +59,11 @@ export default function MinimalLayout({
? tags.filter(tag => tag.name.toLowerCase().includes(tagSearch.toLowerCase()))
: tags;
// Count active advanced filters
const activeAdvancedFiltersCount = Object.values(advancedFilters).filter(value =>
value !== undefined && value !== '' && value !== 'all' && value !== false
).length;
const getSortDisplayText = () => {
const sortLabels: Record<string, string> = {
lastRead: 'Last Read',
@@ -104,7 +115,28 @@ export default function MinimalLayout({
{getSortDisplayText()}
</button>
<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}>
Clear Filters
</Button>
@@ -247,6 +279,41 @@ export default function MinimalLayout({
</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>
);
}

View File

@@ -4,7 +4,8 @@ import { useState } from 'react';
import { Input } from '../ui/Input';
import Button from '../ui/Button';
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 {
stories: Story[];
@@ -15,11 +16,13 @@ interface SidebarLayoutProps {
viewMode: 'grid' | 'list';
sortOption: string;
sortDirection: 'asc' | 'desc';
advancedFilters?: AdvancedFiltersType;
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onTagToggle: (tagName: string) => void;
onViewModeChange: (mode: 'grid' | 'list') => void;
onSortChange: (option: string) => void;
onSortDirectionToggle: () => void;
onAdvancedFiltersChange?: (filters: AdvancedFiltersType) => void;
onRandomStory: () => void;
onClearFilters: () => void;
children: React.ReactNode;
@@ -34,26 +37,34 @@ export default function SidebarLayout({
viewMode,
sortOption,
sortDirection,
advancedFilters = {},
onSearchChange,
onTagToggle,
onViewModeChange,
onSortChange,
onSortDirectionToggle,
onAdvancedFiltersChange,
onRandomStory,
onClearFilters,
children
}: SidebarLayoutProps) {
const [tagSearch, setTagSearch] = useState('');
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
// Filter tags based on search query
const filteredTags = tags.filter(tag =>
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 (
<div className="flex min-h-screen">
{/* 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 */}
<div className="mb-6">
<Button
@@ -185,7 +196,7 @@ export default function SidebarLayout({
)}
</div>
</div>
<div className="mt-2">
<div className="mt-2 space-y-2">
<Button
variant="ghost"
onClick={onClearFilters}
@@ -193,7 +204,35 @@ export default function SidebarLayout({
>
Clear All
</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>
{/* 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>

View File

@@ -4,7 +4,8 @@ import { useState } from 'react';
import { Input } from '../ui/Input';
import Button from '../ui/Button';
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 {
stories: Story[];
@@ -15,11 +16,13 @@ interface ToolbarLayoutProps {
viewMode: 'grid' | 'list';
sortOption: string;
sortDirection: 'asc' | 'desc';
advancedFilters?: AdvancedFiltersType;
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onTagToggle: (tagName: string) => void;
onViewModeChange: (mode: 'grid' | 'list') => void;
onSortChange: (option: string) => void;
onSortDirectionToggle: () => void;
onAdvancedFiltersChange?: (filters: AdvancedFiltersType) => void;
onRandomStory: () => void;
onClearFilters: () => void;
children: React.ReactNode;
@@ -34,16 +37,19 @@ export default function ToolbarLayout({
viewMode,
sortOption,
sortDirection,
advancedFilters = {},
onSearchChange,
onTagToggle,
onViewModeChange,
onSortChange,
onSortDirectionToggle,
onAdvancedFiltersChange,
onRandomStory,
onClearFilters,
children
}: ToolbarLayoutProps) {
const [tagSearchExpanded, setTagSearchExpanded] = useState(false);
const [filterExpanded, setFilterExpanded] = useState(false);
const [activeTab, setActiveTab] = useState<'tags' | 'advanced'>('tags');
const [tagSearch, setTagSearch] = useState('');
const popularTags = tags.slice(0, 6);
@@ -56,6 +62,11 @@ export default function ToolbarLayout({
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 (
<div className="max-w-7xl mx-auto p-6 max-md:p-4">
{/* Integrated Header */}
@@ -138,14 +149,15 @@ export default function ToolbarLayout({
</div>
</div>
{/* Tag Filter Bar */}
{/* Filter Section */}
<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">
<span className="font-medium theme-text text-sm">Popular Tags:</span>
<button
onClick={() => onClearFilters()}
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-gray-100 dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
}`}
@@ -168,23 +180,68 @@ export default function ToolbarLayout({
/>
</div>
))}
{remainingTagsCount > 0 && (
{/* Filter expand button with counts */}
<button
onClick={() => setTagSearchExpanded(!tagSearchExpanded)}
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 ${
filterExpanded || activeAdvancedFiltersCount > 0 || remainingTagsCount > 0
? '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} more tags
{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">
Showing {stories.length} of {totalElements} stories
</div>
</div>
{/* Expandable Tag Search */}
{tagSearchExpanded && (
{/* Expandable Filter Panel */}
{filterExpanded && (
<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 */}
<div className="flex gap-1 mb-4">
<button
onClick={() => setActiveTab('tags')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'tags'
? '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..."
@@ -197,12 +254,6 @@ export default function ToolbarLayout({
Clear
</Button>
)}
<Button
variant="ghost"
onClick={() => setTagSearchExpanded(false)}
>
Close
</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 ? (
@@ -230,6 +281,31 @@ export default function ToolbarLayout({
</div>
</div>
)}
{activeTab === 'advanced' && onAdvancedFiltersChange && (
<AdvancedFilters
filters={advancedFilters}
onChange={onAdvancedFiltersChange}
onReset={() => onAdvancedFiltersChange({})}
/>
)}
{/* Action buttons */}
<div className="flex justify-end gap-3 mt-4 pt-3 border-t theme-border">
<Button
variant="ghost"
onClick={() => setFilterExpanded(false)}
>
Close
</Button>
{(selectedTags.length > 0 || activeAdvancedFiltersCount > 0) && (
<Button variant="ghost" onClick={onClearFilters}>
Clear All Filters
</Button>
)}
</div>
</div>
)}
</div>
</div>

View File

@@ -196,6 +196,23 @@ export const storyApi = {
getRandomStory: async (filters?: {
searchQuery?: 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> => {
try {
// Create URLSearchParams to properly handle array parameters like tags
@@ -208,6 +225,25 @@ export const storyApi = {
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()}`);
return response.data;
} catch (error: any) {
@@ -443,6 +479,22 @@ export const searchApi = {
sortBy?: string;
sortDir?: 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> => {
// Resolve tag aliases to canonical names for expanded search
let resolvedTags = params.tags;
@@ -468,6 +520,23 @@ export const searchApi = {
if (params.sortBy) searchParams.append('sortBy', params.sortBy);
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
if (params.authors && params.authors.length > 0) {
params.authors.forEach(author => searchParams.append('authors', author));

View File

@@ -160,3 +160,48 @@ export interface CollectionStatistics {
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