diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 0b18cbfee..55724cc42 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -518,6 +518,7 @@ input FloatCriterionInput { input MultiCriterionInput { value: [ID!] modifier: CriterionModifier! + excludes: [ID!] } input GenderCriterionInput { @@ -534,6 +535,7 @@ input HierarchicalMultiCriterionInput { value: [ID!] modifier: CriterionModifier! depth: Int + excludes: [ID!] } input DateCriterionInput { diff --git a/pkg/models/filter.go b/pkg/models/filter.go index 42cff1118..e0f9b7a54 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -132,11 +132,13 @@ type HierarchicalMultiCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` Depth *int `json:"depth"` + Excludes []string `json:"excludes"` } type MultiCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` + Excludes []string `json:"excludes"` } type DateCriterionInput struct { diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index d0c74772d..d670dc1a7 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -629,9 +629,12 @@ type joinedMultiCriterionHandlerBuilder struct { addJoinTable func(f *filterBuilder) } -func (m *joinedMultiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc { +func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { + if c != nil { + // make local copy so we can modify it + criterion := *c + joinAlias := m.joinAs if joinAlias == "" { joinAlias = m.joinTable @@ -653,37 +656,68 @@ func (m *joinedMultiCriterionHandlerBuilder) handler(criterion *models.MultiCrit return } - if len(criterion.Value) == 0 { + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { return } - var args []interface{} - for _, tagID := range criterion.Value { - args = append(args, tagID) + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil } - whereClause := "" - havingClause := "" + if len(criterion.Value) > 0 { + whereClause := "" + havingClause := "" + + var args []interface{} + for _, tagID := range criterion.Value { + args = append(args, tagID) + } + + switch criterion.Modifier { + case models.CriterionModifierIncludes: + // includes any of the provided ids + m.addJoinTable(f) + whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) + case models.CriterionModifierEquals: + // includes only the provided ids + m.addJoinTable(f) + whereClause = utils.StrFormat("{joinAlias}.{foreignFK} IN {inBinding} AND (SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ + "joinAlias": joinAlias, + "foreignFK": m.foreignFK, + "inBinding": getInBinding(len(criterion.Value)), + "joinTable": m.joinTable, + "primaryFK": m.primaryFK, + "primaryTable": m.primaryTable, + }) + havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) + args = append(args, len(criterion.Value)) + case models.CriterionModifierIncludesAll: + // includes all of the provided ids + m.addJoinTable(f) + whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) + havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) + } + + f.addWhere(whereClause, args...) + f.addHaving(havingClause) + } + + if len(criterion.Excludes) > 0 { + var args []interface{} + for _, tagID := range criterion.Excludes { + args = append(args, tagID) + } - switch criterion.Modifier { - case models.CriterionModifierIncludes: - // includes any of the provided ids - m.addJoinTable(f) - whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) - case models.CriterionModifierIncludesAll: - // includes all of the provided ids - m.addJoinTable(f) - whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) - havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) - case models.CriterionModifierExcludes: // excludes all of the provided ids // need to use actual join table name for this // .id NOT IN (select . from where . in ) - whereClause = fmt.Sprintf("%[1]s.id NOT IN (SELECT %[3]s.%[2]s from %[3]s where %[3]s.%[4]s in %[5]s)", m.primaryTable, m.primaryFK, m.joinTable, m.foreignFK, getInBinding(len(criterion.Value))) - } + whereClause := fmt.Sprintf("%[1]s.id NOT IN (SELECT %[3]s.%[2]s from %[3]s where %[3]s.%[4]s in %[5]s)", m.primaryTable, m.primaryFK, m.joinTable, m.foreignFK, getInBinding(len(criterion.Excludes))) - f.addWhere(whereClause, args...) - f.addHaving(havingClause) + f.addWhere(whereClause, args...) + } } } } @@ -890,7 +924,7 @@ WHERE id in {inBinding} return valuesClause } -func addHierarchicalConditionClauses(f *filterBuilder, criterion *models.HierarchicalMultiCriterionInput, table, idColumn string) { +func addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { switch criterion.Modifier { case models.CriterionModifierIncludes: f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) @@ -902,9 +936,12 @@ func addHierarchicalConditionClauses(f *filterBuilder, criterion *models.Hierarc } } -func (m *hierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { + if c != nil { + // make a copy so we don't modify the original + criterion := *c + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string if criterion.Modifier == models.CriterionModifierNotNull { @@ -919,19 +956,32 @@ func (m *hierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.Hie return } - if len(criterion.Value) == 0 { + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { return } - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) > 0 { + valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + + switch criterion.Modifier { + case models.CriterionModifierIncludes: + f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) + case models.CriterionModifierIncludesAll: + f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) + f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", m.primaryTable, m.foreignFK, len(criterion.Value))) + } + } + + if len(criterion.Excludes) > 0 { + valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) - switch criterion.Modifier { - case models.CriterionModifierIncludes: - f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) - case models.CriterionModifierIncludesAll: - f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) - f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", m.primaryTable, m.foreignFK, len(criterion.Value))) - case models.CriterionModifierExcludes: f.addWhere(fmt.Sprintf("%s.%s NOT IN (SELECT column2 FROM (%s)) OR %[1]s.%[2]s IS NULL", m.primaryTable, m.foreignFK, valuesClause)) } } @@ -953,9 +1003,26 @@ type joinedHierarchicalMultiCriterionHandlerBuilder struct { primaryFK string } -func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func (m *joinedHierarchicalMultiCriterionHandlerBuilder) addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { + if criterion.Modifier == models.CriterionModifierEquals { + // includes only the provided ids + f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) + f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) + f.addWhere(utils.StrFormat("(SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ + "joinTable": m.joinTable, + "primaryFK": m.primaryFK, + "primaryTable": m.primaryTable, + }), len(criterion.Value)) + } else { + addHierarchicalConditionClauses(f, criterion, table, idColumn) + } +} + +func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { + if c != nil { + // make a copy so we don't modify the original + criterion := *c joinAlias := m.joinAs if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { @@ -974,25 +1041,59 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(criterion *mode return } - if len(criterion.Value) == 0 { + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { return } - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + if len(criterion.Value) > 0 { + valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) - joinTable := utils.StrFormat(`( - SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j - INNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2 -) -`, utils.StrFormatMap{ - "joinTable": m.joinTable, - "foreignFK": m.foreignFK, - "valuesClause": valuesClause, - }) + joinTable := utils.StrFormat(`( + SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j + INNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2 + ) + `, utils.StrFormatMap{ + "joinTable": m.joinTable, + "foreignFK": m.foreignFK, + "valuesClause": valuesClause, + }) - f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable)) + f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable)) - addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id") + m.addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id") + } + + if len(criterion.Excludes) > 0 { + valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + + joinTable := utils.StrFormat(`( + SELECT j2.*, e.column1 AS root_id, e.column2 AS item_id FROM {joinTable} AS j2 + INNER JOIN ({valuesClause}) AS e ON j2.{foreignFK} = e.column2 + ) + `, utils.StrFormatMap{ + "joinTable": m.joinTable, + "foreignFK": m.foreignFK, + "valuesClause": valuesClause, + }) + + joinAlias2 := joinAlias + "2" + + f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.id", joinAlias2, m.primaryFK, m.primaryTable)) + + // modify for exclusion + criterionCopy := criterion + criterionCopy.Modifier = models.CriterionModifierExcludes + criterionCopy.Value = c.Excludes + + m.addHierarchicalConditionClauses(f, criterionCopy, joinAlias2, "root_id") + } } } } diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index de840b283..5f5291053 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -1011,7 +1011,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id f.addLeftJoin("performer_tags", "", "performer_tags.gallery_id = galleries.id") - addHierarchicalConditionClauses(f, tags, "performer_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") } } } diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index f22cacf92..d42de9f85 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -989,7 +989,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id f.addLeftJoin("performer_tags", "", "performer_tags.image_id = images.id") - addHierarchicalConditionClauses(f, tags, "performer_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") } } } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 721a4d456..1a735bcd2 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -1404,7 +1404,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id f.addLeftJoin("performer_tags", "", "performer_tags.scene_id = scenes.id") - addHierarchicalConditionClauses(f, tags, "performer_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") } } } diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index df3c73030..c4ae7dda7 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -221,7 +221,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = m.primary_tag_id f.addLeftJoin("marker_tags", "", "marker_tags.scene_marker_id = scene_markers.id") - addHierarchicalConditionClauses(f, tags, "marker_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "marker_tags", "root_tag_id") } } } @@ -254,7 +254,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = st.tag_id f.addLeftJoin("scene_tags", "", "scene_tags.scene_id = scene_markers.scene_id") - addHierarchicalConditionClauses(f, tags, "scene_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "scene_tags", "root_tag_id") } } } diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index c25f3b267..22f7bde1c 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -518,7 +518,7 @@ func tagParentsCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMu f.addLeftJoin("parents", "", "parents.item_id = tags.id") - addHierarchicalConditionClauses(f, tags, "parents", "root_id") + addHierarchicalConditionClauses(f, *tags, "parents", "root_id") } } } @@ -567,7 +567,7 @@ func tagChildrenCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalM f.addLeftJoin("children", "", "children.item_id = tags.id") - addHierarchicalConditionClauses(f, tags, "children", "root_id") + addHierarchicalConditionClauses(f, *tags, "children", "root_id") } } } diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index dd099cacd..fdf5bcad7 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -38,6 +38,12 @@ import { RatingFilter } from "./Filters/RatingFilter"; import { BooleanFilter } from "./Filters/BooleanFilter"; import { OptionFilter, OptionListFilter } from "./Filters/OptionFilter"; import { PathFilter } from "./Filters/PathFilter"; +import { PerformersCriterion } from "src/models/list-filter/criteria/performers"; +import PerformersFilter from "./Filters/PerformersFilter"; +import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; +import StudiosFilter from "./Filters/StudiosFilter"; +import { TagsCriterion } from "src/models/list-filter/criteria/tags"; +import TagsFilter from "./Filters/TagsFilter"; import { PhashCriterion } from "src/models/list-filter/criteria/phash"; import { PhashFilter } from "./Filters/PhashFilter"; import cx from "classnames"; @@ -110,6 +116,33 @@ const GenericCriterionEditor: React.FC = ({ return; } + if (criterion instanceof PerformersCriterion) { + return ( + setCriterion(c)} + /> + ); + } + + if (criterion instanceof StudiosCriterion) { + return ( + setCriterion(c)} + /> + ); + } + + if (criterion instanceof TagsCriterion) { + return ( + setCriterion(c)} + /> + ); + } + if (criterion instanceof ILabeledIdCriterion) { return ( void; +} + +function usePerformerQuery(query: string) { + const results = useFindPerformersQuery({ + variables: { + filter: { + q: query, + per_page: 200, + }, + }, + }); + + return ( + results.data?.findPerformers.performers.map((p) => { + return { + id: p.id, + label: p.name, + }; + }) ?? [] + ); +} + +const PerformersFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + return ( + + ); +}; + +export default PerformersFilter; diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx new file mode 100644 index 000000000..d14997ef6 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -0,0 +1,342 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { Button, Form } from "react-bootstrap"; +import { Icon } from "src/components/Shared/Icon"; +import { + faCheckCircle, + faMinus, + faPlus, + faTimesCircle, +} from "@fortawesome/free-solid-svg-icons"; +import { faTimesCircle as faTimesCircleRegular } from "@fortawesome/free-regular-svg-icons"; +import { ClearableInput } from "src/components/Shared/ClearableInput"; +import { + IHierarchicalLabelValue, + ILabeledId, + ILabeledValueListValue, +} from "src/models/list-filter/types"; +import { cloneDeep, debounce } from "lodash-es"; +import { + Criterion, + IHierarchicalLabeledIdCriterion, +} from "src/models/list-filter/criteria/criterion"; +import { defineMessages, MessageDescriptor, useIntl } from "react-intl"; +import { CriterionModifier } from "src/core/generated-graphql"; +import { keyboardClickHandler } from "src/utils/keyboard"; + +interface ISelectedItem { + item: ILabeledId; + excluded?: boolean; + onClick: () => void; +} + +const SelectedItem: React.FC = ({ + item, + excluded = false, + onClick, +}) => { + const iconClassName = excluded ? "exclude-icon" : "include-button"; + const spanClassName = excluded + ? "excluded-object-label" + : "selected-object-label"; + const [hovered, setHovered] = useState(false); + + const icon = useMemo(() => { + if (!hovered) { + return excluded ? faTimesCircle : faCheckCircle; + } + + return faTimesCircleRegular; + }, [hovered, excluded]); + + function onMouseOver() { + setHovered(true); + } + + function onMouseOut() { + setHovered(false); + } + + return ( + onClick()} + onKeyDown={keyboardClickHandler(onClick)} + onMouseEnter={() => onMouseOver()} + onMouseLeave={() => onMouseOut()} + onFocus={() => onMouseOver()} + onBlur={() => onMouseOut()} + tabIndex={0} + > +
+ + {item.label} +
+
+
+ ); +}; + +interface ISelectableFilter { + query: string; + setQuery: (query: string) => void; + single: boolean; + includeOnly: boolean; + queryResults: ILabeledId[]; + selected: ILabeledId[]; + excluded: ILabeledId[]; + onSelect: (value: ILabeledId, include: boolean) => void; + onUnselect: (value: ILabeledId) => void; +} + +const SelectableFilter: React.FC = ({ + query, + setQuery, + single, + queryResults, + selected, + excluded, + includeOnly, + onSelect, + onUnselect, +}) => { + const [internalQuery, setInternalQuery] = useState(query); + + const onInputChange = useMemo(() => { + return debounce((input: string) => { + setQuery(input); + }, 250); + }, [setQuery]); + + function onInternalInputChange(input: string) { + setInternalQuery(input); + onInputChange(input); + } + + const objects = useMemo(() => { + return queryResults.filter( + (p) => + selected.find((s) => s.id === p.id) === undefined && + excluded.find((s) => s.id === p.id) === undefined + ); + }, [queryResults, selected, excluded]); + + const includingOnly = includeOnly || (selected.length > 0 && single); + const excludingOnly = excluded.length > 0 && single; + + const includeIcon = ; + const excludeIcon = ; + + return ( + + ); +}; + +interface IObjectsFilter> { + criterion: T; + single?: boolean; + setCriterion: (criterion: T) => void; + queryHook: (query: string) => ILabeledId[]; +} + +export const ObjectsFilter = < + T extends Criterion +>( + props: IObjectsFilter +) => { + const { criterion, setCriterion, queryHook, single = false } = props; + + const [query, setQuery] = useState(""); + + const queryResults = queryHook(query); + + function onSelect(value: ILabeledId, newInclude: boolean) { + let newCriterion: T = cloneDeep(criterion); + + if (newInclude) { + newCriterion.value.items.push(value); + } else { + if (newCriterion.value.excluded) { + newCriterion.value.excluded.push(value); + } else { + newCriterion.value.excluded = [value]; + } + } + + setCriterion(newCriterion); + } + + const onUnselect = useCallback( + (value: ILabeledId) => { + if (!criterion) return; + + let newCriterion: T = cloneDeep(criterion); + + newCriterion.value.items = criterion.value.items.filter( + (v) => v.id !== value.id + ); + newCriterion.value.excluded = criterion.value.excluded.filter( + (v) => v.id !== value.id + ); + + setCriterion(newCriterion); + }, + [criterion, setCriterion] + ); + + const sortedSelected = useMemo(() => { + const ret = criterion.value.items.slice(); + ret.sort((a, b) => a.label.localeCompare(b.label)); + return ret; + }, [criterion]); + + const sortedExcluded = useMemo(() => { + if (!criterion.value.excluded) return []; + const ret = criterion.value.excluded.slice(); + ret.sort((a, b) => a.label.localeCompare(b.label)); + return ret; + }, [criterion]); + + return ( + + ); +}; + +interface IHierarchicalObjectsFilter + extends IObjectsFilter {} + +export const HierarchicalObjectsFilter = < + T extends IHierarchicalLabeledIdCriterion +>( + props: IHierarchicalObjectsFilter +) => { + const intl = useIntl(); + const { criterion, setCriterion } = props; + + const messages = defineMessages({ + studio_depth: { + id: "studio_depth", + defaultMessage: "Levels (empty for all)", + }, + }); + + function onDepthChanged(depth: number) { + let newCriterion: T = cloneDeep(criterion); + newCriterion.value.depth = depth; + setCriterion(newCriterion); + } + + function criterionOptionTypeToIncludeID(): string { + if (criterion.criterionOption.type === "studios") { + return "include-sub-studios"; + } + if (criterion.criterionOption.type === "childTags") { + return "include-parent-tags"; + } + return "include-sub-tags"; + } + + function criterionOptionTypeToIncludeUIString(): MessageDescriptor { + const optionType = + criterion.criterionOption.type === "studios" + ? "include_sub_studios" + : criterion.criterionOption.type === "childTags" + ? "include_parent_tags" + : "include_sub_tags"; + return { + id: optionType, + }; + } + + return ( +
+ + onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)} + /> + + + {criterion.value.depth !== 0 && ( + + + onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1) + } + defaultValue={ + criterion.value && criterion.value.depth !== -1 + ? criterion.value.depth + : "" + } + min="1" + /> + + )} + + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx new file mode 100644 index 000000000..15d300372 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { useFindStudiosQuery } from "src/core/generated-graphql"; +import { HierarchicalObjectsFilter } from "./SelectableFilter"; +import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; + +interface IStudiosFilter { + criterion: StudiosCriterion; + setCriterion: (c: StudiosCriterion) => void; +} + +function useStudioQuery(query: string) { + const results = useFindStudiosQuery({ + variables: { + filter: { + q: query, + per_page: 200, + }, + }, + }); + + return ( + results.data?.findStudios.studios.map((p) => { + return { + id: p.id, + label: p.name, + }; + }) ?? [] + ); +} + +const StudiosFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + return ( + + ); +}; + +export default StudiosFilter; diff --git a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx new file mode 100644 index 000000000..719bada38 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { useFindTagsQuery } from "src/core/generated-graphql"; +import { HierarchicalObjectsFilter } from "./SelectableFilter"; +import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; + +interface ITagsFilter { + criterion: StudiosCriterion; + setCriterion: (c: StudiosCriterion) => void; +} + +function useStudioQuery(query: string) { + const results = useFindTagsQuery({ + variables: { + filter: { + q: query, + per_page: 200, + }, + }, + }); + + return ( + results.data?.findTags.tags.map((p) => { + return { + id: p.id, + label: p.name, + }; + }) ?? [] + ); +} + +const TagsFilter: React.FC = ({ criterion, setCriterion }) => { + return ( + + ); +}; + +export default TagsFilter; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 8b4c67827..1c6a390f4 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -255,6 +255,107 @@ input[type="range"].zoom-slider { } } +.filter-visible-button { + padding-left: 0.3rem; + padding-right: 0.3rem; + + &:focus:not(.active):not(:hover) { + background: none; + } + + &:focus, + &.active:focus { + box-shadow: none; + } +} + +.selectable-filter ul { + list-style-type: none; + margin-top: 0.5rem; + max-height: 300px; + overflow-y: auto; + // to prevent unnecessary vertical scrollbar + padding-bottom: 0.15rem; + padding-inline-start: 0; + + .unselected-object { + opacity: 0.8; + } + + .selected-object, + .excluded-object, + .unselected-object { + cursor: pointer; + height: 2em; + margin-bottom: 0.25rem; + + a { + align-items: center; + display: flex; + height: 2em; + justify-content: space-between; + outline: none; + + &:hover, + &:focus-visible { + background-color: rgba(138, 155, 168, 0.15); + } + + .selected-object-label, + .excluded-object-label { + font-size: 16px; + } + } + + .include-button { + color: $success; + } + + .exclude-icon { + color: $danger; + } + + .exclude-button { + align-items: center; + display: flex; + margin-left: 0.25rem; + padding-left: 0.25rem; + padding-right: 0.25rem; + + .exclude-button-text { + color: $danger; + display: none; + font-size: 12px; + font-weight: 600; + } + + &:hover { + background-color: inherit; + } + + &:hover .exclude-button-text, + &:focus .exclude-button-text { + display: inline; + } + } + + .object-count { + color: $text-muted; + font-size: 12px; + } + } + + .selected-object:hover, + .selected-object a:focus-visible, + .excluded-object:hover, + .excluded-object a:focus-visible { + .include-button, + .exclude-icon { + color: #fff; + } + } +} + .tilted { transform: rotate(45deg); } diff --git a/ui/v2.5/src/components/Shared/ClearableInput.tsx b/ui/v2.5/src/components/Shared/ClearableInput.tsx new file mode 100644 index 000000000..4275b8ee8 --- /dev/null +++ b/ui/v2.5/src/components/Shared/ClearableInput.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { Button, FormControl } from "react-bootstrap"; +import { faTimes } from "@fortawesome/free-solid-svg-icons"; +import { useIntl } from "react-intl"; +import { Icon } from "./Icon"; +import useFocus from "src/utils/focus"; + +interface IClearableInput { + value: string; + setValue: (value: string) => void; +} + +export const ClearableInput: React.FC = ({ + value, + setValue, +}) => { + const intl = useIntl(); + + const [queryRef, setQueryFocus] = useFocus(); + const queryClearShowing = !!value; + + function onChangeQuery(event: React.FormEvent) { + setValue(event.currentTarget.value); + } + + function onClearQuery() { + setValue(""); + setQueryFocus(); + } + + return ( +
+ + {queryClearShowing && ( + + )} +
+ ); +}; + +export default ClearableInput; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 067f8cf4b..4d166878f 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -414,3 +414,30 @@ div.react-datepicker { #date-picker-portal .react-datepicker-popper { z-index: 1600; } + +.clearable-input-group { + align-items: stretch; + display: flex; + flex-wrap: wrap; + position: relative; +} + +.clearable-text-field, +.clearable-text-field:active, +.clearable-text-field:focus { + background-color: #394b59; + border: 0; + border-color: #394b59; + color: #fff; +} + +.clearable-text-field-clear { + background-color: #394b59; + color: #bfccd6; + font-size: 0.875rem; + margin: 0.375rem 0.75rem; + padding: 0; + position: absolute; + right: 0; + z-index: 4; +} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx index 396b3e790..e13c8b2ec 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx @@ -16,6 +16,7 @@ export const StudioPerformersPanel: React.FC = ({ const studioCriterion = new StudiosCriterion(); studioCriterion.value = { items: [{ id: studio.id!, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx index 37d33ea2c..0713f13d5 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx @@ -43,6 +43,7 @@ export const TagMarkersPanel: React.FC = ({ tagCriterion = new TagsCriterion(TagsCriterionOption); tagCriterion.value = { items: [tagValue], + excluded: [], depth: 0, }; filter.criteria.push(tagCriterion); diff --git a/ui/v2.5/src/core/performers.ts b/ui/v2.5/src/core/performers.ts index e13ac8885..597a0be54 100644 --- a/ui/v2.5/src/core/performers.ts +++ b/ui/v2.5/src/core/performers.ts @@ -22,21 +22,21 @@ export const usePerformerFilterHook = ( ) { // add the performer if not present if ( - !performerCriterion.value.find((p) => { + !performerCriterion.value.items.find((p) => { return p.id === performer.id; }) ) { - performerCriterion.value.push(performerValue); + performerCriterion.value.items.push(performerValue); } } else { // overwrite - performerCriterion.value = [performerValue]; + performerCriterion.value.items = [performerValue]; } performerCriterion.modifier = GQL.CriterionModifier.IncludesAll; } else { performerCriterion = new PerformersCriterion(); - performerCriterion.value = [performerValue]; + performerCriterion.value.items = [performerValue]; performerCriterion.modifier = GQL.CriterionModifier.IncludesAll; filter.criteria.push(performerCriterion); } diff --git a/ui/v2.5/src/core/studios.ts b/ui/v2.5/src/core/studios.ts index ef93f191c..95649c199 100644 --- a/ui/v2.5/src/core/studios.ts +++ b/ui/v2.5/src/core/studios.ts @@ -22,6 +22,7 @@ export const useStudioFilterHook = (studio: GQL.StudioDataFragment) => { studioCriterion = new StudiosCriterion(); studioCriterion.value = { items: [studioValue], + excluded: [], depth: (config?.configuration?.ui as IUIConfig)?.showChildStudioContent ? -1 : 0, diff --git a/ui/v2.5/src/core/tags.ts b/ui/v2.5/src/core/tags.ts index 3ec042c84..d4f6fc1bf 100644 --- a/ui/v2.5/src/core/tags.ts +++ b/ui/v2.5/src/core/tags.ts @@ -42,6 +42,7 @@ export const useTagFilterHook = (tag: GQL.TagDataFragment) => { tagCriterion = new TagsCriterion(TagsCriterionOption); tagCriterion.value = { items: [tagValue], + excluded: [], depth: (config?.configuration?.ui as IUIConfig)?.showChildTagContent ? -1 : 0, diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index d314780e5..d07278676 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -726,6 +726,7 @@ "equals": "is", "excludes": "excludes", "format_string": "{criterion} {modifierString} {valueString}", + "format_string_excludes": "{criterion} {modifierString} {valueString} (excludes {excludedString})", "greater_than": "is greater than", "includes": "includes", "includes_all": "includes all", diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 642fe7336..fdf12995b 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -22,6 +22,7 @@ import { IStashIDValue, IDateValue, ITimestampValue, + ILabeledValueListValue, IPhashDistanceValue, } from "../types"; @@ -31,6 +32,7 @@ export type CriterionValue = | string[] | ILabeledId[] | IHierarchicalLabelValue + | ILabeledValueListValue | INumberValue | IStashIDValue | IDateValue @@ -138,6 +140,10 @@ export abstract class Criterion { return JSON.stringify(encodedCriterion); } + public setValueFromQueryString(v: V) { + this.value = v; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any public apply(outputFilter: Record) { // eslint-disable-next-line no-param-reassign @@ -531,11 +537,21 @@ export class ILabeledIdCriterion extends Criterion { } export class IHierarchicalLabeledIdCriterion extends Criterion { - protected toCriterionInput(): HierarchicalMultiCriterionInput { - return { - value: (this.value.items ?? []).map((v) => v.id), - modifier: this.modifier, - depth: this.value.depth, + constructor(type: CriterionOption) { + const value: IHierarchicalLabelValue = { + items: [], + excluded: [], + depth: 0, + }; + + super(type, value); + } + + public setValueFromQueryString(v: IHierarchicalLabelValue) { + this.value = { + items: v.items || [], + excluded: v.excluded || [], + depth: v.depth || 0, }; } @@ -549,24 +565,62 @@ export class IHierarchicalLabeledIdCriterion extends Criterion 0 ? this.value.depth : "all"})`; } + protected toCriterionInput(): HierarchicalMultiCriterionInput { + let excludes: string[] = []; + if (this.value.excluded) { + excludes = this.value.excluded.map((v) => v.id); + } + return { + value: this.value.items.map((v) => v.id), + excludes: excludes, + modifier: this.modifier, + depth: this.value.depth, + }; + } + public isValid(): boolean { if ( this.modifier === CriterionModifier.IsNull || - this.modifier === CriterionModifier.NotNull + this.modifier === CriterionModifier.NotNull || + this.modifier === CriterionModifier.Equals ) { return true; } - return this.value.items.length > 0; + return ( + this.value.items.length > 0 || + (this.value.excluded && this.value.excluded.length > 0) + ); } - constructor(type: CriterionOption) { - const value: IHierarchicalLabelValue = { - items: [], - depth: 0, - }; + public getLabel(intl: IntlShape): string { + const modifierString = Criterion.getModifierLabel(intl, this.modifier); + let valueString = ""; - super(type, value); + if ( + this.modifier !== CriterionModifier.IsNull && + this.modifier !== CriterionModifier.NotNull + ) { + valueString = this.value.items.map((v) => v.label).join(", "); + } + + let id = "criterion_modifier.format_string"; + let excludedString = ""; + + if (this.value.excluded && this.value.excluded.length > 0) { + id = "criterion_modifier.format_string_excludes"; + excludedString = this.value.excluded.map((v) => v.label).join(", "); + } + + return intl.formatMessage( + { id }, + { + criterion: intl.formatMessage({ id: this.criterionOption.messageID }), + modifierString, + valueString, + excludedString, + } + ); } } diff --git a/ui/v2.5/src/models/list-filter/criteria/performers.ts b/ui/v2.5/src/models/list-filter/criteria/performers.ts index 7b177d939..ef7fba0cb 100644 --- a/ui/v2.5/src/models/list-filter/criteria/performers.ts +++ b/ui/v2.5/src/models/list-filter/criteria/performers.ts @@ -1,14 +1,104 @@ -import { ILabeledIdCriterion, ILabeledIdCriterionOption } from "./criterion"; +/* eslint @typescript-eslint/no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ +import { IntlShape } from "react-intl"; +import { + CriterionModifier, + MultiCriterionInput, +} from "src/core/generated-graphql"; +import { ILabeledId, ILabeledValueListValue } from "../types"; +import { Criterion, CriterionOption } from "./criterion"; -export const PerformersCriterionOption = new ILabeledIdCriterionOption( - "performers", - "performers", - "performers", - true -); +const modifierOptions = [ + CriterionModifier.IncludesAll, + CriterionModifier.Includes, + CriterionModifier.Equals, +]; -export class PerformersCriterion extends ILabeledIdCriterion { +const defaultModifier = CriterionModifier.IncludesAll; + +export const PerformersCriterionOption = new CriterionOption({ + messageID: "performers", + type: "performers", + parameterName: "performers", + modifierOptions, + defaultModifier, +}); + +export class PerformersCriterion extends Criterion { constructor() { - super(PerformersCriterionOption); + super(PerformersCriterionOption, { items: [], excluded: [] }); + } + + public setValueFromQueryString(v: ILabeledId[] | ILabeledValueListValue) { + // #3619 - the format of performer value was changed from an array + // to an object. Check for both formats. + if (Array.isArray(v)) { + this.value = { items: v, excluded: [] }; + } else { + this.value = { + items: v.items || [], + excluded: v.excluded || [], + }; + } + } + + public getLabelValue(_intl: IntlShape): string { + return this.value.items.map((v) => v.label).join(", "); + } + + protected toCriterionInput(): MultiCriterionInput { + let excludes: string[] = []; + if (this.value.excluded) { + excludes = this.value.excluded.map((v) => v.id); + } + return { + value: this.value.items.map((v) => v.id), + excludes: excludes, + modifier: this.modifier, + }; + } + + public isValid(): boolean { + if ( + this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull || + this.modifier === CriterionModifier.Equals + ) { + return true; + } + + return ( + this.value.items.length > 0 || + (this.value.excluded && this.value.excluded.length > 0) + ); + } + + public getLabel(intl: IntlShape): string { + const modifierString = Criterion.getModifierLabel(intl, this.modifier); + let valueString = ""; + + if ( + this.modifier !== CriterionModifier.IsNull && + this.modifier !== CriterionModifier.NotNull + ) { + valueString = this.value.items.map((v) => v.label).join(", "); + } + + let id = "criterion_modifier.format_string"; + let excludedString = ""; + + if (this.value.excluded && this.value.excluded.length > 0) { + id = "criterion_modifier.format_string_excludes"; + excludedString = this.value.excluded.map((v) => v.label).join(", "); + } + + return intl.formatMessage( + { id }, + { + criterion: intl.formatMessage({ id: this.criterionOption.messageID }), + modifierString, + valueString, + excludedString, + } + ); } } diff --git a/ui/v2.5/src/models/list-filter/criteria/studios.ts b/ui/v2.5/src/models/list-filter/criteria/studios.ts index 455921543..a78e96200 100644 --- a/ui/v2.5/src/models/list-filter/criteria/studios.ts +++ b/ui/v2.5/src/models/list-filter/criteria/studios.ts @@ -1,15 +1,22 @@ +import { CriterionModifier } from "src/core/generated-graphql"; import { + CriterionOption, IHierarchicalLabeledIdCriterion, ILabeledIdCriterion, ILabeledIdCriterionOption, } from "./criterion"; -export const StudiosCriterionOption = new ILabeledIdCriterionOption( - "studios", - "studios", - "studios", - false -); +const modifierOptions = [CriterionModifier.Includes]; + +const defaultModifier = CriterionModifier.Includes; + +export const StudiosCriterionOption = new CriterionOption({ + messageID: "studios", + type: "studios", + parameterName: "studios", + modifierOptions, + defaultModifier, +}); export class StudiosCriterion extends IHierarchicalLabeledIdCriterion { constructor() { diff --git a/ui/v2.5/src/models/list-filter/criteria/tags.ts b/ui/v2.5/src/models/list-filter/criteria/tags.ts index f3470beee..7266fcf3d 100644 --- a/ui/v2.5/src/models/list-filter/criteria/tags.ts +++ b/ui/v2.5/src/models/list-filter/criteria/tags.ts @@ -1,37 +1,51 @@ -import { - IHierarchicalLabeledIdCriterion, - ILabeledIdCriterionOption, -} from "./criterion"; +import { CriterionModifier } from "src/core/generated-graphql"; +import { CriterionType } from "../types"; +import { CriterionOption, IHierarchicalLabeledIdCriterion } from "./criterion"; export class TagsCriterion extends IHierarchicalLabeledIdCriterion {} -export const TagsCriterionOption = new ILabeledIdCriterionOption( +class tagsCriterionOption extends CriterionOption { + constructor(messageID: string, value: CriterionType, parameterName: string) { + const modifierOptions = [ + CriterionModifier.Includes, + CriterionModifier.IncludesAll, + CriterionModifier.Equals, + ]; + + let defaultModifier = CriterionModifier.IncludesAll; + + super({ + messageID, + type: value, + parameterName, + modifierOptions, + defaultModifier, + }); + } +} + +export const TagsCriterionOption = new tagsCriterionOption( "tags", "tags", - "tags", - true + "tags" ); -export const SceneTagsCriterionOption = new ILabeledIdCriterionOption( +export const SceneTagsCriterionOption = new tagsCriterionOption( "sceneTags", "sceneTags", - "scene_tags", - true + "scene_tags" ); -export const PerformerTagsCriterionOption = new ILabeledIdCriterionOption( +export const PerformerTagsCriterionOption = new tagsCriterionOption( "performerTags", "performerTags", - "performer_tags", - true + "performer_tags" ); -export const ParentTagsCriterionOption = new ILabeledIdCriterionOption( +export const ParentTagsCriterionOption = new tagsCriterionOption( "parent_tags", "parentTags", - "parents", - true + "parents" ); -export const ChildTagsCriterionOption = new ILabeledIdCriterionOption( +export const ChildTagsCriterionOption = new tagsCriterionOption( "sub_tags", "childTags", - "children", - true + "children" ); diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 726c83b6f..def9ac669 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -131,7 +131,7 @@ export class ListFilterModel { // it's possible that we have unsupported criteria. Just skip if so. if (criterion) { if (encodedCriterion.value !== undefined) { - criterion.value = encodedCriterion.value; + criterion.setValueFromQueryString(encodedCriterion.value); } criterion.modifier = encodedCriterion.modifier; this.criteria.push(criterion); diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 548adc59f..79731eb3b 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -18,8 +18,14 @@ export interface ILabeledValue { value: string; } +export interface ILabeledValueListValue { + items: ILabeledId[]; + excluded: ILabeledId[]; +} + export interface IHierarchicalLabelValue { items: ILabeledId[]; + excluded: ILabeledId[]; depth: number; } diff --git a/ui/v2.5/src/utils/keyboard.ts b/ui/v2.5/src/utils/keyboard.ts new file mode 100644 index 000000000..1f02c55bc --- /dev/null +++ b/ui/v2.5/src/utils/keyboard.ts @@ -0,0 +1,9 @@ +export function keyboardClickHandler(onClick: () => void) { + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" || e.key === " ") { + onClick(); + } + } + + return onKeyDown; +} diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index a1ba4cf33..7693ddb69 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -38,12 +38,12 @@ const makePerformerScenesUrl = ( if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new PerformersCriterion(); - criterion.value = [ + criterion.value.items = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; if (extraPerformer) { - criterion.value.push(extraPerformer); + criterion.value.items.push(extraPerformer); } filter.criteria.push(criterion); @@ -59,12 +59,12 @@ const makePerformerImagesUrl = ( if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Images, undefined); const criterion = new PerformersCriterion(); - criterion.value = [ + criterion.value.items = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; if (extraPerformer) { - criterion.value.push(extraPerformer); + criterion.value.items.push(extraPerformer); } filter.criteria.push(criterion); @@ -80,12 +80,12 @@ const makePerformerGalleriesUrl = ( if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined); const criterion = new PerformersCriterion(); - criterion.value = [ + criterion.value.items = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; if (extraPerformer) { - criterion.value.push(extraPerformer); + criterion.value.items.push(extraPerformer); } filter.criteria.push(criterion); @@ -101,12 +101,12 @@ const makePerformerMoviesUrl = ( if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Movies, undefined); const criterion = new PerformersCriterion(); - criterion.value = [ + criterion.value.items = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; if (extraPerformer) { - criterion.value.push(extraPerformer); + criterion.value.items.push(extraPerformer); } filter.criteria.push(criterion); @@ -131,6 +131,7 @@ const makeStudioScenesUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -143,6 +144,7 @@ const makeStudioImagesUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -155,6 +157,7 @@ const makeStudioGalleriesUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -167,6 +170,7 @@ const makeStudioMoviesUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -179,6 +183,7 @@ const makeStudioPerformersUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -218,6 +223,7 @@ const makeParentTagsUrl = (tag: Partial) => { label: tag.name || `Tag ${tag.id}`, }, ], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -235,6 +241,7 @@ const makeChildTagsUrl = (tag: Partial) => { label: tag.name || `Tag ${tag.id}`, }, ], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -247,6 +254,7 @@ const makeTagScenesUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -259,6 +267,7 @@ const makeTagPerformersUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -271,6 +280,7 @@ const makeTagSceneMarkersUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -283,6 +293,7 @@ const makeTagGalleriesUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -295,6 +306,7 @@ const makeTagImagesUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion);