mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +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 { Badge, BadgeProps, Button } from "react-bootstrap";
|
||||
import React, {
|
||||
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 { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import { BsPrefixProps, ReplaceProps } from "react-bootstrap/esm/helpers";
|
||||
import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields";
|
||||
import { useDebounce } from "src/hooks/debounce";
|
||||
|
||||
type TagItemProps = PropsWithChildren<
|
||||
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 {
|
||||
criteria: Criterion[];
|
||||
onEditCriterion: (c: Criterion) => void;
|
||||
onRemoveCriterion: (c: Criterion, valueIndex?: number) => void;
|
||||
onRemoveAll: () => void;
|
||||
truncateOnOverflow?: boolean;
|
||||
}
|
||||
|
||||
export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||
|
|
@ -53,8 +108,117 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
|||
onEditCriterion,
|
||||
onRemoveCriterion,
|
||||
onRemoveAll,
|
||||
truncateOnOverflow = false,
|
||||
}) => {
|
||||
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(
|
||||
criterion: Criterion,
|
||||
|
|
@ -72,7 +236,7 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
|||
onEditCriterion(criterion);
|
||||
}
|
||||
|
||||
function renderFilterTags(criterion: Criterion) {
|
||||
function getFilterTags(criterion: Criterion) {
|
||||
if (
|
||||
criterion instanceof CustomFieldsCriterion &&
|
||||
criterion.value.length > 1
|
||||
|
|
@ -105,9 +269,34 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
|||
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="wrap-tags filter-tags">
|
||||
{criteria.map(renderFilterTags)}
|
||||
<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 (
|
||||
<div className={className} ref={ref}>
|
||||
{filterTags}
|
||||
{criteria.length >= 3 && (
|
||||
<Button
|
||||
variant="minimal"
|
||||
|
|
|
|||
|
|
@ -416,13 +416,18 @@ input[type="range"].zoom-slider {
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-tags .clear-all-button {
|
||||
.more-tags {
|
||||
background-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.clear-all-button {
|
||||
color: $text-color;
|
||||
// to match filter pills
|
||||
line-height: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import { FilterButton } from "../List/Filters/FilterButton";
|
|||
import { Icon } from "../Shared/Icon";
|
||||
import { ListViewOptions } from "../List/ListViewOptions";
|
||||
import { PageSizeSelector, SortBySelect } from "../List/ListFilter";
|
||||
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||
|
||||
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
|
||||
const duration = result?.data?.findScenes?.duration;
|
||||
|
|
@ -316,11 +317,14 @@ interface IOperations {
|
|||
}
|
||||
|
||||
const ListToolbarContent: React.FC<{
|
||||
criteriaCount: number;
|
||||
criteria: Criterion[];
|
||||
items: GQL.SlimSceneDataFragment[];
|
||||
selectedIds: Set<string>;
|
||||
operations: IOperations[];
|
||||
onToggleSidebar: () => void;
|
||||
onEditCriterion: (c: Criterion) => void;
|
||||
onRemoveCriterion: (criterion: Criterion, valueIndex?: number) => void;
|
||||
onRemoveAllCriterion: () => void;
|
||||
onSelectAll: () => void;
|
||||
onSelectNone: () => void;
|
||||
onEdit: () => void;
|
||||
|
|
@ -328,11 +332,14 @@ const ListToolbarContent: React.FC<{
|
|||
onPlay: () => void;
|
||||
onCreateNew: () => void;
|
||||
}> = ({
|
||||
criteriaCount,
|
||||
criteria,
|
||||
items,
|
||||
selectedIds,
|
||||
operations,
|
||||
onToggleSidebar,
|
||||
onEditCriterion,
|
||||
onRemoveCriterion,
|
||||
onRemoveAllCriterion,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
onEdit,
|
||||
|
|
@ -350,9 +357,16 @@ const ListToolbarContent: React.FC<{
|
|||
<div>
|
||||
<FilterButton
|
||||
onClick={() => onToggleSidebar()}
|
||||
count={criteriaCount}
|
||||
count={criteria.length}
|
||||
title={intl.formatMessage({ id: "actions.sidebar.toggle" })}
|
||||
/>
|
||||
<FilterTags
|
||||
criteria={criteria}
|
||||
onEditCriterion={onEditCriterion}
|
||||
onRemoveCriterion={onRemoveCriterion}
|
||||
onRemoveAll={onRemoveAllCriterion}
|
||||
truncateOnOverflow
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasSelection && (
|
||||
|
|
@ -730,11 +744,14 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
|||
})}
|
||||
>
|
||||
<ListToolbarContent
|
||||
criteriaCount={filter.count()}
|
||||
criteria={filter.criteria}
|
||||
items={items}
|
||||
selectedIds={selectedIds}
|
||||
operations={otherOperations}
|
||||
onToggleSidebar={() => setShowSidebar(!showSidebar)}
|
||||
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
||||
onRemoveCriterion={removeCriterion}
|
||||
onRemoveAllCriterion={() => clearAllCriteria()}
|
||||
onSelectAll={() => onSelectAll()}
|
||||
onSelectNone={() => onSelectNone()}
|
||||
onEdit={onEdit}
|
||||
|
|
@ -752,13 +769,6 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
|||
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}>
|
||||
<SceneList
|
||||
filter={effectiveFilter}
|
||||
|
|
|
|||
|
|
@ -1020,13 +1020,13 @@ input[type="range"].blue-slider {
|
|||
&:last-child {
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scene-list-toolbar {
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
gap: 1rem;
|
||||
// offset the main padding
|
||||
margin-top: -0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
|
|
@ -1062,6 +1062,35 @@ input[type="range"].blue-slider {
|
|||
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 {
|
||||
|
|
@ -1092,3 +1121,9 @@ input[type="range"].blue-slider {
|
|||
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;
|
||||
}
|
||||
|
||||
&.hide-sidebar .sidebar + div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:not(.hide-sidebar) .sidebar + div {
|
||||
width: calc(100% - $sidebar-width);
|
||||
}
|
||||
|
||||
> :nth-child(2) {
|
||||
flex-grow: 1;
|
||||
padding-left: 0.5rem;
|
||||
|
|
|
|||
|
|
@ -1317,7 +1317,8 @@
|
|||
"edit_filter": "Edit Filter",
|
||||
"name": "Filter",
|
||||
"saved_filters": "Saved filters",
|
||||
"update_filter": "Update Filter"
|
||||
"update_filter": "Update Filter",
|
||||
"more_filter_criteria": "+{count} more"
|
||||
},
|
||||
"second": "Second",
|
||||
"seconds": "Seconds",
|
||||
|
|
|
|||
Loading…
Reference in a new issue