mirror of
https://github.com/stashapp/stash.git
synced 2025-12-09 01:44:52 +01:00
Show filter tags in scene list toolbar (#5969)
* Add filter tags to toolbar * Show overflow control if filter tags overflow * Remove second set of filter tags from top of page * Add border around filter area
This commit is contained in:
parent
f01f95ddfb
commit
d98e9c6618
6 changed files with 273 additions and 25 deletions
|
|
@ -1,11 +1,18 @@
|
||||||
import React, { PropsWithChildren } from "react";
|
import React, {
|
||||||
import { Badge, BadgeProps, Button } from "react-bootstrap";
|
PropsWithChildren,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import { Badge, BadgeProps, Button, Overlay, Popover } from "react-bootstrap";
|
||||||
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { Icon } from "../Shared/Icon";
|
import { Icon } from "../Shared/Icon";
|
||||||
import { faTimes } from "@fortawesome/free-solid-svg-icons";
|
import { faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { BsPrefixProps, ReplaceProps } from "react-bootstrap/esm/helpers";
|
import { BsPrefixProps, ReplaceProps } from "react-bootstrap/esm/helpers";
|
||||||
import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields";
|
import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields";
|
||||||
|
import { useDebounce } from "src/hooks/debounce";
|
||||||
|
|
||||||
type TagItemProps = PropsWithChildren<
|
type TagItemProps = PropsWithChildren<
|
||||||
ReplaceProps<"span", BsPrefixProps<"span"> & BadgeProps>
|
ReplaceProps<"span", BsPrefixProps<"span"> & BadgeProps>
|
||||||
|
|
@ -41,11 +48,59 @@ export const FilterTag: React.FC<{
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MoreFilterTags: React.FC<{
|
||||||
|
tags: React.ReactNode[];
|
||||||
|
}> = ({ tags }) => {
|
||||||
|
const [showTooltip, setShowTooltip] = React.useState(false);
|
||||||
|
const target = useRef(null);
|
||||||
|
|
||||||
|
if (!tags.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseEnter() {
|
||||||
|
setShowTooltip(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
setShowTooltip(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Overlay target={target.current} placement="bottom" show={showTooltip}>
|
||||||
|
<Popover
|
||||||
|
id="more-criteria-popover"
|
||||||
|
className="hover-popover-content"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onClick={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{tags}
|
||||||
|
</Popover>
|
||||||
|
</Overlay>
|
||||||
|
<Badge
|
||||||
|
ref={target}
|
||||||
|
className={"tag-item more-tags"}
|
||||||
|
variant="secondary"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="search_filter.more_filter_criteria"
|
||||||
|
values={{ count: tags.length }}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface IFilterTagsProps {
|
interface IFilterTagsProps {
|
||||||
criteria: Criterion[];
|
criteria: Criterion[];
|
||||||
onEditCriterion: (c: Criterion) => void;
|
onEditCriterion: (c: Criterion) => void;
|
||||||
onRemoveCriterion: (c: Criterion, valueIndex?: number) => void;
|
onRemoveCriterion: (c: Criterion, valueIndex?: number) => void;
|
||||||
onRemoveAll: () => void;
|
onRemoveAll: () => void;
|
||||||
|
truncateOnOverflow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FilterTags: React.FC<IFilterTagsProps> = ({
|
export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||||
|
|
@ -53,8 +108,117 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||||
onEditCriterion,
|
onEditCriterion,
|
||||||
onRemoveCriterion,
|
onRemoveCriterion,
|
||||||
onRemoveAll,
|
onRemoveAll,
|
||||||
|
truncateOnOverflow = false,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [cutoff, setCutoff] = React.useState<number | undefined>();
|
||||||
|
const elementGap = 10; // Adjust this value based on your CSS gap or margin
|
||||||
|
const moreTagWidth = 80; // reserve space for the "more" tag
|
||||||
|
|
||||||
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
|
const debounceResetCutoff = useDebounce(
|
||||||
|
() => {
|
||||||
|
setCutoff(undefined);
|
||||||
|
// setting cutoff won't trigger a re-render if it's already undefined
|
||||||
|
// so we force a re-render to recalculate the cutoff
|
||||||
|
forceUpdate();
|
||||||
|
},
|
||||||
|
100 // Adjust the debounce delay as needed
|
||||||
|
);
|
||||||
|
|
||||||
|
// trigger recalculation of cutoff when control resizes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!truncateOnOverflow || !ref.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
debounceResetCutoff();
|
||||||
|
});
|
||||||
|
|
||||||
|
const { current } = ref;
|
||||||
|
resizeObserver.observe(current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [truncateOnOverflow, debounceResetCutoff]);
|
||||||
|
|
||||||
|
// we need to check this on every render, and the call to setCutoff _should_ be safe
|
||||||
|
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!truncateOnOverflow) {
|
||||||
|
setCutoff(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current } = ref;
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
// calculate the number of tags that can fit in the container
|
||||||
|
const containerWidth = current.clientWidth;
|
||||||
|
const children = Array.from(current.children);
|
||||||
|
|
||||||
|
// don't recalculate anything if the more tag is visible and cutoff is already set
|
||||||
|
const moreTags = children.find((child) => {
|
||||||
|
return (child as HTMLElement).classList.contains("more-tags");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (moreTags && !!cutoff) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childTags = children.filter((child) => {
|
||||||
|
return (
|
||||||
|
(child as HTMLElement).classList.contains("tag-item") ||
|
||||||
|
(child as HTMLElement).classList.contains("clear-all-button")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearAllButton = children.find((child) => {
|
||||||
|
return (child as HTMLElement).classList.contains("clear-all-button");
|
||||||
|
});
|
||||||
|
|
||||||
|
// calculate the total width without the more tag
|
||||||
|
const defaultTotalWidth = childTags.reduce((total, child, idx) => {
|
||||||
|
return (
|
||||||
|
total +
|
||||||
|
((child as HTMLElement).offsetWidth ?? 0) +
|
||||||
|
(idx === childTags.length - 1 ? 0 : elementGap)
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
if (containerWidth >= defaultTotalWidth) {
|
||||||
|
// if the container is wide enough to fit all tags, reset cutoff
|
||||||
|
setCutoff(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalWidth = 0;
|
||||||
|
let visibleCount = 0;
|
||||||
|
|
||||||
|
// reserve space for the more tags control
|
||||||
|
totalWidth += moreTagWidth;
|
||||||
|
|
||||||
|
// reserve space for the clear all button if present
|
||||||
|
if (clearAllButton) {
|
||||||
|
totalWidth += (clearAllButton as HTMLElement).offsetWidth ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
totalWidth += ((child as HTMLElement).offsetWidth ?? 0) + elementGap;
|
||||||
|
if (totalWidth > containerWidth) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
visibleCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCutoff(visibleCount);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function onRemoveCriterionTag(
|
function onRemoveCriterionTag(
|
||||||
criterion: Criterion,
|
criterion: Criterion,
|
||||||
|
|
@ -72,7 +236,7 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||||
onEditCriterion(criterion);
|
onEditCriterion(criterion);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFilterTags(criterion: Criterion) {
|
function getFilterTags(criterion: Criterion) {
|
||||||
if (
|
if (
|
||||||
criterion instanceof CustomFieldsCriterion &&
|
criterion instanceof CustomFieldsCriterion &&
|
||||||
criterion.value.length > 1
|
criterion.value.length > 1
|
||||||
|
|
@ -105,9 +269,34 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const className = "wrap-tags filter-tags";
|
||||||
|
|
||||||
|
const filterTags = criteria.map((c) => getFilterTags(c)).flat();
|
||||||
|
|
||||||
|
if (cutoff && filterTags.length > cutoff) {
|
||||||
|
const visibleCriteria = filterTags.slice(0, cutoff);
|
||||||
|
const hiddenCriteria = filterTags.slice(cutoff);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} ref={ref}>
|
||||||
|
{visibleCriteria}
|
||||||
|
<MoreFilterTags tags={hiddenCriteria} />
|
||||||
|
{criteria.length >= 3 && (
|
||||||
|
<Button
|
||||||
|
variant="minimal"
|
||||||
|
className="clear-all-button"
|
||||||
|
onClick={() => onRemoveAll()}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="actions.clear" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="wrap-tags filter-tags">
|
<div className={className} ref={ref}>
|
||||||
{criteria.map(renderFilterTags)}
|
{filterTags}
|
||||||
{criteria.length >= 3 && (
|
{criteria.length >= 3 && (
|
||||||
<Button
|
<Button
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
|
|
|
||||||
|
|
@ -416,13 +416,18 @@ input[type="range"].zoom-slider {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
|
||||||
|
|
||||||
.filter-tags .clear-all-button {
|
.more-tags {
|
||||||
color: $text-color;
|
background-color: transparent;
|
||||||
// to match filter pills
|
color: #fff;
|
||||||
line-height: 16px;
|
}
|
||||||
padding: 0;
|
|
||||||
|
.clear-all-button {
|
||||||
|
color: $text-color;
|
||||||
|
// to match filter pills
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-button {
|
.filter-button {
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ import { FilterButton } from "../List/Filters/FilterButton";
|
||||||
import { Icon } from "../Shared/Icon";
|
import { Icon } from "../Shared/Icon";
|
||||||
import { ListViewOptions } from "../List/ListViewOptions";
|
import { ListViewOptions } from "../List/ListViewOptions";
|
||||||
import { PageSizeSelector, SortBySelect } from "../List/ListFilter";
|
import { PageSizeSelector, SortBySelect } from "../List/ListFilter";
|
||||||
|
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||||
|
|
||||||
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
|
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
|
||||||
const duration = result?.data?.findScenes?.duration;
|
const duration = result?.data?.findScenes?.duration;
|
||||||
|
|
@ -316,11 +317,14 @@ interface IOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ListToolbarContent: React.FC<{
|
const ListToolbarContent: React.FC<{
|
||||||
criteriaCount: number;
|
criteria: Criterion[];
|
||||||
items: GQL.SlimSceneDataFragment[];
|
items: GQL.SlimSceneDataFragment[];
|
||||||
selectedIds: Set<string>;
|
selectedIds: Set<string>;
|
||||||
operations: IOperations[];
|
operations: IOperations[];
|
||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
|
onEditCriterion: (c: Criterion) => void;
|
||||||
|
onRemoveCriterion: (criterion: Criterion, valueIndex?: number) => void;
|
||||||
|
onRemoveAllCriterion: () => void;
|
||||||
onSelectAll: () => void;
|
onSelectAll: () => void;
|
||||||
onSelectNone: () => void;
|
onSelectNone: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
|
@ -328,11 +332,14 @@ const ListToolbarContent: React.FC<{
|
||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
onCreateNew: () => void;
|
onCreateNew: () => void;
|
||||||
}> = ({
|
}> = ({
|
||||||
criteriaCount,
|
criteria,
|
||||||
items,
|
items,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
operations,
|
operations,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
|
onEditCriterion,
|
||||||
|
onRemoveCriterion,
|
||||||
|
onRemoveAllCriterion,
|
||||||
onSelectAll,
|
onSelectAll,
|
||||||
onSelectNone,
|
onSelectNone,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
|
@ -350,9 +357,16 @@ const ListToolbarContent: React.FC<{
|
||||||
<div>
|
<div>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
onClick={() => onToggleSidebar()}
|
onClick={() => onToggleSidebar()}
|
||||||
count={criteriaCount}
|
count={criteria.length}
|
||||||
title={intl.formatMessage({ id: "actions.sidebar.toggle" })}
|
title={intl.formatMessage({ id: "actions.sidebar.toggle" })}
|
||||||
/>
|
/>
|
||||||
|
<FilterTags
|
||||||
|
criteria={criteria}
|
||||||
|
onEditCriterion={onEditCriterion}
|
||||||
|
onRemoveCriterion={onRemoveCriterion}
|
||||||
|
onRemoveAll={onRemoveAllCriterion}
|
||||||
|
truncateOnOverflow
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasSelection && (
|
{hasSelection && (
|
||||||
|
|
@ -730,11 +744,14 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ListToolbarContent
|
<ListToolbarContent
|
||||||
criteriaCount={filter.count()}
|
criteria={filter.criteria}
|
||||||
items={items}
|
items={items}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
operations={otherOperations}
|
operations={otherOperations}
|
||||||
onToggleSidebar={() => setShowSidebar(!showSidebar)}
|
onToggleSidebar={() => setShowSidebar(!showSidebar)}
|
||||||
|
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
||||||
|
onRemoveCriterion={removeCriterion}
|
||||||
|
onRemoveAllCriterion={() => clearAllCriteria()}
|
||||||
onSelectAll={() => onSelectAll()}
|
onSelectAll={() => onSelectAll()}
|
||||||
onSelectNone={() => onSelectNone()}
|
onSelectNone={() => onSelectNone()}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
|
|
@ -752,13 +769,6 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||||
onChangeFilter={(newFilter) => setFilter(newFilter)}
|
onChangeFilter={(newFilter) => setFilter(newFilter)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterTags
|
|
||||||
criteria={filter.criteria}
|
|
||||||
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
|
||||||
onRemoveCriterion={removeCriterion}
|
|
||||||
onRemoveAll={() => clearAllCriteria()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LoadedContent loading={result.loading} error={result.error}>
|
<LoadedContent loading={result.loading} error={result.error}>
|
||||||
<SceneList
|
<SceneList
|
||||||
filter={effectiveFilter}
|
filter={effectiveFilter}
|
||||||
|
|
|
||||||
|
|
@ -1020,13 +1020,13 @@ input[type="range"].blue-slider {
|
||||||
&:last-child {
|
&:last-child {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-list-toolbar {
|
.scene-list-toolbar {
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
|
gap: 1rem;
|
||||||
// offset the main padding
|
// offset the main padding
|
||||||
margin-top: -0.5rem;
|
margin-top: -0.5rem;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
|
|
@ -1062,6 +1062,35 @@ input[type="range"].blue-slider {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> div:first-child {
|
||||||
|
border: 1px solid $secondary;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
.filter-button {
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tags {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: 0;
|
||||||
|
width: calc(100% - 35px - 0.5rem);
|
||||||
|
|
||||||
|
@include media-breakpoint-down(xs) {
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-item {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-list-header {
|
.scene-list-header {
|
||||||
|
|
@ -1092,3 +1121,9 @@ input[type="range"].blue-slider {
|
||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#more-criteria-popover {
|
||||||
|
box-shadow: 0 8px 10px 2px rgb(0 0 0 / 30%);
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -788,6 +788,14 @@ $sidebar-width: 250px;
|
||||||
margin-left: -$sidebar-width;
|
margin-left: -$sidebar-width;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.hide-sidebar .sidebar + div {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.hide-sidebar) .sidebar + div {
|
||||||
|
width: calc(100% - $sidebar-width);
|
||||||
|
}
|
||||||
|
|
||||||
> :nth-child(2) {
|
> :nth-child(2) {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
|
|
|
||||||
|
|
@ -1317,7 +1317,8 @@
|
||||||
"edit_filter": "Edit Filter",
|
"edit_filter": "Edit Filter",
|
||||||
"name": "Filter",
|
"name": "Filter",
|
||||||
"saved_filters": "Saved filters",
|
"saved_filters": "Saved filters",
|
||||||
"update_filter": "Update Filter"
|
"update_filter": "Update Filter",
|
||||||
|
"more_filter_criteria": "+{count} more"
|
||||||
},
|
},
|
||||||
"second": "Second",
|
"second": "Second",
|
||||||
"seconds": "Seconds",
|
"seconds": "Seconds",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue