Refactor scene list to not use ItemList component (#5767)

* Add fields to useListSelect
* Add more utility hooks
* Remove context from FilteredListToolbar
* Refactor SceneList to not use ItemList
* Move common logic into useFilteredListHook
This commit is contained in:
WithoutPants
2025-03-28 11:59:05 +11:00
committed by GitHub
parent d9b4e62420
commit bac0b0a379
10 changed files with 637 additions and 321 deletions

View File

@@ -5,7 +5,7 @@ import {
GroupsCriterionOption, GroupsCriterionOption,
} from "src/models/list-filter/criteria/groups"; } from "src/models/list-filter/criteria/groups";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { SceneList } from "src/components/Scenes/SceneList"; import { FilteredSceneList } from "src/components/Scenes/SceneList";
import { View } from "src/components/List/views"; import { View } from "src/components/List/views";
interface IGroupScenesPanel { interface IGroupScenesPanel {
@@ -64,7 +64,7 @@ export const GroupScenesPanel: React.FC<IGroupScenesPanel> = ({
if (group && group.id) { if (group && group.id) {
return ( return (
<SceneList <FilteredSceneList
filterHook={filterHook} filterHook={filterHook}
defaultSort="group_scene_number" defaultSort="group_scene_number"
alterQuery={active} alterQuery={active}

View File

@@ -488,3 +488,33 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
</> </>
); );
}; };
export function useShowEditFilter(props: {
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
showModal: (content: React.ReactNode) => void;
closeModal: () => void;
}) {
const { filter, setFilter, showModal, closeModal } = props;
const showEditFilter = useCallback(
(editingCriterion?: string) => {
function onApplyEditFilter(f: ListFilterModel) {
closeModal();
setFilter(f);
}
showModal(
<EditFilterDialog
filter={filter}
onApply={onApplyEditFilter}
onCancel={() => closeModal()}
editingCriterion={editingCriterion}
/>
);
},
[filter, setFilter, showModal, closeModal]
);
return showEditFilter;
}

View File

@@ -8,11 +8,9 @@ import {
IListFilterOperation, IListFilterOperation,
ListOperationButtons, ListOperationButtons,
} from "./ListOperationButtons"; } from "./ListOperationButtons";
import { DisplayMode } from "src/models/list-filter/types";
import { ButtonToolbar } from "react-bootstrap"; import { ButtonToolbar } from "react-bootstrap";
import { View } from "./views"; import { View } from "./views";
import { useListContext } from "./ListProvider"; import { IListSelect, useFilterOperations } from "./util";
import { useFilter } from "./FilterProvider";
export interface IItemListOperation<T extends QueryResult> { export interface IItemListOperation<T extends QueryResult> {
text: string; text: string;
@@ -32,8 +30,13 @@ export interface IItemListOperation<T extends QueryResult> {
} }
export interface IFilteredListToolbar { export interface IFilteredListToolbar {
showEditFilter?: (editingCriterion?: string) => void; filter: ListFilterModel;
setFilter: (
value: ListFilterModel | ((prevState: ListFilterModel) => ListFilterModel)
) => void;
showEditFilter: () => void;
view?: View; view?: View;
listSelect: IListSelect;
onEdit?: () => void; onEdit?: () => void;
onDelete?: () => void; onDelete?: () => void;
operations?: IListFilterOperation[]; operations?: IListFilterOperation[];
@@ -41,25 +44,22 @@ export interface IFilteredListToolbar {
} }
export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
filter,
setFilter,
showEditFilter, showEditFilter,
view, view,
listSelect,
onEdit, onEdit,
onDelete, onDelete,
operations, operations,
zoomable = false, zoomable = false,
}) => { }) => {
const { getSelected, onSelectAll, onSelectNone } = useListContext();
const { filter, setFilter } = useFilter();
const filterOptions = filter.options; const filterOptions = filter.options;
const { setDisplayMode, setZoom } = useFilterOperations({
function onChangeDisplayMode(displayMode: DisplayMode) { filter,
setFilter(filter.setDisplayMode(displayMode)); setFilter,
} });
const { selectedIds, onSelectAll, onSelectNone } = listSelect;
function onChangeZoom(newZoomIndex: number) {
setFilter(filter.setZoom(newZoomIndex));
}
return ( return (
<ButtonToolbar className="filtered-list-toolbar"> <ButtonToolbar className="filtered-list-toolbar">
@@ -75,16 +75,16 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
onSelectAll={onSelectAll} onSelectAll={onSelectAll}
onSelectNone={onSelectNone} onSelectNone={onSelectNone}
otherOperations={operations} otherOperations={operations}
itemsSelected={getSelected().length > 0} itemsSelected={selectedIds.size > 0}
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
/> />
<ListViewOptions <ListViewOptions
displayMode={filter.displayMode} displayMode={filter.displayMode}
displayModeOptions={filterOptions.displayModeOptions} displayModeOptions={filterOptions.displayModeOptions}
onSetDisplayMode={onChangeDisplayMode} onSetDisplayMode={setDisplayMode}
zoomIndex={zoomable ? filter.zoomIndex : undefined} zoomIndex={zoomable ? filter.zoomIndex : undefined}
onSetZoom={zoomable ? onChangeZoom : undefined} onSetZoom={zoomable ? setZoom : undefined}
/> />
</ButtonToolbar> </ButtonToolbar>
); );

View File

@@ -10,7 +10,10 @@ import * as GQL from "src/core/generated-graphql";
import { QueryResult } from "@apollo/client"; import { QueryResult } from "@apollo/client";
import { Criterion } from "src/models/list-filter/criteria/criterion"; import { Criterion } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { EditFilterDialog } from "src/components/List/EditFilterDialog"; import {
EditFilterDialog,
useShowEditFilter,
} from "src/components/List/EditFilterDialog";
import { FilterTags } from "./FilterTags"; import { FilterTags } from "./FilterTags";
import { View } from "./views"; import { View } from "./views";
import { IHasID } from "src/utils/data"; import { IHasID } from "src/utils/data";
@@ -23,9 +26,15 @@ import {
import { FilterContext, SetFilterURL, useFilter } from "./FilterProvider"; import { FilterContext, SetFilterURL, useFilter } from "./FilterProvider";
import { useModal } from "src/hooks/modal"; import { useModal } from "src/hooks/modal";
import { import {
IFilterStateHook,
IQueryResultHook,
useDefaultFilter, useDefaultFilter,
useEnsureValidPage, useEnsureValidPage,
useFilterOperations,
useFilterState,
useListKeyboardShortcuts, useListKeyboardShortcuts,
useListSelect,
useQueryResult,
useScrollToTopOnPageChange, useScrollToTopOnPageChange,
} from "./util"; } from "./util";
import { import {
@@ -36,6 +45,72 @@ import {
import { PagedList } from "./PagedList"; import { PagedList } from "./PagedList";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
interface IFilteredItemList<T extends QueryResult, E extends IHasID = IHasID> {
filterStateProps: IFilterStateHook;
queryResultProps: IQueryResultHook<T, E>;
}
// Provides the common state and behaviour for filtered item list components
export function useFilteredItemList<
T extends QueryResult,
E extends IHasID = IHasID
>(props: IFilteredItemList<T, E>) {
const { configuration: config } = useContext(ConfigurationContext);
// States
const filterState = useFilterState({
config,
...props.filterStateProps,
});
const { filter, setFilter } = filterState;
const queryResult = useQueryResult({
filter,
...props.queryResultProps,
});
const { result, items, totalCount, pages } = queryResult;
const listSelect = useListSelect(items);
const { onSelectAll, onSelectNone } = listSelect;
const modalState = useModal();
const { showModal, closeModal } = modalState;
// Utility hooks
const { setPage } = useFilterOperations({ filter, setFilter });
// scroll to the top of the page when the page changes
useScrollToTopOnPageChange(filter.currentPage, result.loading);
// ensure that the current page is valid
useEnsureValidPage(filter, totalCount, setFilter);
const showEditFilter = useShowEditFilter({
showModal,
closeModal,
filter,
setFilter,
});
useListKeyboardShortcuts({
currentPage: filter.currentPage,
onChangePage: setPage,
onSelectAll,
onSelectNone,
pages,
showEditFilter,
});
return {
filterState,
queryResult,
listSelect,
modalState,
showEditFilter,
};
}
interface IItemListProps<T extends QueryResult, E extends IHasID> { interface IItemListProps<T extends QueryResult, E extends IHasID> {
view?: View; view?: View;
zoomable?: boolean; zoomable?: boolean;
@@ -83,13 +158,14 @@ export const ItemList = <T extends QueryResult, E extends IHasID>(
const { filter, setFilter: updateFilter } = useFilter(); const { filter, setFilter: updateFilter } = useFilter();
const { effectiveFilter, result, cachedResult, totalCount } = const { effectiveFilter, result, cachedResult, totalCount } =
useQueryResultContext<T, E>(); useQueryResultContext<T, E>();
const listSelect = useListContext<E>();
const { const {
selectedIds, selectedIds,
getSelected, getSelected,
onSelectChange, onSelectChange,
onSelectAll, onSelectAll,
onSelectNone, onSelectNone,
} = useListContext<E>(); } = listSelect;
// scroll to the top of the page when the page changes // scroll to the top of the page when the page changes
useScrollToTopOnPageChange(filter.currentPage, result.loading); useScrollToTopOnPageChange(filter.currentPage, result.loading);
@@ -236,7 +312,10 @@ export const ItemList = <T extends QueryResult, E extends IHasID>(
updateFilter(filter.clearCriteria()); updateFilter(filter.clearCriteria());
} }
const filterListToolbarProps = { const filterListToolbarProps: IFilteredListToolbar = {
filter,
setFilter: updateFilter,
listSelect,
showEditFilter, showEditFilter,
view: view, view: view,
operations: operations, operations: operations,

View File

@@ -29,22 +29,12 @@ export const ListContext = <T extends IHasID = IHasID>(
) => { ) => {
const { selectable = false, items, children } = props; const { selectable = false, items, children } = props;
const { const listSelect = useListSelect(items);
selectedIds,
getSelected,
onSelectChange,
onSelectAll,
onSelectNone,
} = useListSelect(items);
const state: IListContextState<T> = { const state: IListContextState<T> = {
selectable, selectable,
selectedIds,
getSelected,
onSelectChange,
onSelectAll,
onSelectNone,
items, items,
...listSelect,
}; };
return ( return (
@@ -74,6 +64,8 @@ const emptyState: IListContextState = {
onSelectAll: () => {}, onSelectAll: () => {},
onSelectNone: () => {}, onSelectNone: () => {},
items: [], items: [],
hasSelection: false,
selectedItems: [],
}; };
export function useListContextOptional<T extends IHasID = IHasID>() { export function useListContextOptional<T extends IHasID = IHasID>() {

View File

@@ -8,6 +8,9 @@ import { IHasID } from "src/utils/data";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import { View } from "./views"; import { View } from "./views";
import { usePrevious } from "src/hooks/state"; import { usePrevious } from "src/hooks/state";
import * as GQL from "src/core/generated-graphql";
import { DisplayMode } from "src/models/list-filter/types";
import { Criterion } from "src/models/list-filter/criteria/criterion";
export function useFilterURL( export function useFilterURL(
filter: ListFilterModel, filter: ListFilterModel,
@@ -106,6 +109,106 @@ export function useDefaultFilter(emptyFilter: ListFilterModel, view?: View) {
return { defaultFilter: retFilter, loading }; return { defaultFilter: retFilter, loading };
} }
function useEmptyFilter(props: {
filterMode: GQL.FilterMode;
defaultSort?: string;
config?: GQL.ConfigDataFragment;
}) {
const { filterMode, defaultSort, config } = props;
const emptyFilter = useMemo(
() =>
new ListFilterModel(filterMode, config, {
defaultSortBy: defaultSort,
}),
[config, filterMode, defaultSort]
);
return emptyFilter;
}
export interface IFilterStateHook {
filterMode: GQL.FilterMode;
defaultSort?: string;
view?: View;
useURL?: boolean;
}
export function useFilterState(
props: IFilterStateHook & {
config?: GQL.ConfigDataFragment;
}
) {
const { filterMode, defaultSort, config, view, useURL } = props;
const [filter, setFilterState] = useState<ListFilterModel>(
() =>
new ListFilterModel(filterMode, config, { defaultSortBy: defaultSort })
);
const emptyFilter = useEmptyFilter({ filterMode, defaultSort, config });
const { defaultFilter, loading } = useDefaultFilter(emptyFilter, view);
const { setFilter } = useFilterURL(filter, setFilterState, {
defaultFilter,
active: useURL,
});
return { loading, filter, setFilter };
}
export function useFilterOperations(props: {
filter: ListFilterModel;
setFilter: (
value: ListFilterModel | ((prevState: ListFilterModel) => ListFilterModel)
) => void;
}) {
const { setFilter } = props;
const setPage = useCallback(
(p: number) => {
setFilter((cv) => cv.changePage(p));
},
[setFilter]
);
const setDisplayMode = useCallback(
(displayMode: DisplayMode) => {
setFilter((cv) => cv.setDisplayMode(displayMode));
},
[setFilter]
);
const setZoom = useCallback(
(newZoomIndex: number) => {
setFilter((cv) => cv.setZoom(newZoomIndex));
},
[setFilter]
);
const removeCriterion = useCallback(
(removedCriterion: Criterion) => {
setFilter((cv) =>
cv.removeCriterion(removedCriterion.criterionOption.type)
);
},
[setFilter]
);
const clearAllCriteria = useCallback(() => {
setFilter((cv) => cv.clearCriteria());
}, [setFilter]);
return {
setPage,
setDisplayMode,
setZoom,
removeCriterion,
clearAllCriteria,
};
}
export function useListKeyboardShortcuts(props: { export function useListKeyboardShortcuts(props: {
currentPage?: number; currentPage?: number;
onChangePage?: (page: number) => void; onChangePage?: (page: number) => void;
@@ -190,10 +293,11 @@ export function useListKeyboardShortcuts(props: {
}, [onSelectAll, onSelectNone]); }, [onSelectAll, onSelectNone]);
} }
export function useListSelect<T extends { id: string }>(items: T[]) { export function useListSelect<T extends IHasID = IHasID>(items: T[]) {
const [itemsSelected, setItemsSelected] = useState<T[]>([]); const [itemsSelected, setItemsSelected] = useState<T[]>([]);
const [lastClickedId, setLastClickedId] = useState<string>(); const [lastClickedId, setLastClickedId] = useState<string>();
// TODO - this doesn't get updated when items changes
const selectedIds = useMemo(() => { const selectedIds = useMemo(() => {
const newSelectedIds = new Set<string>(); const newSelectedIds = new Set<string>();
itemsSelected.forEach((item) => { itemsSelected.forEach((item) => {
@@ -303,18 +407,26 @@ export function useListSelect<T extends { id: string }>(items: T[]) {
setLastClickedId(undefined); setLastClickedId(undefined);
} }
// TODO - this is for backwards compatibility
const getSelected = useCallback(() => itemsSelected, [itemsSelected]); const getSelected = useCallback(() => itemsSelected, [itemsSelected]);
// convenience state
const hasSelection = itemsSelected.length > 0;
return { return {
selectedItems: itemsSelected,
selectedIds, selectedIds,
getSelected, getSelected,
onSelectChange, onSelectChange,
onSelectAll, onSelectAll,
onSelectNone, onSelectNone,
hasSelection,
}; };
} }
export type IListSelect<T extends IHasID> = ReturnType<typeof useListSelect<T>>; export type IListSelect<T extends IHasID = IHasID> = ReturnType<
typeof useListSelect<T>
>;
// returns true if the filter has changed in a way that impacts the total count // returns true if the filter has changed in a way that impacts the total count
function totalCountImpacted( function totalCountImpacted(
@@ -358,6 +470,81 @@ export function useCachedQueryResult<T extends QueryResult>(
return cachedResult; return cachedResult;
} }
export interface IQueryResultHook<
T extends QueryResult,
E extends IHasID = IHasID
> {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
useResult: (filter: ListFilterModel) => T;
getCount: (data: T) => number;
getItems: (data: T) => E[];
}
export function useQueryResult<
T extends QueryResult,
E extends IHasID = IHasID
>(
props: IQueryResultHook<T, E> & {
filter: ListFilterModel;
}
) {
const { filter, filterHook, useResult, getItems, getCount } = props;
const effectiveFilter = useMemo(() => {
if (filterHook) {
return filterHook(filter.clone());
}
return filter;
}, [filter, filterHook]);
const result = useResult(effectiveFilter);
// use cached query result for pagination and metadata rendering
const cachedResult = useCachedQueryResult(effectiveFilter, result);
const items = useMemo(() => getItems(result), [getItems, result]);
const totalCount = useMemo(
() => getCount(cachedResult),
[getCount, cachedResult]
);
const pages = Math.ceil(totalCount / filter.itemsPerPage);
return {
effectiveFilter,
result,
cachedResult,
items,
totalCount,
pages,
};
}
// this hook collects the common logic when closing the edit/delete dialog
// if applied is true, then the list should be refetched and selection cleared
export function useCloseEditDelete(props: {
onSelectNone: () => void;
closeModal: () => void;
result: QueryResult;
}) {
const { onSelectNone, closeModal, result } = props;
const onCloseEditDelete = useCallback(
(applied?: boolean) => {
closeModal();
if (applied) {
onSelectNone();
// refetch
result.refetch();
}
},
[onSelectNone, closeModal, result]
);
return onCloseEditDelete;
}
export function useScrollToTopOnPageChange( export function useScrollToTopOnPageChange(
currentPage: number, currentPage: number,
loading: boolean loading: boolean

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { SceneList } from "src/components/Scenes/SceneList"; import { FilteredSceneList } from "src/components/Scenes/SceneList";
import { usePerformerFilterHook } from "src/core/performers"; import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views"; import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch"; import { PatchComponent } from "src/patch";
@@ -14,7 +14,7 @@ export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> =
PatchComponent("PerformerScenesPanel", ({ active, performer }) => { PatchComponent("PerformerScenesPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer); const filterHook = usePerformerFilterHook(performer);
return ( return (
<SceneList <FilteredSceneList
filterHook={filterHook} filterHook={filterHook}
alterQuery={active} alterQuery={active}
view={View.PerformerScenes} view={View.PerformerScenes}

View File

@@ -1,11 +1,10 @@
import React, { useState } from "react"; import React, { useCallback, useContext, useEffect, useMemo } from "react";
import cloneDeep from "lodash-es/cloneDeep"; import cloneDeep from "lodash-es/cloneDeep";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { queryFindScenes, useFindScenes } from "src/core/StashService"; import { queryFindScenes, useFindScenes } from "src/core/StashService";
import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
import { Tagger } from "../Tagger/scenes/SceneTagger"; import { Tagger } from "../Tagger/scenes/SceneTagger";
@@ -26,14 +25,12 @@ import { objectTitle } from "src/core/files";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { View } from "../List/views"; import { View } from "../List/views";
import { FileSize } from "../Shared/FileSize"; import { FileSize } from "../Shared/FileSize";
import { PagedList } from "../List/PagedList";
function getItems(result: GQL.FindScenesQueryResult) { import { useCloseEditDelete, useFilterOperations } from "../List/util";
return result?.data?.findScenes?.scenes ?? []; import { IListFilterOperation } from "../List/ListOperationButtons";
} import { FilteredListToolbar } from "../List/FilteredListToolbar";
import { useFilteredItemList } from "../List/ItemList";
function getCount(result: GQL.FindScenesQueryResult) { import { FilterTags } from "../List/FilterTags";
return result?.data?.findScenes?.count ?? 0;
}
function renderMetadataByline(result: GQL.FindScenesQueryResult) { function renderMetadataByline(result: GQL.FindScenesQueryResult) {
const duration = result?.data?.findScenes?.duration; const duration = result?.data?.findScenes?.duration;
@@ -64,111 +61,43 @@ function renderMetadataByline(result: GQL.FindScenesQueryResult) {
); );
} }
interface ISceneList { function usePlayScene() {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
defaultSort?: string;
view?: View;
alterQuery?: boolean;
fromGroupId?: string;
}
export const SceneList: React.FC<ISceneList> = ({
filterHook,
defaultSort,
view,
alterQuery,
fromGroupId,
}) => {
const intl = useIntl();
const history = useHistory(); const history = useHistory();
const config = React.useContext(ConfigurationContext);
const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);
const [mergeScenes, setMergeScenes] =
useState<{ id: string; title: string }[]>();
const [isIdentifyDialogOpen, setIsIdentifyDialogOpen] = useState(false);
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const filterMode = GQL.FilterMode.Scenes; const playScene = useCallback(
(queue: SceneQueue, sceneID: string, options: IPlaySceneOptions) => {
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.play_selected" }),
onClick: playSelected,
isDisplayed: showWhenSelected,
icon: faPlay,
},
{
text: intl.formatMessage({ id: "actions.play_random" }),
onClick: playRandom,
},
{
text: `${intl.formatMessage({ id: "actions.generate" })}…`,
onClick: async () => setIsGenerateDialogOpen(true),
isDisplayed: showWhenSelected,
},
{
text: `${intl.formatMessage({ id: "actions.identify" })}…`,
onClick: async () => setIsIdentifyDialogOpen(true),
isDisplayed: showWhenSelected,
},
{
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
onClick: onMerge,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll,
},
];
function addKeybinds(
result: GQL.FindScenesQueryResult,
filter: ListFilterModel
) {
Mousetrap.bind("p r", () => {
playRandom(result, filter);
});
return () => {
Mousetrap.unbind("p r");
};
}
function playScene(
queue: SceneQueue,
sceneID: string,
options: IPlaySceneOptions
) {
history.push(queue.makeLink(sceneID, options)); history.push(queue.makeLink(sceneID, options));
},
[history]
);
return playScene;
} }
async function playSelected( function usePlaySelected(selectedIds: Set<string>) {
result: GQL.FindScenesQueryResult, const { configuration: config } = useContext(ConfigurationContext);
filter: ListFilterModel, const playScene = usePlayScene();
selectedIds: Set<string>
) { 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 = const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
config.configuration?.interface.autostartVideoOnPlaySelected ?? false;
playScene(queue, sceneIDs[0], { autoPlay }); playScene(queue, sceneIDs[0], { autoPlay });
}, [selectedIds, config?.interface.autostartVideoOnPlaySelected, playScene]);
return playSelected;
} }
async function playRandom( function usePlayRandom(filter: ListFilterModel, count: number) {
result: GQL.FindScenesQueryResult, const { configuration: config } = useContext(ConfigurationContext);
filter: ListFilterModel const playScene = usePlayScene();
) {
const playRandom = useCallback(async () => {
// query for a random scene // query for a random scene
if (result.data?.findScenes) { if (count === 0) {
const { count } = result.data.findScenes; return;
}
const pages = Math.ceil(count / filter.itemsPerPage); const pages = Math.ceil(count / filter.itemsPerPage);
const page = Math.floor(Math.random() * pages) + 1; const page = Math.floor(Math.random() * pages) + 1;
@@ -183,112 +112,50 @@ export const SceneList: React.FC<ISceneList> = ({
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 = const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
config.configuration?.interface.autostartVideoOnPlaySelected ?? false;
playScene(queue, scene.id, { sceneIndex: index, autoPlay }); playScene(queue, scene.id, { sceneIndex: index, autoPlay });
} }
} }, [
filter,
count,
config?.interface.autostartVideoOnPlaySelected,
playScene,
]);
return playRandom;
} }
async function onMerge( function useAddKeybinds(filter: ListFilterModel, count: number) {
result: GQL.FindScenesQueryResult, const playRandom = usePlayRandom(filter, count);
filter: ListFilterModel,
selectedIds: Set<string> useEffect(() => {
) { Mousetrap.bind("p r", () => {
const selected = playRandom();
result.data?.findScenes.scenes });
.filter((s) => selectedIds.has(s.id))
.map((s) => { return () => {
return { Mousetrap.unbind("p r");
id: s.id,
title: objectTitle(s),
}; };
}) ?? []; }, [playRandom]);
setMergeScenes(selected);
} }
async function onExport() { const SceneList: React.FC<{
setIsExportAll(false); scenes: GQL.SlimSceneDataFragment[];
setIsExportDialogOpen(true); filter: ListFilterModel;
} selectedIds: Set<string>;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
fromGroupId?: string;
}> = ({ scenes, filter, selectedIds, onSelectChange, fromGroupId }) => {
const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]);
async function onExportAll() { if (scenes.length === 0) {
setIsExportAll(true); return null;
setIsExportDialogOpen(true);
} }
function renderContent(
result: GQL.FindScenesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
function maybeRenderSceneGenerateDialog() {
if (isGenerateDialogOpen) {
return (
<GenerateDialog
type="scene"
selectedIds={Array.from(selectedIds.values())}
onClose={() => setIsGenerateDialogOpen(false)}
/>
);
}
}
function maybeRenderSceneIdentifyDialog() {
if (isIdentifyDialogOpen) {
return (
<IdentifyDialog
selectedIds={Array.from(selectedIds.values())}
onClose={() => setIsIdentifyDialogOpen(false)}
/>
);
}
}
function maybeRenderSceneExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
scenes: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={() => setIsExportDialogOpen(false)}
/>
);
}
}
function renderMergeDialog() {
if (mergeScenes) {
return (
<SceneMergeModal
scenes={mergeScenes}
onClose={(mergedID?: string) => {
setMergeScenes(undefined);
if (mergedID) {
history.push(`/scenes/${mergedID}`);
}
}}
show
/>
);
}
}
function renderScenes() {
if (!result.data?.findScenes) return;
const queue = SceneQueue.fromListFilterModel(filter);
if (filter.displayMode === DisplayMode.Grid) { if (filter.displayMode === DisplayMode.Grid) {
return ( return (
<SceneCardsGrid <SceneCardsGrid
scenes={result.data.findScenes.scenes} scenes={scenes}
queue={queue} queue={queue}
zoomIndex={filter.zoomIndex} zoomIndex={filter.zoomIndex}
selectedIds={selectedIds} selectedIds={selectedIds}
@@ -300,7 +167,7 @@ export const SceneList: React.FC<ISceneList> = ({
if (filter.displayMode === DisplayMode.List) { if (filter.displayMode === DisplayMode.List) {
return ( return (
<SceneListTable <SceneListTable
scenes={result.data.findScenes.scenes} scenes={scenes}
queue={queue} queue={queue}
selectedIds={selectedIds} selectedIds={selectedIds}
onSelectChange={onSelectChange} onSelectChange={onSelectChange}
@@ -308,69 +175,230 @@ export const SceneList: React.FC<ISceneList> = ({
); );
} }
if (filter.displayMode === DisplayMode.Wall) { if (filter.displayMode === DisplayMode.Wall) {
return ( return <SceneWallPanel scenes={scenes} sceneQueue={queue} />;
<SceneWallPanel }
scenes={result.data.findScenes.scenes} if (filter.displayMode === DisplayMode.Tagger) {
sceneQueue={queue} return <Tagger scenes={scenes} queue={queue} />;
}
return null;
};
interface IFilteredScenes {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
defaultSort?: string;
view?: View;
alterQuery?: boolean;
fromGroupId?: string;
}
export const FilteredSceneList = (props: IFilteredScenes) => {
const intl = useIntl();
const history = useHistory();
const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props;
// States
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
useFilteredItemList({
filterStateProps: {
filterMode: GQL.FilterMode.Scenes,
defaultSort,
view,
useURL: alterQuery,
},
queryResultProps: {
useResult: useFindScenes,
getCount: (r) => r.data?.findScenes.count ?? 0,
getItems: (r) => r.data?.findScenes.scenes ?? [],
filterHook,
},
});
const { filter, setFilter, loading: filterLoading } = filterState;
const { effectiveFilter, result, cachedResult, items, totalCount } =
queryResult;
const {
selectedIds,
selectedItems,
onSelectChange,
onSelectNone,
hasSelection,
} = listSelect;
const { modal, showModal, closeModal } = modalState;
// Utility hooks
const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({
filter,
setFilter,
});
useAddKeybinds(filter, totalCount);
const onCloseEditDelete = useCloseEditDelete({
closeModal,
onSelectNone,
result,
});
const metadataByline = useMemo(() => {
if (cachedResult.loading) return "";
return renderMetadataByline(cachedResult) ?? "";
}, [cachedResult]);
const playSelected = usePlaySelected(selectedIds);
const playRandom = usePlayRandom(filter, totalCount);
function onExport(all: boolean) {
showModal(
<ExportDialog
exportInput={{
scenes: {
ids: Array.from(selectedIds.values()),
all: all,
},
}}
onClose={() => closeModal()}
/> />
); );
} }
if (filter.displayMode === DisplayMode.Tagger) {
return <Tagger scenes={result.data.findScenes.scenes} queue={queue} />;
}
}
return ( function onMerge() {
<> const selected =
{maybeRenderSceneGenerateDialog()} selectedItems.map((s) => {
{maybeRenderSceneIdentifyDialog()} return {
{maybeRenderSceneExportDialog()} id: s.id,
{renderMergeDialog()} title: objectTitle(s),
{renderScenes()} };
</> }) ?? [];
showModal(
<SceneMergeModal
scenes={selected}
onClose={(mergedID?: string) => {
closeModal();
if (mergedID) {
history.push(`/scenes/${mergedID}`);
}
}}
show
/>
); );
} }
function renderEditDialog( const otherOperations: IListFilterOperation[] = [
selectedScenes: GQL.SlimSceneDataFragment[], {
onClose: (applied: boolean) => void text: intl.formatMessage({ id: "actions.play_selected" }),
) { onClick: playSelected,
return <EditScenesDialog selected={selectedScenes} onClose={onClose} />; isDisplayed: () => hasSelection,
} icon: faPlay,
},
{
text: intl.formatMessage({ id: "actions.play_random" }),
onClick: playRandom,
},
{
text: `${intl.formatMessage({ id: "actions.generate" })}…`,
onClick: () =>
showModal(
<GenerateDialog
type="scene"
selectedIds={Array.from(selectedIds.values())}
onClose={() => closeModal()}
/>
),
isDisplayed: () => hasSelection,
},
{
text: `${intl.formatMessage({ id: "actions.identify" })}…`,
onClick: () =>
showModal(
<IdentifyDialog
selectedIds={Array.from(selectedIds.values())}
onClose={() => closeModal()}
/>
),
isDisplayed: () => hasSelection,
},
{
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
onClick: () => onMerge(),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: () => onExport(false),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: () => onExport(true),
},
];
function renderDeleteDialog( // render
selectedScenes: GQL.SlimSceneDataFragment[], if (filterLoading) return null;
onClose: (confirmed: boolean) => void
) {
return <DeleteScenesDialog selected={selectedScenes} onClose={onClose} />;
}
return ( return (
<TaggerContext> <TaggerContext>
<ItemListContext <div className="item-list-container">
filterMode={filterMode} {modal}
defaultSort={defaultSort}
useResult={useFindScenes} <FilteredListToolbar
getItems={getItems} filter={filter}
getCount={getCount} setFilter={setFilter}
alterQuery={alterQuery} showEditFilter={showEditFilter}
filterHook={filterHook}
view={view} view={view}
selectable listSelect={listSelect}
> onEdit={() =>
<ItemList showModal(
zoomable <EditScenesDialog
view={view} selected={selectedItems}
otherOperations={otherOperations} onClose={onCloseEditDelete}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
renderMetadataByline={renderMetadataByline}
/> />
</ItemListContext> )
}
onDelete={() => {
showModal(
<DeleteScenesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}}
operations={otherOperations}
zoomable
/>
<FilterTags
criteria={filter.criteria}
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
onRemoveCriterion={removeCriterion}
onRemoveAll={() => clearAllCriteria()}
/>
<PagedList
result={result}
cachedResult={cachedResult}
filter={filter}
totalCount={totalCount}
onChangePage={setPage}
metadataByline={metadataByline}
>
<SceneList
filter={effectiveFilter}
scenes={items}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
fromGroupId={fromGroupId}
/>
</PagedList>
</div>
</TaggerContext> </TaggerContext>
); );
}; };
export default SceneList; export default FilteredSceneList;

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { SceneList } from "src/components/Scenes/SceneList"; import { FilteredSceneList } from "src/components/Scenes/SceneList";
import { useStudioFilterHook } from "src/core/studios"; import { useStudioFilterHook } from "src/core/studios";
import { View } from "src/components/List/views"; import { View } from "src/components/List/views";
@@ -17,7 +17,7 @@ export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({
}) => { }) => {
const filterHook = useStudioFilterHook(studio, showChildStudioContent); const filterHook = useStudioFilterHook(studio, showChildStudioContent);
return ( return (
<SceneList <FilteredSceneList
filterHook={filterHook} filterHook={filterHook}
alterQuery={active} alterQuery={active}
view={View.StudioScenes} view={View.StudioScenes}

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { SceneList } from "src/components/Scenes/SceneList"; import { FilteredSceneList } from "src/components/Scenes/SceneList";
import { useTagFilterHook } from "src/core/tags"; import { useTagFilterHook } from "src/core/tags";
import { View } from "src/components/List/views"; import { View } from "src/components/List/views";
@@ -17,7 +17,7 @@ export const TagScenesPanel: React.FC<ITagScenesPanel> = ({
}) => { }) => {
const filterHook = useTagFilterHook(tag, showSubTagContent); const filterHook = useTagFilterHook(tag, showSubTagContent);
return ( return (
<SceneList <FilteredSceneList
filterHook={filterHook} filterHook={filterHook}
alterQuery={active} alterQuery={active}
view={View.TagScenes} view={View.TagScenes}