mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
Improve studio/tag/performer filtering (#3619)
* Support excludes field * Refactor studio filter * Refactor tags filter * Support excludes in tags --------- Co-authored-by: Kermie <kermie@isinthe.house>
This commit is contained in:
@@ -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<IGenericCriterionEditor> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (criterion instanceof PerformersCriterion) {
|
||||
return (
|
||||
<PerformersFilter
|
||||
criterion={criterion}
|
||||
setCriterion={(c) => setCriterion(c)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (criterion instanceof StudiosCriterion) {
|
||||
return (
|
||||
<StudiosFilter
|
||||
criterion={criterion}
|
||||
setCriterion={(c) => setCriterion(c)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (criterion instanceof TagsCriterion) {
|
||||
return (
|
||||
<TagsFilter
|
||||
criterion={criterion}
|
||||
setCriterion={(c) => setCriterion(c)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (criterion instanceof ILabeledIdCriterion) {
|
||||
return (
|
||||
<LabeledIdFilter
|
||||
|
||||
44
ui/v2.5/src/components/List/Filters/PerformersFilter.tsx
Normal file
44
ui/v2.5/src/components/List/Filters/PerformersFilter.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
|
||||
import { useFindPerformersQuery } from "src/core/generated-graphql";
|
||||
import { ObjectsFilter } from "./SelectableFilter";
|
||||
|
||||
interface IPerformersFilter {
|
||||
criterion: PerformersCriterion;
|
||||
setCriterion: (c: PerformersCriterion) => 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<IPerformersFilter> = ({
|
||||
criterion,
|
||||
setCriterion,
|
||||
}) => {
|
||||
return (
|
||||
<ObjectsFilter
|
||||
criterion={criterion}
|
||||
setCriterion={setCriterion}
|
||||
queryHook={usePerformerQuery}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformersFilter;
|
||||
342
ui/v2.5/src/components/List/Filters/SelectableFilter.tsx
Normal file
342
ui/v2.5/src/components/List/Filters/SelectableFilter.tsx
Normal file
@@ -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<ISelectedItem> = ({
|
||||
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 (
|
||||
<a
|
||||
onClick={() => onClick()}
|
||||
onKeyDown={keyboardClickHandler(onClick)}
|
||||
onMouseEnter={() => onMouseOver()}
|
||||
onMouseLeave={() => onMouseOut()}
|
||||
onFocus={() => onMouseOver()}
|
||||
onBlur={() => onMouseOut()}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div>
|
||||
<Icon className={`fa-fw ${iconClassName}`} icon={icon} />
|
||||
<span className={spanClassName}>{item.label}</span>
|
||||
</div>
|
||||
<div></div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
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<ISelectableFilter> = ({
|
||||
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 = <Icon className="fa-fw include-button" icon={faPlus} />;
|
||||
const excludeIcon = <Icon className="fa-fw exclude-icon" icon={faMinus} />;
|
||||
|
||||
return (
|
||||
<div className="selectable-filter">
|
||||
<ClearableInput
|
||||
value={internalQuery}
|
||||
setValue={(v) => onInternalInputChange(v)}
|
||||
/>
|
||||
<ul>
|
||||
{selected.map((p) => (
|
||||
<li key={p.id} className="selected-object">
|
||||
<SelectedItem item={p} onClick={() => onUnselect(p)} />
|
||||
</li>
|
||||
))}
|
||||
{excluded.map((p) => (
|
||||
<li key={p.id} className="excluded-object">
|
||||
<SelectedItem item={p} excluded onClick={() => onUnselect(p)} />
|
||||
</li>
|
||||
))}
|
||||
{objects.map((p) => (
|
||||
<li key={p.id} className="unselected-object">
|
||||
{/* if excluding only, clicking on an item also excludes it */}
|
||||
<a
|
||||
onClick={() => onSelect(p, !excludingOnly)}
|
||||
onKeyDown={keyboardClickHandler(() =>
|
||||
onSelect(p, !excludingOnly)
|
||||
)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div>
|
||||
{!excludingOnly ? includeIcon : excludeIcon}
|
||||
<span>{p.label}</span>
|
||||
</div>
|
||||
<div>
|
||||
{/* TODO item count */}
|
||||
{/* <span className="object-count">{p.id}</span> */}
|
||||
{!includingOnly && !excludingOnly && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(p, false);
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="minimal exclude-button"
|
||||
>
|
||||
<span className="exclude-button-text">exclude</span>
|
||||
{excludeIcon}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IObjectsFilter<T extends Criterion<ILabeledValueListValue>> {
|
||||
criterion: T;
|
||||
single?: boolean;
|
||||
setCriterion: (criterion: T) => void;
|
||||
queryHook: (query: string) => ILabeledId[];
|
||||
}
|
||||
|
||||
export const ObjectsFilter = <
|
||||
T extends Criterion<ILabeledValueListValue | IHierarchicalLabelValue>
|
||||
>(
|
||||
props: IObjectsFilter<T>
|
||||
) => {
|
||||
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 (
|
||||
<SelectableFilter
|
||||
single={single}
|
||||
includeOnly={criterion.modifier === CriterionModifier.Equals}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
selected={sortedSelected}
|
||||
queryResults={queryResults}
|
||||
onSelect={onSelect}
|
||||
onUnselect={onUnselect}
|
||||
excluded={sortedExcluded}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IHierarchicalObjectsFilter<T extends IHierarchicalLabeledIdCriterion>
|
||||
extends IObjectsFilter<T> {}
|
||||
|
||||
export const HierarchicalObjectsFilter = <
|
||||
T extends IHierarchicalLabeledIdCriterion
|
||||
>(
|
||||
props: IHierarchicalObjectsFilter<T>
|
||||
) => {
|
||||
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 (
|
||||
<Form>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id={criterionOptionTypeToIncludeID()}
|
||||
checked={criterion.value.depth !== 0}
|
||||
label={intl.formatMessage(criterionOptionTypeToIncludeUIString())}
|
||||
onChange={() => onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
{criterion.value.depth !== 0 && (
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
className="btn-secondary"
|
||||
type="number"
|
||||
placeholder={intl.formatMessage(messages.studio_depth)}
|
||||
onChange={(e) =>
|
||||
onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1)
|
||||
}
|
||||
defaultValue={
|
||||
criterion.value && criterion.value.depth !== -1
|
||||
? criterion.value.depth
|
||||
: ""
|
||||
}
|
||||
min="1"
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
<ObjectsFilter {...props} />
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
44
ui/v2.5/src/components/List/Filters/StudiosFilter.tsx
Normal file
44
ui/v2.5/src/components/List/Filters/StudiosFilter.tsx
Normal file
@@ -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<IStudiosFilter> = ({
|
||||
criterion,
|
||||
setCriterion,
|
||||
}) => {
|
||||
return (
|
||||
<HierarchicalObjectsFilter
|
||||
criterion={criterion}
|
||||
setCriterion={setCriterion}
|
||||
queryHook={useStudioQuery}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudiosFilter;
|
||||
41
ui/v2.5/src/components/List/Filters/TagsFilter.tsx
Normal file
41
ui/v2.5/src/components/List/Filters/TagsFilter.tsx
Normal file
@@ -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<ITagsFilter> = ({ criterion, setCriterion }) => {
|
||||
return (
|
||||
<HierarchicalObjectsFilter
|
||||
criterion={criterion}
|
||||
setCriterion={setCriterion}
|
||||
queryHook={useStudioQuery}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagsFilter;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user