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