mirror of
https://github.com/stashapp/stash.git
synced 2025-12-07 08:54:10 +01:00
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:
parent
27bc6c8fca
commit
d0a7b09bf3
14 changed files with 642 additions and 173 deletions
|
|
@ -101,8 +101,12 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (criteria.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex justify-content-center mb-2 wrap-tags filter-tags">
|
<div className="wrap-tags filter-tags">
|
||||||
{criteria.map(renderFilterTags)}
|
{criteria.map(renderFilterTags)}
|
||||||
{criteria.length >= 3 && (
|
{criteria.length >= 3 && (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,32 @@
|
||||||
import React, { useMemo } from "react";
|
import React from "react";
|
||||||
import { Badge, Button } from "react-bootstrap";
|
import { Badge, Button } from "react-bootstrap";
|
||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
|
||||||
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { Icon } from "src/components/Shared/Icon";
|
import { Icon } from "src/components/Shared/Icon";
|
||||||
import { useIntl } from "react-intl";
|
import { useIntl } from "react-intl";
|
||||||
|
|
||||||
interface IFilterButtonProps {
|
interface IFilterButtonProps {
|
||||||
filter: ListFilterModel;
|
count?: number;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FilterButton: React.FC<IFilterButtonProps> = ({
|
export const FilterButton: React.FC<IFilterButtonProps> = ({
|
||||||
filter,
|
count = 0,
|
||||||
onClick,
|
onClick,
|
||||||
|
title,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const count = useMemo(() => filter.count(), [filter]);
|
|
||||||
|
if (!title) {
|
||||||
|
title = intl.formatMessage({ id: "search_filter.edit_filter" });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="filter-button"
|
className="filter-button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={intl.formatMessage({ id: "search_filter.edit_filter" })}
|
title={title}
|
||||||
>
|
>
|
||||||
<Icon icon={faFilter} />
|
<Icon icon={faFilter} />
|
||||||
{count ? (
|
{count ? (
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,22 @@
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { FormattedMessage } from "react-intl";
|
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 { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { FilterButton } from "./FilterButton";
|
|
||||||
import { SearchTermInput } from "../ListFilter";
|
import { SearchTermInput } from "../ListFilter";
|
||||||
import { SidebarSavedFilterList } from "../SavedFilterList";
|
import { SidebarSavedFilterList } from "../SavedFilterList";
|
||||||
import { View } from "../views";
|
import { View } from "../views";
|
||||||
import useFocus from "src/utils/focus";
|
import useFocus from "src/utils/focus";
|
||||||
import ScreenUtils from "src/utils/screen";
|
import ScreenUtils from "src/utils/screen";
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
|
import { Button } from "react-bootstrap";
|
||||||
export const FilteredSidebarToolbar: React.FC<{
|
|
||||||
onClose?: () => void;
|
|
||||||
}> = ({ onClose, children }) => {
|
|
||||||
return <SidebarToolbar onClose={onClose}>{children}</SidebarToolbar>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FilteredSidebarHeader: React.FC<{
|
export const FilteredSidebarHeader: React.FC<{
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
onClose?: () => void;
|
|
||||||
showEditFilter: () => void;
|
showEditFilter: () => void;
|
||||||
filter: ListFilterModel;
|
filter: ListFilterModel;
|
||||||
setFilter: (filter: ListFilterModel) => void;
|
setFilter: (filter: ListFilterModel) => void;
|
||||||
view?: View;
|
view?: View;
|
||||||
}> = ({ sidebarOpen, onClose, showEditFilter, filter, setFilter, view }) => {
|
}> = ({ sidebarOpen, showEditFilter, filter, setFilter, view }) => {
|
||||||
const focus = useFocus();
|
const focus = useFocus();
|
||||||
const [, setFocus] = focus;
|
const [, setFocus] = focus;
|
||||||
|
|
||||||
|
|
@ -37,15 +30,24 @@ export const FilteredSidebarHeader: React.FC<{
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FilteredSidebarToolbar onClose={onClose} />
|
|
||||||
<div className="sidebar-search-container">
|
<div className="sidebar-search-container">
|
||||||
<SearchTermInput
|
<SearchTermInput
|
||||||
filter={filter}
|
filter={filter}
|
||||||
onFilterUpdate={setFilter}
|
onFilterUpdate={setFilter}
|
||||||
focus={focus}
|
focus={focus}
|
||||||
/>
|
/>
|
||||||
<FilterButton onClick={() => showEditFilter()} filter={filter} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
className="edit-filter-button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => showEditFilter()}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="search_filter.edit_filter" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SidebarSection
|
<SidebarSection
|
||||||
className="sidebar-saved-filters"
|
className="sidebar-saved-filters"
|
||||||
text={<FormattedMessage id="search_filter.saved_filters" />}
|
text={<FormattedMessage id="search_filter.saved_filters" />}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import { useDebounce } from "src/hooks/debounce";
|
||||||
import { View } from "./views";
|
import { View } from "./views";
|
||||||
import { ClearableInput } from "../Shared/ClearableInput";
|
import { ClearableInput } from "../Shared/ClearableInput";
|
||||||
import { useStopWheelScroll } from "src/utils/form";
|
import { useStopWheelScroll } from "src/utils/form";
|
||||||
|
import { ISortByOption } from "src/models/list-filter/filter-options";
|
||||||
|
|
||||||
export function useDebouncedSearchInput(
|
export function useDebouncedSearchInput(
|
||||||
filter: ListFilterModel,
|
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 {
|
interface IListFilterProps {
|
||||||
onFilterUpdate: (newFilter: ListFilterModel) => void;
|
onFilterUpdate: (newFilter: ListFilterModel) => void;
|
||||||
filter: ListFilterModel;
|
filter: ListFilterModel;
|
||||||
|
|
@ -247,8 +336,6 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const filterOptions = filter.options;
|
const filterOptions = filter.options;
|
||||||
|
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Mousetrap.bind("r", () => onReshuffleRandomSort());
|
Mousetrap.bind("r", () => onReshuffleRandomSort());
|
||||||
|
|
||||||
|
|
@ -289,32 +376,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||||
onFilterUpdate(newFilter);
|
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() {
|
function render() {
|
||||||
const currentSortBy = filterOptions.sortByOptions.find(
|
|
||||||
(o) => o.value === filter.sortBy
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!withSidebar && (
|
{!withSidebar && (
|
||||||
|
|
@ -342,56 +404,21 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||||
>
|
>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
onClick={() => openFilterDialog()}
|
onClick={() => openFilterDialog()}
|
||||||
filter={filter}
|
count={filter.count()}
|
||||||
/>
|
/>
|
||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dropdown as={ButtonGroup} className="mr-2">
|
<SortBySelect
|
||||||
<InputGroup.Prepend>
|
className="mr-2"
|
||||||
<Dropdown.Toggle variant="secondary">
|
sortBy={filter.sortBy}
|
||||||
{currentSortBy
|
sortDirection={filter.sortDirection}
|
||||||
? intl.formatMessage({ id: currentSortBy.messageID })
|
options={filterOptions.sortByOptions}
|
||||||
: ""}
|
onChangeSortBy={onChangeSortBy}
|
||||||
</Dropdown.Toggle>
|
onChangeSortDirection={onChangeSortDirection}
|
||||||
</InputGroup.Prepend>
|
onReshuffleRandomSort={onReshuffleRandomSort}
|
||||||
<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>
|
|
||||||
|
|
||||||
<PageSizeSelector
|
<PageSizeSelector
|
||||||
pageSize={filter.itemsPerPage}
|
pageSize={filter.itemsPerPage}
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,17 @@ import {
|
||||||
faPencilAlt,
|
faPencilAlt,
|
||||||
faTrash,
|
faTrash,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import cx from "classnames";
|
||||||
|
|
||||||
export const OperationDropdown: React.FC<PropsWithChildren<{}>> = ({
|
export const OperationDropdown: React.FC<
|
||||||
children,
|
PropsWithChildren<{
|
||||||
}) => {
|
className?: string;
|
||||||
|
}>
|
||||||
|
> = ({ className, children }) => {
|
||||||
if (!children) return null;
|
if (!children) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown as={ButtonGroup}>
|
<Dropdown className={className} as={ButtonGroup}>
|
||||||
<Dropdown.Toggle variant="secondary" id="more-menu">
|
<Dropdown.Toggle variant="secondary" id="more-menu">
|
||||||
<Icon icon={faEllipsisH} />
|
<Icon icon={faEllipsisH} />
|
||||||
</Dropdown.Toggle>
|
</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 {
|
export interface IListFilterOperation {
|
||||||
text: string;
|
text: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
.filter-tags .clear-all-button {
|
||||||
color: $text-color;
|
color: $text-color;
|
||||||
// to match filter pills
|
// to match filter pills
|
||||||
|
|
@ -929,25 +935,49 @@ input[type="range"].zoom-slider {
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
|
// make controls slightly larger on mobile
|
||||||
|
@include media-breakpoint-down(xs) {
|
||||||
|
.btn,
|
||||||
|
.form-control {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-search-container {
|
.sidebar-search-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-term-input {
|
.search-term-input {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
margin-right: 0.25rem;
|
margin-right: 0;
|
||||||
|
|
||||||
.clearable-text-field {
|
.clearable-text-field {
|
||||||
height: 100%;
|
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) {
|
@include media-breakpoint-down(xs) {
|
||||||
.sidebar .search-term-input {
|
.sidebar .sidebar-search-container {
|
||||||
margin-right: 0.5rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,6 @@ const allMenuItems: IMenuItem[] = [
|
||||||
href: "/scenes",
|
href: "/scenes",
|
||||||
icon: faPlayCircle,
|
icon: faPlayCircle,
|
||||||
hotkey: "g s",
|
hotkey: "g s",
|
||||||
userCreatable: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "images",
|
name: "images",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,13 @@ import { SceneCardsGrid } from "./SceneCardsGrid";
|
||||||
import { TaggerContext } from "../Tagger/context";
|
import { TaggerContext } from "../Tagger/context";
|
||||||
import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog";
|
import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog";
|
||||||
import { ConfigurationContext } from "src/hooks/Config";
|
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 { SceneMergeModal } from "./SceneMergeDialog";
|
||||||
import { objectTitle } from "src/core/files";
|
import { objectTitle } from "src/core/files";
|
||||||
import TextUtils from "src/utils/text";
|
import TextUtils from "src/utils/text";
|
||||||
|
|
@ -27,8 +33,10 @@ import { View } from "../List/views";
|
||||||
import { FileSize } from "../Shared/FileSize";
|
import { FileSize } from "../Shared/FileSize";
|
||||||
import { LoadedContent } from "../List/PagedList";
|
import { LoadedContent } from "../List/PagedList";
|
||||||
import { useCloseEditDelete, useFilterOperations } from "../List/util";
|
import { useCloseEditDelete, useFilterOperations } from "../List/util";
|
||||||
import { IListFilterOperation } from "../List/ListOperationButtons";
|
import {
|
||||||
import { FilteredListToolbar } from "../List/FilteredListToolbar";
|
OperationDropdown,
|
||||||
|
OperationDropdownItem,
|
||||||
|
} from "../List/ListOperationButtons";
|
||||||
import { useFilteredItemList } from "../List/ItemList";
|
import { useFilteredItemList } from "../List/ItemList";
|
||||||
import { FilterTags } from "../List/FilterTags";
|
import { FilterTags } from "../List/FilterTags";
|
||||||
import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar";
|
import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar";
|
||||||
|
|
@ -49,6 +57,11 @@ import {
|
||||||
} from "../List/Filters/FilterSidebar";
|
} from "../List/Filters/FilterSidebar";
|
||||||
import { PatchContainerComponent } from "src/patch";
|
import { PatchContainerComponent } from "src/patch";
|
||||||
import { Pagination, PaginationIndex } from "../List/Pagination";
|
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) {
|
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
|
||||||
const duration = result?.data?.findScenes?.duration;
|
const duration = result?.data?.findScenes?.duration;
|
||||||
|
|
@ -82,33 +95,51 @@ function renderMetadataByline(result: GQL.FindScenesQueryResult) {
|
||||||
function usePlayScene() {
|
function usePlayScene() {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
|
const { configuration: config } = useContext(ConfigurationContext);
|
||||||
|
const cont = config?.interface.continuePlaylistDefault ?? false;
|
||||||
|
const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
|
||||||
|
|
||||||
const playScene = useCallback(
|
const playScene = useCallback(
|
||||||
(queue: SceneQueue, sceneID: string, options: IPlaySceneOptions) => {
|
(queue: SceneQueue, sceneID: string, options?: IPlaySceneOptions) => {
|
||||||
history.push(queue.makeLink(sceneID, options));
|
history.push(
|
||||||
|
queue.makeLink(sceneID, { autoPlay, continue: cont, ...options })
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[history]
|
[history, cont, autoPlay]
|
||||||
);
|
);
|
||||||
|
|
||||||
return playScene;
|
return playScene;
|
||||||
}
|
}
|
||||||
|
|
||||||
function usePlaySelected(selectedIds: Set<string>) {
|
function usePlaySelected(selectedIds: Set<string>) {
|
||||||
const { configuration: config } = useContext(ConfigurationContext);
|
|
||||||
const playScene = usePlayScene();
|
const playScene = usePlayScene();
|
||||||
|
|
||||||
const playSelected = useCallback(() => {
|
const playSelected = useCallback(() => {
|
||||||
// populate queue and go to first scene
|
// populate queue and go to first scene
|
||||||
const sceneIDs = Array.from(selectedIds.values());
|
const sceneIDs = Array.from(selectedIds.values());
|
||||||
const queue = SceneQueue.fromSceneIDList(sceneIDs);
|
const queue = SceneQueue.fromSceneIDList(sceneIDs);
|
||||||
const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
|
|
||||||
playScene(queue, sceneIDs[0], { autoPlay });
|
playScene(queue, sceneIDs[0]);
|
||||||
}, [selectedIds, config?.interface.autostartVideoOnPlaySelected, playScene]);
|
}, [selectedIds, playScene]);
|
||||||
|
|
||||||
return playSelected;
|
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) {
|
function usePlayRandom(filter: ListFilterModel, count: number) {
|
||||||
const { configuration: config } = useContext(ConfigurationContext);
|
|
||||||
const playScene = usePlayScene();
|
const playScene = usePlayScene();
|
||||||
|
|
||||||
const playRandom = useCallback(async () => {
|
const playRandom = useCallback(async () => {
|
||||||
|
|
@ -130,15 +161,9 @@ function usePlayRandom(filter: ListFilterModel, count: number) {
|
||||||
if (scene) {
|
if (scene) {
|
||||||
// navigate to the image player page
|
// navigate to the image player page
|
||||||
const queue = SceneQueue.fromListFilterModel(filterCopy);
|
const queue = SceneQueue.fromListFilterModel(filterCopy);
|
||||||
const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
|
playScene(queue, scene.id, { sceneIndex: index });
|
||||||
playScene(queue, scene.id, { sceneIndex: index, autoPlay });
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [filter, count, playScene]);
|
||||||
filter,
|
|
||||||
count,
|
|
||||||
config?.interface.autostartVideoOnPlaySelected,
|
|
||||||
playScene,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return playRandom;
|
return playRandom;
|
||||||
}
|
}
|
||||||
|
|
@ -213,12 +238,23 @@ const SidebarContent: React.FC<{
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
showEditFilter: (editingCriterion?: string) => 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<FilteredSidebarHeader
|
<FilteredSidebarHeader
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
onClose={onClose}
|
|
||||||
showEditFilter={showEditFilter}
|
showEditFilter={showEditFilter}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
|
|
@ -262,10 +298,193 @@ const SidebarContent: React.FC<{
|
||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
/>
|
/>
|
||||||
</ScenesFilterSidebarSections>
|
</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 {
|
interface IFilteredScenes {
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
defaultSort?: string;
|
defaultSort?: string;
|
||||||
|
|
@ -312,6 +531,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||||
selectedIds,
|
selectedIds,
|
||||||
selectedItems,
|
selectedItems,
|
||||||
onSelectChange,
|
onSelectChange,
|
||||||
|
onSelectAll,
|
||||||
onSelectNone,
|
onSelectNone,
|
||||||
hasSelection,
|
hasSelection,
|
||||||
} = listSelect;
|
} = listSelect;
|
||||||
|
|
@ -337,13 +557,36 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const metadataByline = useMemo(() => {
|
const metadataByline = useMemo(() => {
|
||||||
if (cachedResult.loading) return "";
|
if (cachedResult.loading) return null;
|
||||||
|
|
||||||
return renderMetadataByline(cachedResult) ?? "";
|
return renderMetadataByline(cachedResult) ?? null;
|
||||||
}, [cachedResult]);
|
}, [cachedResult]);
|
||||||
|
|
||||||
const playSelected = usePlaySelected(selectedIds);
|
const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]);
|
||||||
|
|
||||||
const playRandom = usePlayRandom(filter, totalCount);
|
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) {
|
function onExport(all: boolean) {
|
||||||
showModal(
|
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" }),
|
text: intl.formatMessage({ id: "actions.play" }),
|
||||||
onClick: playSelected,
|
onClick: () => onPlay(),
|
||||||
isDisplayed: () => hasSelection,
|
isDisplayed: () => items.length > 0,
|
||||||
icon: faPlay,
|
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" }),
|
text: intl.formatMessage({ id: "actions.play_random" }),
|
||||||
onClick: playRandom,
|
onClick: playRandom,
|
||||||
|
isDisplayed: () => totalCount > 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: `${intl.formatMessage({ id: "actions.generate" })}…`,
|
text: `${intl.formatMessage({ id: "actions.generate" })}…`,
|
||||||
|
|
@ -452,34 +720,36 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||||
view={view}
|
view={view}
|
||||||
sidebarOpen={showSidebar}
|
sidebarOpen={showSidebar}
|
||||||
onClose={() => setShowSidebar(false)}
|
onClose={() => setShowSidebar(false)}
|
||||||
|
count={cachedResult.loading ? undefined : totalCount}
|
||||||
/>
|
/>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<div>
|
<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}
|
filter={filter}
|
||||||
setFilter={setFilter}
|
totalCount={totalCount}
|
||||||
showEditFilter={showEditFilter}
|
metadataByline={metadataByline}
|
||||||
view={view}
|
onChangeFilter={(newFilter) => setFilter(newFilter)}
|
||||||
listSelect={listSelect}
|
|
||||||
onEdit={() =>
|
|
||||||
showModal(
|
|
||||||
<EditScenesDialog
|
|
||||||
selected={selectedItems}
|
|
||||||
onClose={onCloseEditDelete}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onDelete={() => {
|
|
||||||
showModal(
|
|
||||||
<DeleteScenesDialog
|
|
||||||
selected={selectedItems}
|
|
||||||
onClose={onCloseEditDelete}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
operations={otherOperations}
|
|
||||||
onToggleSidebar={() => setShowSidebar((v) => !v)}
|
|
||||||
zoomable
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterTags
|
<FilterTags
|
||||||
|
|
@ -489,14 +759,6 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||||
onRemoveAll={() => clearAllCriteria()}
|
onRemoveAll={() => clearAllCriteria()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PaginationIndex
|
|
||||||
loading={cachedResult.loading}
|
|
||||||
itemsPerPage={filter.itemsPerPage}
|
|
||||||
currentPage={filter.currentPage}
|
|
||||||
totalItems={totalCount}
|
|
||||||
metadataByline={metadataByline}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LoadedContent loading={result.loading} error={result.error}>
|
<LoadedContent loading={result.loading} error={result.error}>
|
||||||
<SceneList
|
<SceneList
|
||||||
filter={effectiveFilter}
|
filter={effectiveFilter}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -774,8 +774,9 @@ $sidebar-width: 250px;
|
||||||
.sidebar {
|
.sidebar {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
margin-top: 4rem;
|
margin-top: $navbar-height;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding-top: 0.5rem;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -890,8 +891,7 @@ $sticky-header-height: calc(50px + 3.3rem);
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
|
|
||||||
// sticky detail header is 50px + 3.3rem
|
top: calc($sticky-detail-header-height + $navbar-height);
|
||||||
top: calc(50px + 3.3rem);
|
|
||||||
|
|
||||||
.sidebar-toolbar {
|
.sidebar-toolbar {
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
|
|
@ -918,7 +918,6 @@ $sticky-header-height: calc(50px + 3.3rem);
|
||||||
flex: 100% 0 0;
|
flex: 100% 0 0;
|
||||||
height: calc(100vh - 4rem);
|
height: calc(100vh - 4rem);
|
||||||
max-height: calc(100vh - 4rem);
|
max-height: calc(100vh - 4rem);
|
||||||
padding-top: 0;
|
|
||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
// variables required by other scss files
|
// variables required by other scss files
|
||||||
|
|
||||||
// this is calculated from the existing height
|
// this is calculated from the existing height
|
||||||
// TODO: we should set this explicitly in the navbar
|
|
||||||
$navbar-height: 48.75px;
|
$navbar-height: 48.75px;
|
||||||
|
|
||||||
|
$sticky-detail-header-height: 50px;
|
||||||
|
|
||||||
@import "styles/theme";
|
@import "styles/theme";
|
||||||
@import "styles/range";
|
@import "styles/range";
|
||||||
@import "styles/scrollbars";
|
@import "styles/scrollbars";
|
||||||
|
|
@ -55,7 +56,7 @@ body {
|
||||||
|
|
||||||
@include media-breakpoint-down(xs) {
|
@include media-breakpoint-down(xs) {
|
||||||
@media (orientation: portrait) {
|
@media (orientation: portrait) {
|
||||||
padding: 1rem 0 $navbar-height;
|
padding: 0.5rem 0 $navbar-height;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -85,10 +86,10 @@ dd {
|
||||||
|
|
||||||
.sticky.detail-header {
|
.sticky.detail-header {
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 50px;
|
min-height: $sticky-detail-header-height;
|
||||||
padding: unset;
|
padding: unset;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 3.3rem;
|
top: $navbar-height;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
|
|
@ -692,8 +693,7 @@ div.dropdown-menu {
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
margin: unset;
|
margin: unset;
|
||||||
// stylelint-disable declaration-no-important
|
white-space: normal;
|
||||||
white-space: normal !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1025,6 +1025,9 @@ div.dropdown-menu {
|
||||||
top: auto;
|
top: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@include media-breakpoint-up(xl) {
|
||||||
|
height: $navbar-height;
|
||||||
|
}
|
||||||
|
|
||||||
.navbar-toggler {
|
.navbar-toggler {
|
||||||
padding: 0.5em 0;
|
padding: 0.5em 0;
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@
|
||||||
"open_random": "Open Random",
|
"open_random": "Open Random",
|
||||||
"optimise_database": "Optimise Database",
|
"optimise_database": "Optimise Database",
|
||||||
"overwrite": "Overwrite",
|
"overwrite": "Overwrite",
|
||||||
|
"play": "Play",
|
||||||
"play_random": "Play Random",
|
"play_random": "Play Random",
|
||||||
"play_selected": "Play selected",
|
"play_selected": "Play selected",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
|
|
@ -124,9 +125,12 @@
|
||||||
"set_image": "Set image…",
|
"set_image": "Set image…",
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
"show_configuration": "Show Configuration",
|
"show_configuration": "Show Configuration",
|
||||||
|
"show_results": "Show results",
|
||||||
|
"show_count_results": "Show {count} results",
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"close": "Close sidebar",
|
"close": "Close sidebar",
|
||||||
"open": "Open sidebar"
|
"open": "Open sidebar",
|
||||||
|
"toggle": "Toggle sidebar"
|
||||||
},
|
},
|
||||||
"skip": "Skip",
|
"skip": "Skip",
|
||||||
"split": "Split",
|
"split": "Split",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { CriterionOption } from "./criteria/criterion";
|
import { CriterionOption } from "./criteria/criterion";
|
||||||
import { DisplayMode } from "./types";
|
import { DisplayMode } from "./types";
|
||||||
|
|
||||||
interface ISortByOption {
|
export interface ISortByOption {
|
||||||
messageID: string;
|
messageID: string;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -521,6 +521,34 @@ export class ListFilterModel {
|
||||||
public setPageSize(pageSize: number) {
|
public setPageSize(pageSize: number) {
|
||||||
const ret = this.clone();
|
const ret = this.clone();
|
||||||
ret.itemsPerPage = pageSize;
|
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;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue