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:
WithoutPants
2023-05-25 12:03:49 +10:00
committed by GitHub
parent 45e61b9228
commit 62b6457f4e
30 changed files with 1105 additions and 117 deletions

View File

@@ -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

View 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;

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

View 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;

View 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;

View File

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