Scene list toolbar (#5938)

* Add sticky query toolbar to scenes page
* Filter button accept count instead of filter
* Add play button
* Add create button functionality. Remove new scene button from navbar
* Separate toolbar into component
* Separate sort by select component
* Don't show filter tags control if no criteria
* Add utility setter methods to ListFilterModel
* Add results header with display options
* Use css for filter tag styling
* Add className to OperationDropdown and Item
* Increase size of sidebar controls on mobile
This commit is contained in:
WithoutPants 2025-06-26 09:17:22 +10:00 committed by GitHub
parent 27bc6c8fca
commit d0a7b09bf3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 642 additions and 173 deletions

View file

@ -101,8 +101,12 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
);
}
if (criteria.length === 0) {
return null;
}
return (
<div className="d-flex justify-content-center mb-2 wrap-tags filter-tags">
<div className="wrap-tags filter-tags">
{criteria.map(renderFilterTags)}
{criteria.length >= 3 && (
<Button

View file

@ -1,28 +1,32 @@
import React, { useMemo } from "react";
import React from "react";
import { Badge, Button } from "react-bootstrap";
import { ListFilterModel } from "src/models/list-filter/filter";
import { faFilter } from "@fortawesome/free-solid-svg-icons";
import { Icon } from "src/components/Shared/Icon";
import { useIntl } from "react-intl";
interface IFilterButtonProps {
filter: ListFilterModel;
count?: number;
onClick: () => void;
title?: string;
}
export const FilterButton: React.FC<IFilterButtonProps> = ({
filter,
count = 0,
onClick,
title,
}) => {
const intl = useIntl();
const count = useMemo(() => filter.count(), [filter]);
if (!title) {
title = intl.formatMessage({ id: "search_filter.edit_filter" });
}
return (
<Button
variant="secondary"
className="filter-button"
onClick={onClick}
title={intl.formatMessage({ id: "search_filter.edit_filter" })}
title={title}
>
<Icon icon={faFilter} />
{count ? (

View file

@ -1,29 +1,22 @@
import React, { useEffect } from "react";
import { FormattedMessage } from "react-intl";
import { SidebarSection, SidebarToolbar } from "src/components/Shared/Sidebar";
import { SidebarSection } from "src/components/Shared/Sidebar";
import { ListFilterModel } from "src/models/list-filter/filter";
import { FilterButton } from "./FilterButton";
import { SearchTermInput } from "../ListFilter";
import { SidebarSavedFilterList } from "../SavedFilterList";
import { View } from "../views";
import useFocus from "src/utils/focus";
import ScreenUtils from "src/utils/screen";
import Mousetrap from "mousetrap";
export const FilteredSidebarToolbar: React.FC<{
onClose?: () => void;
}> = ({ onClose, children }) => {
return <SidebarToolbar onClose={onClose}>{children}</SidebarToolbar>;
};
import { Button } from "react-bootstrap";
export const FilteredSidebarHeader: React.FC<{
sidebarOpen: boolean;
onClose?: () => void;
showEditFilter: () => void;
filter: ListFilterModel;
setFilter: (filter: ListFilterModel) => void;
view?: View;
}> = ({ sidebarOpen, onClose, showEditFilter, filter, setFilter, view }) => {
}> = ({ sidebarOpen, showEditFilter, filter, setFilter, view }) => {
const focus = useFocus();
const [, setFocus] = focus;
@ -37,15 +30,24 @@ export const FilteredSidebarHeader: React.FC<{
return (
<>
<FilteredSidebarToolbar onClose={onClose} />
<div className="sidebar-search-container">
<SearchTermInput
filter={filter}
onFilterUpdate={setFilter}
focus={focus}
/>
<FilterButton onClick={() => showEditFilter()} filter={filter} />
</div>
<div>
<Button
className="edit-filter-button"
size="sm"
onClick={() => showEditFilter()}
>
<FormattedMessage id="search_filter.edit_filter" />
</Button>
</div>
<SidebarSection
className="sidebar-saved-filters"
text={<FormattedMessage id="search_filter.saved_filters" />}

View file

@ -36,6 +36,7 @@ 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";
export function useDebouncedSearchInput(
filter: ListFilterModel,
@ -230,6 +231,94 @@ export const PageSizeSelector: React.FC<{
);
};
export const SortBySelect: React.FC<{
className?: string;
sortBy: string | undefined;
sortDirection: SortDirectionEnum;
options: ISortByOption[];
onChangeSortBy: (eventKey: string | null) => void;
onChangeSortDirection: () => void;
onReshuffleRandomSort: () => void;
}> = ({
className,
sortBy,
sortDirection,
options,
onChangeSortBy,
onChangeSortDirection,
onReshuffleRandomSort,
}) => {
const intl = useIntl();
const currentSortBy = options.find((o) => o.value === sortBy);
function renderSortByOptions() {
return options
.map((o) => {
return {
message: intl.formatMessage({ id: o.messageID }),
value: o.value,
};
})
.sort((a, b) => a.message.localeCompare(b.message))
.map((option) => (
<Dropdown.Item
onSelect={onChangeSortBy}
key={option.value}
className="bg-secondary text-white"
eventKey={option.value}
>
{option.message}
</Dropdown.Item>
));
}
return (
<Dropdown as={ButtonGroup} className={className}>
<InputGroup.Prepend>
<Dropdown.Toggle variant="secondary">
{currentSortBy
? intl.formatMessage({ id: currentSortBy.messageID })
: ""}
</Dropdown.Toggle>
</InputGroup.Prepend>
<Dropdown.Menu className="bg-secondary text-white">
{renderSortByOptions()}
</Dropdown.Menu>
<OverlayTrigger
overlay={
<Tooltip id="sort-direction-tooltip">
{sortDirection === SortDirectionEnum.Asc
? intl.formatMessage({ id: "ascending" })
: intl.formatMessage({ id: "descending" })}
</Tooltip>
}
>
<Button variant="secondary" onClick={onChangeSortDirection}>
<Icon
icon={
sortDirection === SortDirectionEnum.Asc ? faCaretUp : faCaretDown
}
/>
</Button>
</OverlayTrigger>
{sortBy === "random" && (
<OverlayTrigger
overlay={
<Tooltip id="sort-reshuffle-tooltip">
{intl.formatMessage({ id: "actions.reshuffle" })}
</Tooltip>
}
>
<Button variant="secondary" onClick={onReshuffleRandomSort}>
<Icon icon={faRandom} />
</Button>
</OverlayTrigger>
)}
</Dropdown>
);
};
interface IListFilterProps {
onFilterUpdate: (newFilter: ListFilterModel) => void;
filter: ListFilterModel;
@ -247,8 +336,6 @@ export const ListFilter: React.FC<IListFilterProps> = ({
}) => {
const filterOptions = filter.options;
const intl = useIntl();
useEffect(() => {
Mousetrap.bind("r", () => onReshuffleRandomSort());
@ -289,32 +376,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
onFilterUpdate(newFilter);
}
function renderSortByOptions() {
return filterOptions.sortByOptions
.map((o) => {
return {
message: intl.formatMessage({ id: o.messageID }),
value: o.value,
};
})
.sort((a, b) => a.message.localeCompare(b.message))
.map((option) => (
<Dropdown.Item
onSelect={onChangeSortBy}
key={option.value}
className="bg-secondary text-white"
eventKey={option.value}
>
{option.message}
</Dropdown.Item>
));
}
function render() {
const currentSortBy = filterOptions.sortByOptions.find(
(o) => o.value === filter.sortBy
);
return (
<>
{!withSidebar && (
@ -342,56 +404,21 @@ export const ListFilter: React.FC<IListFilterProps> = ({
>
<FilterButton
onClick={() => openFilterDialog()}
filter={filter}
count={filter.count()}
/>
</OverlayTrigger>
</ButtonGroup>
)}
<Dropdown as={ButtonGroup} className="mr-2">
<InputGroup.Prepend>
<Dropdown.Toggle variant="secondary">
{currentSortBy
? intl.formatMessage({ id: currentSortBy.messageID })
: ""}
</Dropdown.Toggle>
</InputGroup.Prepend>
<Dropdown.Menu className="bg-secondary text-white">
{renderSortByOptions()}
</Dropdown.Menu>
<OverlayTrigger
overlay={
<Tooltip id="sort-direction-tooltip">
{filter.sortDirection === SortDirectionEnum.Asc
? intl.formatMessage({ id: "ascending" })
: intl.formatMessage({ id: "descending" })}
</Tooltip>
}
>
<Button variant="secondary" onClick={onChangeSortDirection}>
<Icon
icon={
filter.sortDirection === SortDirectionEnum.Asc
? faCaretUp
: faCaretDown
}
/>
</Button>
</OverlayTrigger>
{filter.sortBy === "random" && (
<OverlayTrigger
overlay={
<Tooltip id="sort-reshuffle-tooltip">
{intl.formatMessage({ id: "actions.reshuffle" })}
</Tooltip>
}
>
<Button variant="secondary" onClick={onReshuffleRandomSort}>
<Icon icon={faRandom} />
</Button>
</OverlayTrigger>
)}
</Dropdown>
<SortBySelect
className="mr-2"
sortBy={filter.sortBy}
sortDirection={filter.sortDirection}
options={filterOptions.sortByOptions}
onChangeSortBy={onChangeSortBy}
onChangeSortDirection={onChangeSortDirection}
onReshuffleRandomSort={onReshuffleRandomSort}
/>
<PageSizeSelector
pageSize={filter.itemsPerPage}

View file

@ -15,14 +15,17 @@ import {
faPencilAlt,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
import cx from "classnames";
export const OperationDropdown: React.FC<PropsWithChildren<{}>> = ({
children,
}) => {
export const OperationDropdown: React.FC<
PropsWithChildren<{
className?: string;
}>
> = ({ className, children }) => {
if (!children) return null;
return (
<Dropdown as={ButtonGroup}>
<Dropdown className={className} as={ButtonGroup}>
<Dropdown.Toggle variant="secondary" id="more-menu">
<Icon icon={faEllipsisH} />
</Dropdown.Toggle>
@ -33,6 +36,21 @@ export const OperationDropdown: React.FC<PropsWithChildren<{}>> = ({
);
};
export const OperationDropdownItem: React.FC<{
text: string;
onClick: () => void;
className?: string;
}> = ({ text, onClick, className }) => {
return (
<Dropdown.Item
className={cx("bg-secondary text-white", className)}
onClick={onClick}
>
{text}
</Dropdown.Item>
);
};
export interface IListFilterOperation {
text: string;
onClick: () => void;

View file

@ -412,6 +412,12 @@ input[type="range"].zoom-slider {
}
}
.filter-tags {
display: flex;
justify-content: center;
margin-bottom: 0.5rem;
}
.filter-tags .clear-all-button {
color: $text-color;
// to match filter pills
@ -929,25 +935,49 @@ input[type="range"].zoom-slider {
}
.sidebar {
// make controls slightly larger on mobile
@include media-breakpoint-down(xs) {
.btn,
.form-control {
font-size: 1.25rem;
}
}
.sidebar-search-container {
display: flex;
margin-bottom: 0.5rem;
margin-top: 0.25rem;
}
.search-term-input {
flex-grow: 1;
margin-right: 0.25rem;
margin-right: 0;
.clearable-text-field {
height: 100%;
}
}
.edit-filter-button {
width: 100%;
}
.sidebar-footer {
background-color: $body-bg;
bottom: 0;
display: none;
padding: 0.5rem;
position: sticky;
@include media-breakpoint-down(xs) {
display: flex;
justify-content: center;
}
}
}
@include media-breakpoint-down(xs) {
.sidebar .search-term-input {
margin-right: 0.5rem;
.sidebar .sidebar-search-container {
margin-top: 0.25rem;
}
}

View file

@ -103,7 +103,6 @@ const allMenuItems: IMenuItem[] = [
href: "/scenes",
icon: faPlayCircle,
hotkey: "g s",
userCreatable: true,
},
{
name: "images",

View file

@ -19,7 +19,13 @@ import { SceneCardsGrid } from "./SceneCardsGrid";
import { TaggerContext } from "../Tagger/context";
import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog";
import { ConfigurationContext } from "src/hooks/Config";
import { faPlay } from "@fortawesome/free-solid-svg-icons";
import {
faPencil,
faPlay,
faPlus,
faTimes,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
import { SceneMergeModal } from "./SceneMergeDialog";
import { objectTitle } from "src/core/files";
import TextUtils from "src/utils/text";
@ -27,8 +33,10 @@ import { View } from "../List/views";
import { FileSize } from "../Shared/FileSize";
import { LoadedContent } from "../List/PagedList";
import { useCloseEditDelete, useFilterOperations } from "../List/util";
import { IListFilterOperation } from "../List/ListOperationButtons";
import { FilteredListToolbar } from "../List/FilteredListToolbar";
import {
OperationDropdown,
OperationDropdownItem,
} from "../List/ListOperationButtons";
import { useFilteredItemList } from "../List/ItemList";
import { FilterTags } from "../List/FilterTags";
import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar";
@ -49,6 +57,11 @@ import {
} from "../List/Filters/FilterSidebar";
import { PatchContainerComponent } from "src/patch";
import { Pagination, PaginationIndex } from "../List/Pagination";
import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap";
import { FilterButton } from "../List/Filters/FilterButton";
import { Icon } from "../Shared/Icon";
import { ListViewOptions } from "../List/ListViewOptions";
import { PageSizeSelector, SortBySelect } from "../List/ListFilter";
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
const duration = result?.data?.findScenes?.duration;
@ -82,33 +95,51 @@ function renderMetadataByline(result: GQL.FindScenesQueryResult) {
function usePlayScene() {
const history = useHistory();
const { configuration: config } = useContext(ConfigurationContext);
const cont = config?.interface.continuePlaylistDefault ?? false;
const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
const playScene = useCallback(
(queue: SceneQueue, sceneID: string, options: IPlaySceneOptions) => {
history.push(queue.makeLink(sceneID, options));
(queue: SceneQueue, sceneID: string, options?: IPlaySceneOptions) => {
history.push(
queue.makeLink(sceneID, { autoPlay, continue: cont, ...options })
);
},
[history]
[history, cont, autoPlay]
);
return playScene;
}
function usePlaySelected(selectedIds: Set<string>) {
const { configuration: config } = useContext(ConfigurationContext);
const playScene = usePlayScene();
const playSelected = useCallback(() => {
// populate queue and go to first scene
const sceneIDs = Array.from(selectedIds.values());
const queue = SceneQueue.fromSceneIDList(sceneIDs);
const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
playScene(queue, sceneIDs[0], { autoPlay });
}, [selectedIds, config?.interface.autostartVideoOnPlaySelected, playScene]);
playScene(queue, sceneIDs[0]);
}, [selectedIds, playScene]);
return playSelected;
}
function usePlayFirst() {
const playScene = usePlayScene();
const playFirst = useCallback(
(queue: SceneQueue, sceneID: string, index: number) => {
// populate queue and go to first scene
playScene(queue, sceneID, { sceneIndex: index });
},
[playScene]
);
return playFirst;
}
function usePlayRandom(filter: ListFilterModel, count: number) {
const { configuration: config } = useContext(ConfigurationContext);
const playScene = usePlayScene();
const playRandom = useCallback(async () => {
@ -130,15 +161,9 @@ function usePlayRandom(filter: ListFilterModel, count: number) {
if (scene) {
// navigate to the image player page
const queue = SceneQueue.fromListFilterModel(filterCopy);
const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
playScene(queue, scene.id, { sceneIndex: index, autoPlay });
playScene(queue, scene.id, { sceneIndex: index });
}
}, [
filter,
count,
config?.interface.autostartVideoOnPlaySelected,
playScene,
]);
}, [filter, count, playScene]);
return playRandom;
}
@ -213,12 +238,23 @@ const SidebarContent: React.FC<{
sidebarOpen: boolean;
onClose?: () => void;
showEditFilter: (editingCriterion?: string) => void;
}> = ({ filter, setFilter, view, showEditFilter, sidebarOpen, onClose }) => {
count?: number;
}> = ({
filter,
setFilter,
view,
showEditFilter,
sidebarOpen,
onClose,
count,
}) => {
const showResultsId =
count !== undefined ? "actions.show_count_results" : "actions.show_results";
return (
<>
<FilteredSidebarHeader
sidebarOpen={sidebarOpen}
onClose={onClose}
showEditFilter={showEditFilter}
filter={filter}
setFilter={setFilter}
@ -262,10 +298,193 @@ const SidebarContent: React.FC<{
setFilter={setFilter}
/>
</ScenesFilterSidebarSections>
<div className="sidebar-footer">
<Button className="sidebar-close-button" onClick={onClose}>
<FormattedMessage id={showResultsId} values={{ count }} />
</Button>
</div>
</>
);
};
interface IOperations {
text: string;
onClick: () => void;
isDisplayed?: () => boolean;
className?: string;
}
const ListToolbarContent: React.FC<{
criteriaCount: number;
items: GQL.SlimSceneDataFragment[];
selectedIds: Set<string>;
operations: IOperations[];
onToggleSidebar: () => void;
onSelectAll: () => void;
onSelectNone: () => void;
onEdit: () => void;
onDelete: () => void;
onPlay: () => void;
onCreateNew: () => void;
}> = ({
criteriaCount,
items,
selectedIds,
operations,
onToggleSidebar,
onSelectAll,
onSelectNone,
onEdit,
onDelete,
onPlay,
onCreateNew,
}) => {
const intl = useIntl();
const hasSelection = selectedIds.size > 0;
return (
<>
{!hasSelection && (
<div>
<FilterButton
onClick={() => onToggleSidebar()}
count={criteriaCount}
title={intl.formatMessage({ id: "actions.sidebar.toggle" })}
/>
</div>
)}
{hasSelection && (
<div className="selected-items-info">
<Button
variant="secondary"
className="minimal"
onClick={() => onSelectNone()}
title={intl.formatMessage({ id: "actions.select_none" })}
>
<Icon icon={faTimes} />
</Button>
<span>{selectedIds.size} selected</span>
<Button variant="link" onClick={() => onSelectAll()}>
<FormattedMessage id="actions.select_all" />
</Button>
</div>
)}
<div>
<ButtonGroup>
{!!items.length && (
<Button
className="play-button"
variant="secondary"
onClick={() => onPlay()}
title={intl.formatMessage({ id: "actions.play" })}
>
<Icon icon={faPlay} />
</Button>
)}
{!hasSelection && (
<Button
className="create-new-button"
variant="secondary"
onClick={() => onCreateNew()}
title={intl.formatMessage(
{ id: "actions.create_entity" },
{ entityType: intl.formatMessage({ id: "scene" }) }
)}
>
<Icon icon={faPlus} />
</Button>
)}
{hasSelection && (
<>
<Button variant="secondary" onClick={() => onEdit()}>
<Icon icon={faPencil} />
</Button>
<Button
variant="danger"
className="btn-danger-minimal"
onClick={() => onDelete()}
>
<Icon icon={faTrash} />
</Button>
</>
)}
<OperationDropdown className="scene-list-operations">
{operations.map((o) => {
if (o.isDisplayed && !o.isDisplayed()) {
return null;
}
return (
<OperationDropdownItem
key={o.text}
onClick={o.onClick}
text={o.text}
className={o.className}
/>
);
})}
</OperationDropdown>
</ButtonGroup>
</div>
</>
);
};
const ListResultsHeader: React.FC<{
loading: boolean;
filter: ListFilterModel;
totalCount: number;
metadataByline?: React.ReactNode;
onChangeFilter: (filter: ListFilterModel) => void;
}> = ({ loading, filter, totalCount, metadataByline, onChangeFilter }) => {
return (
<ButtonToolbar className="scene-list-header">
<div>
<PaginationIndex
loading={loading}
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
/>
</div>
<div>
<SortBySelect
options={filter.options.sortByOptions}
sortBy={filter.sortBy}
sortDirection={filter.sortDirection}
onChangeSortBy={(s) =>
onChangeFilter(filter.setSortBy(s ?? undefined))
}
onChangeSortDirection={() =>
onChangeFilter(filter.toggleSortDirection())
}
onReshuffleRandomSort={() =>
onChangeFilter(filter.reshuffleRandomSort())
}
/>
<PageSizeSelector
pageSize={filter.itemsPerPage}
setPageSize={(s) => onChangeFilter(filter.setPageSize(s))}
/>
<ListViewOptions
displayMode={filter.displayMode}
zoomIndex={filter.zoomIndex}
displayModeOptions={filter.options.displayModeOptions}
onSetDisplayMode={(mode) =>
onChangeFilter(filter.setDisplayMode(mode))
}
onSetZoom={(zoom) => onChangeFilter(filter.setZoom(zoom))}
/>
</div>
</ButtonToolbar>
);
};
interface IFilteredScenes {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
defaultSort?: string;
@ -312,6 +531,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
selectedIds,
selectedItems,
onSelectChange,
onSelectAll,
onSelectNone,
hasSelection,
} = listSelect;
@ -337,13 +557,36 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
});
const metadataByline = useMemo(() => {
if (cachedResult.loading) return "";
if (cachedResult.loading) return null;
return renderMetadataByline(cachedResult) ?? "";
return renderMetadataByline(cachedResult) ?? null;
}, [cachedResult]);
const playSelected = usePlaySelected(selectedIds);
const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]);
const playRandom = usePlayRandom(filter, totalCount);
const playSelected = usePlaySelected(selectedIds);
const playFirst = usePlayFirst();
function onCreateNew() {
history.push("/scenes/new");
}
function onPlay() {
if (items.length === 0) {
return;
}
// if there are selected items, play those
if (hasSelection) {
playSelected();
return;
}
// otherwise, play the first item in the list
const sceneID = items[0].id;
playFirst(queue, sceneID, 0);
}
function onExport(all: boolean) {
showModal(
@ -381,16 +624,41 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
);
}
const otherOperations: IListFilterOperation[] = [
function onEdit() {
showModal(
<EditScenesDialog selected={selectedItems} onClose={onCloseEditDelete} />
);
}
function onDelete() {
showModal(
<DeleteScenesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.play_selected" }),
onClick: playSelected,
isDisplayed: () => hasSelection,
icon: faPlay,
text: intl.formatMessage({ id: "actions.play" }),
onClick: () => onPlay(),
isDisplayed: () => items.length > 0,
className: "play-item",
},
{
text: intl.formatMessage(
{ id: "actions.create_entity" },
{ entityType: intl.formatMessage({ id: "scene" }) }
),
onClick: () => onCreateNew(),
isDisplayed: () => !hasSelection,
className: "create-new-item",
},
{
text: intl.formatMessage({ id: "actions.play_random" }),
onClick: playRandom,
isDisplayed: () => totalCount > 1,
},
{
text: `${intl.formatMessage({ id: "actions.generate" })}…`,
@ -452,34 +720,36 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
view={view}
sidebarOpen={showSidebar}
onClose={() => setShowSidebar(false)}
count={cachedResult.loading ? undefined : totalCount}
/>
</Sidebar>
<div>
<FilteredListToolbar
<ButtonToolbar
className={cx("scene-list-toolbar", {
"has-selection": hasSelection,
})}
>
<ListToolbarContent
criteriaCount={filter.count()}
items={items}
selectedIds={selectedIds}
operations={otherOperations}
onToggleSidebar={() => setShowSidebar(!showSidebar)}
onSelectAll={() => onSelectAll()}
onSelectNone={() => onSelectNone()}
onEdit={onEdit}
onDelete={onDelete}
onCreateNew={onCreateNew}
onPlay={onPlay}
/>
</ButtonToolbar>
<ListResultsHeader
loading={cachedResult.loading}
filter={filter}
setFilter={setFilter}
showEditFilter={showEditFilter}
view={view}
listSelect={listSelect}
onEdit={() =>
showModal(
<EditScenesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
)
}
onDelete={() => {
showModal(
<DeleteScenesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}}
operations={otherOperations}
onToggleSidebar={() => setShowSidebar((v) => !v)}
zoomable
totalCount={totalCount}
metadataByline={metadataByline}
onChangeFilter={(newFilter) => setFilter(newFilter)}
/>
<FilterTags
@ -489,14 +759,6 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
onRemoveAll={() => clearAllCriteria()}
/>
<PaginationIndex
loading={cachedResult.loading}
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
/>
<LoadedContent loading={result.loading} error={result.error}>
<SceneList
filter={effectiveFilter}

View file

@ -1003,3 +1003,92 @@ input[type="range"].blue-slider {
}
}
}
.scene-list-toolbar,
.scene-list-header {
align-items: center;
background-color: $body-bg;
display: flex;
justify-content: space-between;
> div {
align-items: center;
display: flex;
gap: 0.5rem;
justify-content: flex-start;
&:last-child {
flex-shrink: 0;
justify-content: flex-end;
margin-left: auto;
}
}
}
.scene-list-toolbar {
flex-wrap: wrap;
// offset the main padding
margin-top: -0.5rem;
padding-bottom: 0.5rem;
padding-top: 0.5rem;
position: sticky;
top: $navbar-height;
z-index: 10;
@include media-breakpoint-down(xs) {
top: 0;
}
.selected-items-info .btn {
margin-right: 0.5rem;
}
// hide drop down menu items for play and create new
// when the buttons are visible
@include media-breakpoint-up(sm) {
.scene-list-operations {
.play-item,
.create-new-item {
display: none;
}
}
}
// hide play and create new buttons on xs screens
// show these in the drop down menu instead
@include media-breakpoint-down(xs) {
.play-button,
.create-new-button {
display: none;
}
}
}
.scene-list-header {
flex-wrap: wrap-reverse;
gap: 0.5rem;
margin-bottom: 0.5rem;
.paginationIndex {
margin: 0;
}
// center the header on smaller screens
@include media-breakpoint-down(sm) {
& > div,
& > div:last-child {
flex-basis: 100%;
justify-content: center;
margin-left: auto;
margin-right: auto;
}
}
}
.detail-body .scene-list-toolbar {
top: calc($sticky-detail-header-height + $navbar-height);
@include media-breakpoint-down(xs) {
top: 0;
}
}

View file

@ -774,8 +774,9 @@ $sidebar-width: 250px;
.sidebar {
bottom: 0;
left: 0;
margin-top: 4rem;
margin-top: $navbar-height;
overflow-y: auto;
padding-top: 0.5rem;
position: fixed;
scrollbar-gutter: stable;
top: 0;
@ -890,8 +891,7 @@ $sticky-header-height: calc(50px + 3.3rem);
padding-left: 0;
position: sticky;
// sticky detail header is 50px + 3.3rem
top: calc(50px + 3.3rem);
top: calc($sticky-detail-header-height + $navbar-height);
.sidebar-toolbar {
padding-top: 15px;
@ -918,7 +918,6 @@ $sticky-header-height: calc(50px + 3.3rem);
flex: 100% 0 0;
height: calc(100vh - 4rem);
max-height: calc(100vh - 4rem);
padding-top: 0;
top: 0;
}

View file

@ -1,9 +1,10 @@
// variables required by other scss files
// this is calculated from the existing height
// TODO: we should set this explicitly in the navbar
$navbar-height: 48.75px;
$sticky-detail-header-height: 50px;
@import "styles/theme";
@import "styles/range";
@import "styles/scrollbars";
@ -55,7 +56,7 @@ body {
@include media-breakpoint-down(xs) {
@media (orientation: portrait) {
padding: 1rem 0 $navbar-height;
padding: 0.5rem 0 $navbar-height;
}
}
}
@ -85,10 +86,10 @@ dd {
.sticky.detail-header {
display: block;
min-height: 50px;
min-height: $sticky-detail-header-height;
padding: unset;
position: fixed;
top: 3.3rem;
top: $navbar-height;
z-index: 10;
@media (max-width: 576px) {
@ -692,8 +693,7 @@ div.dropdown-menu {
.badge {
margin: unset;
// stylelint-disable declaration-no-important
white-space: normal !important;
white-space: normal;
}
}
@ -1025,6 +1025,9 @@ div.dropdown-menu {
top: auto;
}
}
@include media-breakpoint-up(xl) {
height: $navbar-height;
}
.navbar-toggler {
padding: 0.5em 0;

View file

@ -81,6 +81,7 @@
"open_random": "Open Random",
"optimise_database": "Optimise Database",
"overwrite": "Overwrite",
"play": "Play",
"play_random": "Play Random",
"play_selected": "Play selected",
"preview": "Preview",
@ -124,9 +125,12 @@
"set_image": "Set image…",
"show": "Show",
"show_configuration": "Show Configuration",
"show_results": "Show results",
"show_count_results": "Show {count} results",
"sidebar": {
"close": "Close sidebar",
"open": "Open sidebar"
"open": "Open sidebar",
"toggle": "Toggle sidebar"
},
"skip": "Skip",
"split": "Split",

View file

@ -1,7 +1,7 @@
import { CriterionOption } from "./criteria/criterion";
import { DisplayMode } from "./types";
interface ISortByOption {
export interface ISortByOption {
messageID: string;
value: string;
}

View file

@ -521,6 +521,34 @@ export class ListFilterModel {
public setPageSize(pageSize: number) {
const ret = this.clone();
ret.itemsPerPage = pageSize;
ret.currentPage = 1; // reset to first page
return ret;
}
public setSortBy(sortBy: string | undefined) {
const ret = this.clone();
ret.sortBy = sortBy;
ret.currentPage = 1; // reset to first page
return ret;
}
public toggleSortDirection() {
const ret = this.clone();
if (ret.sortDirection === SortDirectionEnum.Asc) {
ret.sortDirection = SortDirectionEnum.Desc;
} else {
ret.sortDirection = SortDirectionEnum.Asc;
}
ret.currentPage = 1; // reset to first page
return ret;
}
public reshuffleRandomSort() {
const ret = this.clone();
ret.currentPage = 1;
ret.randomSeed = -1;
return ret;
}