From a590caa3d3016e8ac3960ec9f1e5dfefd4720ee4 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 16 Nov 2025 16:20:38 -0800 Subject: [PATCH] FR: Performer Age Slider (#6267) - Add SidebarAgeFilter component with age presets (18-25, 25-35, 35-45, 45-60, 60+) --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .../List/Filters/SidebarAgeFilter.tsx | 310 ++++++++++++++++++ ui/v2.5/src/components/List/styles.scss | 6 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 13 +- ui/v2.5/src/components/Shared/styles.scss | 2 +- ui/v2.5/src/models/list-filter/scenes.ts | 5 +- 5 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 ui/v2.5/src/components/List/Filters/SidebarAgeFilter.tsx diff --git a/ui/v2.5/src/components/List/Filters/SidebarAgeFilter.tsx b/ui/v2.5/src/components/List/Filters/SidebarAgeFilter.tsx new file mode 100644 index 000000000..3a6449ab6 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/SidebarAgeFilter.tsx @@ -0,0 +1,310 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { CriterionModifier } from "../../../core/generated-graphql"; +import { CriterionOption } from "../../../models/list-filter/criteria/criterion"; +import { NumberCriterion } from "src/models/list-filter/criteria/criterion"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { Option, SidebarListFilter } from "./SidebarListFilter"; +import { DoubleRangeInput } from "src/components/Shared/DoubleRangeInput"; +import { useDebounce } from "src/hooks/debounce"; + +interface ISidebarFilter { + title?: React.ReactNode; + option: CriterionOption; + filter: ListFilterModel; + setFilter: (f: ListFilterModel) => void; + sectionID?: string; +} + +// Age presets +const AGE_PRESETS = [ + { id: "18-25", label: "18-25", min: 18, max: 25 }, + { id: "25-35", label: "25-35", min: 25, max: 35 }, + { id: "35-45", label: "35-45", min: 35, max: 45 }, + { id: "45-60", label: "45-60", min: 45, max: 60 }, + { id: "60+", label: "60+", min: 60, max: null }, +]; + +const MAX_AGE = 60; // Maximum age for the slider +const MAX_LABEL = "60+"; // Display label for maximum age + +export const SidebarAgeFilter: React.FC = ({ + title, + option, + filter, + setFilter, + sectionID, +}) => { + const criteria = filter.criteriaFor(option.type) as NumberCriterion[]; + const criterion = criteria.length > 0 ? criteria[0] : null; + + // Get current values from criterion + const currentMin = criterion?.value?.value ?? 18; + const currentMax = criterion?.value?.value2 ?? MAX_AGE; + + const [sliderMin, setSliderMin] = useState(currentMin); + const [sliderMax, setSliderMax] = useState(currentMax); + const [minInput, setMinInput] = useState(currentMin.toString()); + const [maxInput, setMaxInput] = useState( + currentMax >= MAX_AGE ? MAX_LABEL : currentMax.toString() + ); + + // Reset slider when criterion is removed externally (via filter tag X) + useEffect(() => { + if (!criterion) { + setSliderMin(18); + setSliderMax(MAX_AGE); + setMinInput("18"); + setMaxInput(MAX_LABEL); + } + }, [criterion]); + + // Determine which preset is selected + const selectedPreset = useMemo(() => { + if (!criterion) return null; + + // Check if current values match any preset + for (const preset of AGE_PRESETS) { + if (preset.max === null) { + // For "60+" preset + if ( + criterion.modifier === CriterionModifier.GreaterThan && + criterion.value.value === preset.min + ) { + return preset.id; + } + } else { + // For range presets + if ( + criterion.modifier === CriterionModifier.Between && + criterion.value.value === preset.min && + criterion.value.value2 === preset.max + ) { + return preset.id; + } + } + } + + // Check if it's a custom range or custom GreaterThan + if ( + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.GreaterThan + ) { + return "custom"; + } + + return null; + }, [criterion]); + + const options: Option[] = useMemo(() => { + return AGE_PRESETS.map((preset) => ({ + id: preset.id, + label: preset.label, + className: "age-preset", + })); + }, []); + + const selected: Option[] = useMemo(() => { + if (!selectedPreset) return []; + if (selectedPreset === "custom") return []; + + const preset = AGE_PRESETS.find((p) => p.id === selectedPreset); + if (preset) { + return [ + { + id: preset.id, + label: preset.label, + className: "age-preset", + }, + ]; + } + return []; + }, [selectedPreset]); + + function onSelectPreset(item: Option) { + const preset = AGE_PRESETS.find((p) => p.id === item.id); + if (!preset) return; + + setSliderMin(preset.min); + setSliderMax(preset.max ?? MAX_AGE); + setMinInput(preset.min.toString()); + setMaxInput(preset.max === null ? MAX_LABEL : preset.max.toString()); + + const currentCriteria = filter.criteriaFor( + option.type + ) as NumberCriterion[]; + const currentCriterion = + currentCriteria.length > 0 ? currentCriteria[0] : null; + const newCriterion = currentCriterion + ? currentCriterion.clone() + : option.makeCriterion(); + + if (preset.max === null) { + // "60+" - use GreaterThan + newCriterion.modifier = CriterionModifier.GreaterThan; + newCriterion.value.value = preset.min; + newCriterion.value.value2 = undefined; + } else { + // Range preset - use Between + newCriterion.modifier = CriterionModifier.Between; + newCriterion.value.value = preset.min; + newCriterion.value.value2 = preset.max; + } + + setFilter(filter.replaceCriteria(option.type, [newCriterion])); + } + + function onUnselectPreset() { + setSliderMin(18); + setSliderMax(MAX_AGE); + setMinInput("18"); + setMaxInput(MAX_LABEL); + setFilter(filter.removeCriterion(option.type)); + } + + // Parse age input (supports formats like "25", "100+") + function parseAgeInput(input: string): number | null { + const trimmed = input.trim().toLowerCase(); + + if (trimmed === "max" || trimmed === MAX_LABEL.toLowerCase()) { + return MAX_AGE; + } + + const age = parseInt(trimmed); + if (isNaN(age) || age < 18 || age > MAX_AGE) { + return null; + } + + return age; + } + + // Filter update + function updateFilter(min: number, max: number) { + // If slider is at full range (18 to max), remove the filter entirely + if (min === 18 && max >= MAX_AGE) { + setFilter(filter.removeCriterion(option.type)); + return; + } + + const currentCriteria = filter.criteriaFor( + option.type + ) as NumberCriterion[]; + const currentCriterion = + currentCriteria.length > 0 ? currentCriteria[0] : null; + const newCriterion = currentCriterion + ? currentCriterion.clone() + : option.makeCriterion(); + + // If max is at MAX_AGE (but min > 18), use GreaterThan + if (max >= MAX_AGE) { + newCriterion.modifier = CriterionModifier.GreaterThan; + newCriterion.value.value = min; + newCriterion.value.value2 = undefined; + } else { + newCriterion.modifier = CriterionModifier.Between; + newCriterion.value.value = min; + newCriterion.value.value2 = max; + } + + setFilter(filter.replaceCriteria(option.type, [newCriterion])); + } + + const updateFilterDebounceMS = 300; + const debounceUpdateFilter = useDebounce( + updateFilter, + updateFilterDebounceMS + ); + + function handleSliderChange(min: number, max: number) { + setSliderMin(min); + setSliderMax(max); + setMinInput(min.toString()); + setMaxInput(max >= MAX_AGE ? MAX_LABEL : max.toString()); + + debounceUpdateFilter(min, max); + } + + function handleMinInputChange(value: string) { + setMinInput(value); + } + + function handleMaxInputChange(value: string) { + setMaxInput(value); + } + + function handleMinInputBlur() { + const parsed = parseAgeInput(minInput); + if (parsed !== null && parsed >= 18 && parsed < sliderMax) { + handleSliderChange(parsed, sliderMax); + } else { + // Reset to current value if invalid + setMinInput(sliderMin.toString()); + } + } + + function handleMaxInputBlur() { + const parsed = parseAgeInput(maxInput); + if (parsed !== null && parsed > sliderMin && parsed <= MAX_AGE) { + handleSliderChange(sliderMin, parsed); + } else { + // Reset to current value if invalid + setMaxInput(sliderMax >= MAX_AGE ? MAX_LABEL : sliderMax.toString()); + } + } + + const customSlider = ( +
+ handleSliderChange(min, max)} + minInput={ + handleMinInputChange(e.target.value)} + onBlur={handleMinInputBlur} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + placeholder="18" + /> + } + maxInput={ + handleMaxInputChange(e.target.value)} + onBlur={handleMaxInputBlur} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + placeholder={MAX_LABEL} + /> + } + /> +
+ ); + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 1b5b4c6e1..e1397a48e 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -1402,12 +1402,14 @@ input[type="range"].zoom-slider { } // Duration slider styles -.duration-slider { +.duration-slider, +.age-slider-container { padding: 0.5rem 0 1rem; width: 100%; } -.duration-label-input { +.duration-label-input, +.age-label-input { background: transparent; border: 1px solid transparent; border-radius: 0.25rem; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 14baf7188..729cea05a 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -55,7 +55,11 @@ import { RatingCriterionOption } from "src/models/list-filter/criteria/rating"; import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; -import { DurationCriterionOption } from "src/models/list-filter/scenes"; +import { + DurationCriterionOption, + PerformerAgeCriterionOption, +} from "src/models/list-filter/scenes"; +import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter"; import { SidebarDurationFilter } from "../List/Filters/SidebarDurationFilter"; import { FilteredSidebarHeader, @@ -337,6 +341,13 @@ const SidebarContent: React.FC<{ setFilter={setFilter} sectionID="organized" /> + } + option={PerformerAgeCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="performer_age" + />
diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 8eaa3b90a..2da24774b 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -965,7 +965,7 @@ $sticky-header-height: calc(50px + 3.3rem); margin-bottom: 0.5rem; padding: 0 0.25rem; - .duration-label-input { + input[type="text"] { &:first-child { text-align: left; } diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 09c60e483..b8dd6515a 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -79,6 +79,9 @@ const displayModeOptions = [ DisplayMode.Tagger, ]; +export const PerformerAgeCriterionOption = + createMandatoryNumberCriterionOption("performer_age"); + export const DurationCriterionOption = createDurationCriterionOption("duration"); @@ -113,7 +116,7 @@ const criterionOptions = [ PerformerTagsCriterionOption, PerformersCriterionOption, createMandatoryNumberCriterionOption("performer_count"), - createMandatoryNumberCriterionOption("performer_age"), + PerformerAgeCriterionOption, PerformerFavoriteCriterionOption, // StudioTagsCriterionOption, StudiosCriterionOption,