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

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