React component for advanced filtering UI.
export function AdvancedFilterPanel({
filters,
onFiltersChange,
availableGenres,
availableFormats,
availableStatuses,
availableTags,
yearRange,
matchCount,
userPresets,
onSavePreset,
onApplyPreset,
onDeletePreset,
}: Readonly<AdvancedFilterPanelProps>) {
const [isOpen, setIsOpen] = useState(false);
const [isPresetDialogVisible, setIsPresetDialogVisible] = useState(false);
const [presetName, setPresetName] = useState("");
const [presetDescription, setPresetDescription] = useState("");
// Memoize active filter count for badge display
const activeFilterCount = useMemo(() => {
const isDefaultConfidence =
filters.confidence.min === 0 && filters.confidence.max === 100;
const hasYearRange =
filters.yearRange &&
(filters.yearRange.min !== null || filters.yearRange.max !== null);
return (
(isDefaultConfidence ? 0 : 1) +
filters.formats.length +
filters.genres.length +
filters.publicationStatuses.length +
(hasYearRange ? 1 : 0) +
(filters.tags?.length || 0)
);
}, [filters]);
// Handle confidence range change
const handleConfidenceChange = (value: { min: number; max: number }) => {
onFiltersChange({ ...filters, confidence: value });
};
// Add or remove format filter
const handleFormatToggle = (format: string) => {
const newFormats = filters.formats.includes(format)
? filters.formats.filter((f) => f !== format)
: [...filters.formats, format];
onFiltersChange({ ...filters, formats: newFormats });
};
// Add or remove genre filter
const handleGenreToggle = (genre: string) => {
const newGenres = filters.genres.includes(genre)
? filters.genres.filter((g) => g !== genre)
: [...filters.genres, genre];
onFiltersChange({ ...filters, genres: newGenres });
};
// Add or remove publication status filter
const handleStatusToggle = (status: string) => {
const newStatuses = filters.publicationStatuses.includes(status)
? filters.publicationStatuses.filter((s) => s !== status)
: [...filters.publicationStatuses, status];
onFiltersChange({ ...filters, publicationStatuses: newStatuses });
};
// Add or remove tag filter
const handleTagToggle = (tag: string) => {
const currentTags = filters.tags || [];
const newTags = currentTags.includes(tag)
? currentTags.filter((t) => t !== tag)
: [...currentTags, tag];
onFiltersChange({ ...filters, tags: newTags });
};
// Handle year range change with validation to ensure min <= max
const handleYearRangeChange = (min: number | null, max: number | null) => {
// Validate and clamp values if needed
let validatedMin = min;
let validatedMax = max;
if (min !== null && max !== null && min > max) {
// Swap values if min > max
[validatedMin, validatedMax] = [validatedMax, validatedMin];
}
onFiltersChange({
...filters,
yearRange: { min: validatedMin, max: validatedMax },
});
};
// Apply built-in preset filter configuration - merge with existing filters to preserve unspecified fields
const handleBuiltInPresetApply = (preset: BuiltInFilterPreset) => {
onFiltersChange({ ...filters, ...preset.filters });
};
// Apply user preset - merge with existing filters to preserve unspecified fields
const handleUserPresetApply = (preset: FilterPreset) => {
onFiltersChange({ ...filters, ...preset.filters });
onApplyPreset(preset);
};
// Save current filters as preset
const handleSavePreset = () => {
if (presetName.trim()) {
onSavePreset(presetName.trim(), presetDescription.trim() || undefined);
setPresetName("");
setPresetDescription("");
setIsPresetDialogVisible(false);
}
};
// Delete preset with confirmation
const handleDeletePreset = (presetId: string, presetName: string) => {
if (globalThis.confirm(`Delete preset "${presetName}"?`)) {
onDeletePreset(presetId);
}
};
// Reset all filters to default state
const handleClearAll = () => {
onFiltersChange({
confidence: { min: 0, max: 100 },
formats: [],
genres: [],
publicationStatuses: [],
yearRange: { min: null, max: null },
tags: [],
});
};
// Select all genres at once
const handleSelectAllGenres = () => {
onFiltersChange({ ...filters, genres: availableGenres });
};
// Deselect all genres
const handleClearAllGenres = () => {
onFiltersChange({ ...filters, genres: [] });
};
// Select all tags at once
const handleSelectAllTags = () => {
onFiltersChange({ ...filters, tags: availableTags });
};
// Deselect all tags
const handleClearAllTags = () => {
onFiltersChange({ ...filters, tags: [] });
};
return (
<Card className="bg-linear-to-br from-slate-50 to-slate-100/50 backdrop-blur-sm dark:from-slate-800/50 dark:to-slate-900/30">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<SlidersHorizontal className="h-5 w-5 text-slate-600 dark:text-slate-400" />
<div>
<CardTitle className="text-lg">Advanced Filters</CardTitle>
<CardDescription>Fine-tune your match results</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
{/* Active filter count badge */}
{activeFilterCount > 0 && (
<Badge
variant="secondary"
className="rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
>
{activeFilterCount} active
</Badge>
)}
{/* Collapse toggle */}
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<CollapsibleChevron isExpanded={isOpen} />
<span className="sr-only">Toggle advanced filters</span>
</Button>
</CollapsibleTrigger>
</div>
</div>
{/* Filter presets */}
<div className="space-y-3 pt-3">
{/* Built-in presets */}
<div className="flex flex-wrap gap-2">
{BUILT_IN_PRESETS.map((preset) => {
const PresetIcon = preset.icon;
return (
<Button
key={preset.id}
variant="outline"
size="sm"
onClick={() => handleBuiltInPresetApply(preset)}
className="h-7 gap-1.5 text-xs"
title={preset.description}
>
<PresetIcon className="h-3 w-3" />
{preset.name}
</Button>
);
})}
</div>
{/* User presets */}
{userPresets.length > 0 && (
<>
<div className="border-t border-slate-200 dark:border-slate-700" />
<div className="flex flex-wrap gap-2">
{userPresets.map((preset) => (
<div key={preset.id} className="group relative">
<Button
variant="outline"
size="sm"
onClick={() => handleUserPresetApply(preset)}
className="h-7 gap-1.5 pr-8 text-xs"
title={preset.description || preset.name}
>
<Star className="h-3 w-3" />
{preset.name}
</Button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeletePreset(preset.id, preset.name);
}}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity hover:bg-slate-200 group-hover:opacity-100 dark:hover:bg-slate-700"
aria-label={`Delete ${preset.name}`}
>
<Trash2 className="h-3 w-3 text-slate-500 dark:text-slate-400" />
</button>
</div>
))}
</div>
</>
)}
{/* Save preset button */}
<Button
variant="ghost"
size="sm"
onClick={() => setIsPresetDialogVisible(true)}
className="h-7 gap-1.5 text-xs"
>
<Save className="h-3 w-3" />
Save Current Filters
</Button>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="space-y-6 pt-0">
<Alert
variant="default"
className="border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-300"
>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Some entries may not appear as they may not have any related
data to filter by.
</AlertDescription>
</Alert>
{/* Confidence Range */}
<div className="space-y-3">
<div>
<div className="text-sm font-medium text-slate-700 dark:text-slate-300">
Confidence Score
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Filter by match confidence percentage
</p>
</div>
<RangeSlider
min={0}
max={100}
step={5}
value={filters.confidence}
onChange={handleConfidenceChange}
/>
</div>
{/* Year Range Filter */}
<div className="space-y-3">
<div>
<div className="text-sm font-medium text-slate-700 dark:text-slate-300">
Publication Year
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Filter by year of publication
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<label
htmlFor="year-min"
className="mb-1 block text-xs text-slate-500 dark:text-slate-400"
>
From
</label>
<Input
id="year-min"
type="number"
min={yearRange.min || 1900}
max={yearRange.max || new Date().getFullYear()}
value={filters.yearRange?.min ?? ""}
onChange={(e) =>
handleYearRangeChange(
e.target.value
? Number.parseInt(e.target.value, 10)
: null,
filters.yearRange?.max ?? null,
)
}
placeholder="Min"
className="h-8 text-sm"
/>
</div>
<div className="flex-1">
<label
htmlFor="year-max"
className="mb-1 block text-xs text-slate-500 dark:text-slate-400"
>
To
</label>
<Input
id="year-max"
type="number"
min={yearRange.min || 1900}
max={yearRange.max || new Date().getFullYear()}
value={filters.yearRange?.max ?? ""}
onChange={(e) =>
handleYearRangeChange(
filters.yearRange?.min ?? null,
e.target.value
? Number.parseInt(e.target.value, 10)
: null,
)
}
placeholder="Max"
className="h-8 text-sm"
/>
</div>
</div>
{(filters.yearRange?.min !== null ||
filters.yearRange?.max !== null) && (
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500 dark:text-slate-400">
Showing: {filters.yearRange?.min || "Any"} -{" "}
{filters.yearRange?.max || "Any"}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleYearRangeChange(null, null)}
className="h-6 text-xs"
>
Clear
</Button>
</div>
)}
</div>
{/* Format Filter */}
{availableFormats.length > 0 && (
<div className="space-y-3">
<div className="text-sm font-medium text-slate-700 dark:text-slate-300">
Format
</div>
<div className="space-y-2">
{availableFormats.map((format) => (
<div key={format} className="flex items-center gap-2">
<Checkbox
id={`format-${format}`}
checked={filters.formats.includes(format)}
onCheckedChange={() => handleFormatToggle(format)}
/>
<label
htmlFor={`format-${format}`}
className="cursor-pointer text-sm text-slate-700 dark:text-slate-300"
>
{formatLabel(format)}
</label>
</div>
))}
</div>
</div>
)}
{/* Genre Filter */}
{availableGenres.length > 0 && (
<SearchableFilterList
items={availableGenres}
selectedItems={filters.genres}
onToggle={handleGenreToggle}
label={(genre) => genre}
shouldShowSelectClear
onSelectAll={handleSelectAllGenres}
onClearAll={handleClearAllGenres}
searchPlaceholder="Search genres..."
/>
)}
{/* Tags Filter */}
{availableTags.length > 0 && (
<SearchableFilterList
items={availableTags}
selectedItems={filters.tags || []}
onToggle={handleTagToggle}
label={(tag) => tag}
shouldShowSelectClear
onSelectAll={handleSelectAllTags}
onClearAll={handleClearAllTags}
searchPlaceholder="Search tags..."
/>
)}
{/* Publication Status Filter */}
{availableStatuses.length > 0 && (
<div className="space-y-3">
<div className="text-sm font-medium text-slate-700 dark:text-slate-300">
Publication Status
</div>
<div className="space-y-2">
{availableStatuses.map((status) => (
<div key={status} className="flex items-center gap-2">
<Checkbox
id={`status-${status}`}
checked={filters.publicationStatuses.includes(status)}
onCheckedChange={() => handleStatusToggle(status)}
/>
<label
htmlFor={`status-${status}`}
className="cursor-pointer text-sm text-slate-700 dark:text-slate-300"
>
{formatPublicationStatusLabel(status)}
</label>
</div>
))}
</div>
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between border-t border-slate-200 pt-4 dark:border-slate-700">
<span className="text-sm text-slate-600 dark:text-slate-400">
Showing {matchCount} {matchCount === 1 ? "match" : "matches"}
</span>
<Button
variant="outline"
size="sm"
onClick={handleClearAll}
disabled={activeFilterCount === 0}
>
Clear All Filters
</Button>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
{/* Preset Save Dialog */}
<Dialog
open={isPresetDialogVisible}
onOpenChange={setIsPresetDialogVisible}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Save Filter Preset</DialogTitle>
<DialogDescription>
Save your current filter configuration for quick access later.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label
htmlFor="preset-name"
className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
Preset Name *
</label>
<Input
id="preset-name"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder="e.g., High Quality Action"
className="w-full"
/>
</div>
<div>
<label
htmlFor="preset-description"
className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
Description (optional)
</label>
<Textarea
id="preset-description"
value={presetDescription}
onChange={(e) => setPresetDescription(e.target.value)}
placeholder="Describe what this preset filters for..."
rows={3}
className="w-full resize-none"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsPresetDialogVisible(false);
setPresetName("");
setPresetDescription("");
}}
>
Cancel
</Button>
<Button onClick={handleSavePreset} disabled={!presetName.trim()}>
Save Preset
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
Advanced filter panel for manga matching results. Provides collapsible filtering by confidence score, format, genres, and publication status. Includes preset filters and real-time genre search functionality.