mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03: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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user