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

@@ -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,64 +180,128 @@ export default function ToolbarLayout({
/>
</div>
))}
{remainingTagsCount > 0 && (
<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"
>
+{remainingTagsCount} more tags
</button>
)}
{/* Filter expand button with counts */}
<button
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 > 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">
<Input
type="text"
placeholder="Search from all available tags..."
value={tagSearch}
onChange={(e) => setTagSearch(e.target.value)}
className="flex-1"
{/* 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..."
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
</Button>
)}
<Button
variant="ghost"
onClick={() => setTagSearchExpanded(false)}
)}
{/* 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>
</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>
))
{(selectedTags.length > 0 || activeAdvancedFiltersCount > 0) && (
<Button variant="ghost" onClick={onClearFilters}>
Clear All Filters
</Button>
)}
</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

@@ -159,4 +159,49 @@ export interface CollectionStatistics {
authorName: string;
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';
}