Refactor filtered list toolbar (#6317)

* Refactor list operation buttons into a single button group
* Refactor ListFilter into FilteredListToolbar and restyle
* Move zoom keybinds out of zoom control
* Use button group for display mode select
* Hide zoom slider on xs devices

(cherry picked from commit d6a2953371)
This commit is contained in:
WithoutPants 2025-11-25 17:36:13 +11:00
parent b8263535ea
commit b3d84187a3
16 changed files with 210 additions and 242 deletions

View file

@ -195,7 +195,6 @@ export const GalleryList: React.FC<IGalleryList> = ({
selectable
>
<ItemList
zoomable
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}

View file

@ -226,7 +226,6 @@ export const GroupList: React.FC<IGroupList> = ({
selectable={selectable}
>
<ItemList
zoomable
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}

View file

@ -464,7 +464,6 @@ export const ImageList: React.FC<IImageList> = ({
selectable
>
<ItemList
zoomable
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}

View file

@ -2,8 +2,8 @@ import React from "react";
import { QueryResult } from "@apollo/client";
import { ListFilterModel } from "src/models/list-filter/filter";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { ListFilter } from "./ListFilter";
import { ListViewOptions } from "./ListViewOptions";
import { PageSizeSelector, SearchTermInput, SortBySelect } from "./ListFilter";
import { ListViewButtonGroup } from "./ListViewOptions";
import {
IListFilterOperation,
ListOperationButtons,
@ -11,6 +11,8 @@ import {
import { ButtonGroup, ButtonToolbar } from "react-bootstrap";
import { View } from "./views";
import { IListSelect, useFilterOperations } from "./util";
import { SavedFilterDropdown } from "./SavedFilterList";
import { FilterButton } from "./Filters/FilterButton";
export interface IItemListOperation<T extends QueryResult> {
text: string;
@ -63,34 +65,47 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
return (
<ButtonToolbar className="filtered-list-toolbar">
<SearchTermInput filter={filter} onFilterUpdate={setFilter} />
<ButtonGroup>
{showEditFilter && (
<ListFilter
onFilterUpdate={setFilter}
filter={filter}
openFilterDialog={() => showEditFilter()}
view={view}
/>
)}
<ListOperationButtons
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
otherOperations={operations}
itemsSelected={selectedIds.size > 0}
onEdit={onEdit}
onDelete={onDelete}
<SavedFilterDropdown
filter={filter}
onSetFilter={setFilter}
view={view}
/>
<ButtonGroup>
<ListViewOptions
displayMode={filter.displayMode}
displayModeOptions={filterOptions.displayModeOptions}
onSetDisplayMode={setDisplayMode}
zoomIndex={zoomable ? filter.zoomIndex : undefined}
onSetZoom={zoomable ? setZoom : undefined}
/>
</ButtonGroup>
<FilterButton onClick={() => showEditFilter()} count={filter.count()} />
</ButtonGroup>
<ButtonGroup></ButtonGroup>
<SortBySelect
sortBy={filter.sortBy}
sortDirection={filter.sortDirection}
options={filterOptions.sortByOptions}
onChangeSortBy={(e) => setFilter(filter.setSortBy(e ?? undefined))}
onChangeSortDirection={() => setFilter(filter.toggleSortDirection())}
onReshuffleRandomSort={() => setFilter(filter.reshuffleRandomSort())}
/>
<PageSizeSelector
pageSize={filter.itemsPerPage}
setPageSize={(size) => setFilter(filter.setPageSize(size))}
/>
<ListOperationButtons
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
otherOperations={operations}
itemsSelected={selectedIds.size > 0}
onEdit={onEdit}
onDelete={onDelete}
/>
<ListViewButtonGroup
displayMode={filter.displayMode}
displayModeOptions={filterOptions.displayModeOptions}
onSetDisplayMode={setDisplayMode}
zoomIndex={zoomable ? filter.zoomIndex : undefined}
onSetZoom={zoomable ? setZoom : undefined}
/>
</ButtonToolbar>
);
};

View file

@ -44,6 +44,8 @@ import {
} from "./FilteredListToolbar";
import { PagedList } from "./PagedList";
import { ConfigurationContext } from "src/hooks/Config";
import { useZoomKeybinds } from "./ZoomSlider";
import { DisplayMode } from "src/models/list-filter/types";
interface IFilteredItemList<T extends QueryResult, E extends IHasID = IHasID> {
filterStateProps: IFilterStateHook;
@ -113,7 +115,6 @@ export function useFilteredItemList<
interface IItemListProps<T extends QueryResult, E extends IHasID> {
view?: View;
zoomable?: boolean;
otherOperations?: IItemListOperation<T>[];
renderContent: (
result: T,
@ -145,7 +146,6 @@ export const ItemList = <T extends QueryResult, E extends IHasID>(
) => {
const {
view,
zoomable,
otherOperations,
renderContent,
renderEditDialog,
@ -217,6 +217,15 @@ export const ItemList = <T extends QueryResult, E extends IHasID>(
showEditFilter,
});
const zoomable =
filter.displayMode === DisplayMode.Grid ||
filter.displayMode === DisplayMode.Wall;
useZoomKeybinds({
zoomIndex: zoomable ? filter.zoomIndex : undefined,
onChangeZoom: (zoom) => updateFilter(filter.setZoom(zoom)),
});
useEffect(() => {
if (addKeybinds) {
const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds);

View file

@ -1,4 +1,3 @@
import cloneDeep from "lodash-es/cloneDeep";
import React, {
useCallback,
useEffect,
@ -23,17 +22,14 @@ import {
import { Icon } from "../Shared/Icon";
import { ListFilterModel } from "src/models/list-filter/filter";
import useFocus from "src/utils/focus";
import { FormattedMessage, useIntl } from "react-intl";
import { SavedFilterDropdown } from "./SavedFilterList";
import { useIntl } from "react-intl";
import {
faCaretDown,
faCaretUp,
faCheck,
faRandom,
} from "@fortawesome/free-solid-svg-icons";
import { FilterButton } from "./Filters/FilterButton";
import { useDebounce } from "src/hooks/debounce";
import { View } from "./views";
import { ClearableInput } from "../Shared/ClearableInput";
import { useStopWheelScroll } from "src/utils/form";
import { ISortByOption } from "src/models/list-filter/filter-options";
@ -318,109 +314,3 @@ export const SortBySelect: React.FC<{
</Dropdown>
);
};
interface IListFilterProps {
onFilterUpdate: (newFilter: ListFilterModel) => void;
filter: ListFilterModel;
view?: View;
openFilterDialog: () => void;
}
export const ListFilter: React.FC<IListFilterProps> = ({
onFilterUpdate,
filter,
openFilterDialog,
view,
}) => {
const filterOptions = filter.options;
useEffect(() => {
Mousetrap.bind("r", () => onReshuffleRandomSort());
return () => {
Mousetrap.unbind("r");
};
});
function onChangePageSize(pp: number) {
const newFilter = cloneDeep(filter);
newFilter.itemsPerPage = pp;
newFilter.currentPage = 1;
onFilterUpdate(newFilter);
}
function onChangeSortDirection() {
const newFilter = cloneDeep(filter);
if (filter.sortDirection === SortDirectionEnum.Asc) {
newFilter.sortDirection = SortDirectionEnum.Desc;
} else {
newFilter.sortDirection = SortDirectionEnum.Asc;
}
onFilterUpdate(newFilter);
}
function onChangeSortBy(eventKey: string | null) {
const newFilter = cloneDeep(filter);
newFilter.sortBy = eventKey ?? undefined;
newFilter.currentPage = 1;
onFilterUpdate(newFilter);
}
function onReshuffleRandomSort() {
const newFilter = cloneDeep(filter);
newFilter.currentPage = 1;
newFilter.randomSeed = -1;
onFilterUpdate(newFilter);
}
function render() {
return (
<>
<div className="d-flex">
<SearchTermInput filter={filter} onFilterUpdate={onFilterUpdate} />
</div>
<ButtonGroup className="mr-2">
<SavedFilterDropdown
filter={filter}
onSetFilter={(f) => {
onFilterUpdate(f);
}}
view={view}
/>
<OverlayTrigger
placement="top"
overlay={
<Tooltip id="filter-tooltip">
<FormattedMessage id="search_filter.name" />
</Tooltip>
}
>
<FilterButton
onClick={() => openFilterDialog()}
count={filter.count()}
/>
</OverlayTrigger>
</ButtonGroup>
<SortBySelect
className="mr-2"
sortBy={filter.sortBy}
sortDirection={filter.sortDirection}
options={filterOptions.sortByOptions}
onChangeSortBy={onChangeSortBy}
onChangeSortDirection={onChangeSortDirection}
onReshuffleRandomSort={onReshuffleRandomSort}
/>
<PageSizeSelector
pageSize={filter.itemsPerPage}
setPageSize={onChangePageSize}
/>
</>
);
}
return render();
};

View file

@ -1,11 +1,5 @@
import React, { PropsWithChildren, useEffect } from "react";
import {
Button,
ButtonGroup,
Dropdown,
OverlayTrigger,
Tooltip,
} from "react-bootstrap";
import React, { PropsWithChildren, useEffect, useMemo } from "react";
import { Button, ButtonGroup, Dropdown } from "react-bootstrap";
import Mousetrap from "mousetrap";
import { FormattedMessage, useIntl } from "react-intl";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
@ -108,8 +102,8 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
};
});
function maybeRenderButtons() {
const buttons = (otherOperations ?? []).filter((o) => {
const buttons = useMemo(() => {
const ret = (otherOperations ?? []).filter((o) => {
if (!o.icon) {
return false;
}
@ -120,16 +114,17 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
return o.isDisplayed();
});
if (itemsSelected) {
if (onEdit) {
buttons.push({
ret.push({
icon: faPencilAlt,
text: intl.formatMessage({ id: "actions.edit" }),
onClick: onEdit,
});
}
if (onDelete) {
buttons.push({
ret.push({
icon: faTrash,
text: intl.formatMessage({ id: "actions.delete" }),
onClick: onDelete,
@ -138,58 +133,57 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
}
}
if (buttons.length > 0) {
return (
<ButtonGroup className="ml-2">
{buttons.map((button) => {
return (
<OverlayTrigger
overlay={<Tooltip id="edit">{button.text}</Tooltip>}
key={button.text}
>
<Button
variant={button.buttonVariant ?? "secondary"}
onClick={button.onClick}
>
{button.icon ? <Icon icon={button.icon} /> : undefined}
</Button>
</OverlayTrigger>
);
})}
</ButtonGroup>
);
}
}
return ret;
}, [otherOperations, itemsSelected, onEdit, onDelete, intl]);
function renderSelectAll() {
if (onSelectAll) {
return (
<Dropdown.Item
key="select-all"
className="bg-secondary text-white"
onClick={() => onSelectAll?.()}
>
<FormattedMessage id="actions.select_all" />
</Dropdown.Item>
);
}
}
const operationButtons = useMemo(() => {
return (
<>
{buttons.map((button) => {
return (
<Button
key={button.text}
variant={button.buttonVariant ?? "secondary"}
onClick={button.onClick}
title={button.text}
>
<Icon icon={button.icon!} />
</Button>
);
})}
</>
);
}, [buttons]);
function renderSelectNone() {
if (onSelectNone) {
return (
<Dropdown.Item
key="select-none"
className="bg-secondary text-white"
onClick={() => onSelectNone?.()}
>
<FormattedMessage id="actions.select_none" />
</Dropdown.Item>
);
const moreDropdown = useMemo(() => {
function renderSelectAll() {
if (onSelectAll) {
return (
<Dropdown.Item
key="select-all"
className="bg-secondary text-white"
onClick={() => onSelectAll?.()}
>
<FormattedMessage id="actions.select_all" />
</Dropdown.Item>
);
}
}
function renderSelectNone() {
if (onSelectNone) {
return (
<Dropdown.Item
key="select-none"
className="bg-secondary text-white"
onClick={() => onSelectNone?.()}
>
<FormattedMessage id="actions.select_none" />
</Dropdown.Item>
);
}
}
}
function renderMore() {
const options = [renderSelectAll(), renderSelectNone()].filter((o) => o);
if (otherOperations) {
@ -224,13 +218,19 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
{options.length > 0 ? options : undefined}
</OperationDropdown>
);
}, [otherOperations, onSelectAll, onSelectNone]);
// don't render anything if there are no buttons or operations
if (buttons.length === 0 && !moreDropdown) {
return null;
}
return (
<>
{maybeRenderButtons()}
<ButtonGroup className="ml-2">{renderMore()}</ButtonGroup>
<ButtonGroup>
{operationButtons}
{moreDropdown}
</ButtonGroup>
</>
);
};

View file

@ -1,8 +1,16 @@
import React, { useEffect, useRef, useState } from "react";
import Mousetrap from "mousetrap";
import { Button, Dropdown, Overlay, Popover } from "react-bootstrap";
import {
Button,
ButtonGroup,
Dropdown,
Overlay,
OverlayTrigger,
Popover,
Tooltip,
} from "react-bootstrap";
import { DisplayMode } from "src/models/list-filter/types";
import { useIntl } from "react-intl";
import { IntlShape, useIntl } from "react-intl";
import { Icon } from "../Shared/Icon";
import {
faChevronDown,
@ -53,6 +61,10 @@ function getLabelId(option: DisplayMode) {
return `display_mode.${displayModeId}`;
}
function getLabel(intl: IntlShape, option: DisplayMode) {
return intl.formatMessage({ id: getLabelId(option) });
}
export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
zoomIndex,
onSetZoom,
@ -60,9 +72,6 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
onSetDisplayMode,
displayModeOptions,
}) => {
const minZoom = 0;
const maxZoom = 3;
const intl = useIntl();
const overlayTarget = useRef(null);
@ -98,10 +107,6 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
};
});
function getLabel(option: DisplayMode) {
return intl.formatMessage({ id: getLabelId(option) });
}
function onChangeZoom(v: number) {
if (onSetZoom) {
onSetZoom(v);
@ -116,7 +121,7 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
variant="secondary"
title={intl.formatMessage(
{ id: "display_mode.label_current" },
{ current: getLabel(displayMode) }
{ current: getLabel(intl, displayMode) }
)}
onClick={() => setShowOptions(!showOptions)}
>
@ -140,8 +145,6 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
displayMode === DisplayMode.Wall) ? (
<div className="zoom-slider-container">
<ZoomSelect
minZoom={minZoom}
maxZoom={maxZoom}
zoomIndex={zoomIndex}
onChangeZoom={onChangeZoom}
/>
@ -156,7 +159,7 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
onSetDisplayMode(option);
}}
>
<Icon icon={getIcon(option)} /> {getLabel(option)}
<Icon icon={getIcon(option)} /> {getLabel(intl, option)}
</Dropdown.Item>
))}
</div>
@ -167,3 +170,48 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
</>
);
};
export const ListViewButtonGroup: React.FC<IListViewOptionsProps> = ({
zoomIndex,
onSetZoom,
displayMode,
onSetDisplayMode,
displayModeOptions,
}) => {
const intl = useIntl();
return (
<>
{displayModeOptions.length > 1 && (
<ButtonGroup>
{displayModeOptions.map((option) => (
<OverlayTrigger
key={option}
overlay={
<Tooltip id="display-mode-tooltip">
{getLabel(intl, option)}
</Tooltip>
}
>
<Button
variant="secondary"
active={displayMode === option}
onClick={() => onSetDisplayMode(option)}
>
<Icon icon={getIcon(option)} />
</Button>
</OverlayTrigger>
))}
</ButtonGroup>
)}
<div className="zoom-slider-container">
{onSetZoom &&
zoomIndex !== undefined &&
(displayMode === DisplayMode.Grid ||
displayMode === DisplayMode.Wall) ? (
<ZoomSelect zoomIndex={zoomIndex} onChangeZoom={onSetZoom} />
) : null}
</div>
</>
);
};

View file

@ -2,19 +2,14 @@ import React, { useEffect } from "react";
import Mousetrap from "mousetrap";
import { Form } from "react-bootstrap";
export interface IZoomSelectProps {
minZoom: number;
maxZoom: number;
zoomIndex: number;
onChangeZoom: (v: number) => void;
}
const minZoom = 0;
const maxZoom = 3;
export const ZoomSelect: React.FC<IZoomSelectProps> = ({
minZoom,
maxZoom,
zoomIndex,
onChangeZoom,
}) => {
export function useZoomKeybinds(props: {
zoomIndex: number | undefined;
onChangeZoom: (v: number) => void;
}) {
const { zoomIndex, onChangeZoom } = props;
useEffect(() => {
Mousetrap.bind("+", () => {
if (zoomIndex !== undefined && zoomIndex < maxZoom) {
@ -32,7 +27,17 @@ export const ZoomSelect: React.FC<IZoomSelectProps> = ({
Mousetrap.unbind("-");
};
});
}
export interface IZoomSelectProps {
zoomIndex: number;
onChangeZoom: (v: number) => void;
}
export const ZoomSelect: React.FC<IZoomSelectProps> = ({
zoomIndex,
onChangeZoom,
}) => {
return (
<Form.Control
className="zoom-slider"

View file

@ -93,7 +93,8 @@
// hide zoom slider in xs viewport
@include media-breakpoint-down(xs) {
.display-mode-menu .zoom-slider-container {
.display-mode-menu .zoom-slider-container,
.zoom-slider-container {
display: none;
}
}
@ -916,6 +917,8 @@ input[type="range"].zoom-slider {
}
.filtered-list-toolbar {
align-items: center;
gap: 0.5rem;
justify-content: center;
margin-bottom: 0.5rem;
@ -933,8 +936,10 @@ input[type="range"].zoom-slider {
}
}
.btn.display-mode-select {
margin-left: 0.5rem;
// set the width of the zoom-slider-container to prevent buttons moving when
// the slider appears/disappears
.zoom-slider-container {
min-width: 60px;
}
}
@ -946,10 +951,6 @@ input[type="range"].zoom-slider {
}
}
.search-term-input {
margin-right: 0.5rem;
}
.custom-field-filter {
align-items: center;
display: flex;

View file

@ -329,7 +329,6 @@ export const PerformerList: React.FC<IPerformerList> = ({
selectable
>
<ItemList
zoomable
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}

View file

@ -70,6 +70,7 @@ import {
ToolbarSelectionSection,
} from "../List/ListToolbar";
import { ListResultsHeader } from "../List/ListResultsHeader";
import { useZoomKeybinds } from "../List/ZoomSlider";
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
const duration = result?.data?.findScenes?.duration;
@ -519,6 +520,10 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
Mousetrap.unbind("d d");
};
});
useZoomKeybinds({
zoomIndex: filter.zoomIndex,
onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)),
});
const onCloseEditDelete = useCloseEditDelete({
closeModal,

View file

@ -138,7 +138,6 @@ export const SceneMarkerList: React.FC<ISceneMarkerList> = ({
selectable
>
<ItemList
zoomable
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}

View file

@ -188,7 +188,6 @@ export const StudioList: React.FC<IStudioList> = ({
selectable
>
<ItemList
zoomable
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}

View file

@ -367,7 +367,6 @@ export const TagList: React.FC<ITagList> = ({ filterHook, alterQuery }) => {
>
<ItemList
view={view}
zoomable
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}

View file

@ -5,6 +5,7 @@
### 🎨 Improvements
* **[0.29.4]** Restored display mode button group to non-scene list pages. ([#6317](https://github.com/stashapp/stash/pull/6317))
* **[0.29.4]** Added keyboard shortcut for tagger view (`v t`). ([#6261](https://github.com/stashapp/stash/pull/6261))
* **[0.29.2]** Returned saved filters button to the top toolbar in the Scene list. ([#6215](https://github.com/stashapp/stash/pull/6215))
* **[0.29.2]** Top pagination can now be optionally shown in the scene list with [custom css](https://github.com/stashapp/stash/pull/6234#issue-3593190476). ([#6234](https://github.com/stashapp/stash/pull/6234))
@ -34,6 +35,7 @@
* Include IP address in login errors in log. ([#5760](https://github.com/stashapp/stash/pull/5760))
### 🐛 Bug fixes
* **[0.29.4]** Fixed zoom keyboard shortcuts not working. ([#6317](https://github.com/stashapp/stash/pull/6317))
* **[0.29.4]** Fixed existing match stash ID sometimes not being displayed in the performer scrape dialog. ([#6257](https://github.com/stashapp/stash/pull/6257))
* **[0.29.4]** Fixed inline videos showing as full-screen on iPhone devices. ([#6259](https://github.com/stashapp/stash/pull/6259))
* **[0.29.4]** Fixed download backup function not working when generated directory is on a different filesystem. ([#6244](https://github.com/stashapp/stash/pull/6244))