• 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.

    Parameters

    Returns Element

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