Item list refactor and related bug fixes (#3474)

* Replace ListHook with ItemList
* Cache ItemList pagination
* Fix SceneMarkerList Helmet
* Prevent ItemList query string conflicts
* Tweak saved filter clearing search term
* Hide pagination on filter changes
* Fix debounce of query term
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
DingDongSoLong4 2023-03-07 04:42:51 +02:00 committed by GitHub
parent 2d4384169a
commit 6b59b9643c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 2008 additions and 2006 deletions

View file

@ -3,7 +3,7 @@ import { Route, Switch } from "react-router-dom";
import { useIntl } from "react-intl";
import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "../Shared/constants";
import { PersistanceLevel } from "src/hooks/ListHook";
import { PersistanceLevel } from "../List/ItemList";
import Gallery from "./GalleryDetails/Gallery";
import GalleryCreate from "./GalleryDetails/GalleryCreate";
import { GalleryList } from "./GalleryList";

View file

@ -260,10 +260,16 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
<Tab.Content>
<Tab.Pane eventKey="images">
<GalleryImagesPanel gallery={gallery} />
<GalleryImagesPanel
active={activeRightTabKey == "images"}
gallery={gallery}
/>
</Tab.Pane>
<Tab.Pane eventKey="add">
<GalleryAddPanel gallery={gallery} />
<GalleryAddPanel
active={activeRightTabKey == "add"}
gallery={gallery}
/>
</Tab.Pane>
</Tab.Content>
</Tab.Container>

View file

@ -3,7 +3,7 @@ import * as GQL from "src/core/generated-graphql";
import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
import { ListFilterModel } from "src/models/list-filter/filter";
import { ImageList } from "src/components/Images/ImageList";
import { showWhenSelected } from "src/hooks/ListHook";
import { showWhenSelected } from "src/components/List/ItemList";
import { mutateAddGalleryImages } from "src/core/StashService";
import { useToast } from "src/hooks/Toast";
import { useIntl } from "react-intl";
@ -11,10 +11,14 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { galleryTitle } from "src/core/galleries";
interface IGalleryAddProps {
active: boolean;
gallery: GQL.GalleryDataFragment;
}
export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => {
export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({
active,
gallery,
}) => {
const Toast = useToast();
const intl = useIntl();
@ -93,6 +97,10 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => {
];
return (
<ImageList filterHook={filterHook} extraOperations={otherOperations} />
<ImageList
filterHook={filterHook}
extraOperations={otherOperations}
alterQuery={active}
/>
);
};

View file

@ -4,17 +4,22 @@ import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
import { ListFilterModel } from "src/models/list-filter/filter";
import { ImageList } from "src/components/Images/ImageList";
import { mutateRemoveGalleryImages } from "src/core/StashService";
import { showWhenSelected, PersistanceLevel } from "src/hooks/ListHook";
import {
showWhenSelected,
PersistanceLevel,
} from "src/components/List/ItemList";
import { useToast } from "src/hooks/Toast";
import { useIntl } from "react-intl";
import { faMinus } from "@fortawesome/free-solid-svg-icons";
import { galleryTitle } from "src/core/galleries";
interface IGalleryDetailsProps {
active: boolean;
gallery: GQL.GalleryDataFragment;
}
export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> = ({
active,
gallery,
}) => {
const intl = useIntl();
@ -95,6 +100,7 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> = ({
return (
<ImageList
filterHook={filterHook}
alterQuery={active}
extraOperations={otherOperations}
persistState={PersistanceLevel.VIEW}
persistanceKey="galleryimages"

View file

@ -4,18 +4,15 @@ import cloneDeep from "lodash-es/cloneDeep";
import { Table } from "react-bootstrap";
import { Link, useHistory } from "react-router-dom";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import {
FindGalleriesQueryResult,
SlimGalleryDataFragment,
} from "src/core/generated-graphql";
import {
showWhenSelected,
makeItemList,
PersistanceLevel,
useGalleriesList,
} from "src/hooks/ListHook";
showWhenSelected,
} from "../List/ItemList";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { queryFindGalleries } from "src/core/StashService";
import { queryFindGalleries, useFindGalleries } from "src/core/StashService";
import { GalleryCard } from "./GalleryCard";
import GalleryWallCard from "./GalleryWallCard";
import { EditGalleriesDialog } from "./EditGalleriesDialog";
@ -23,14 +20,27 @@ import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog";
import { ExportDialog } from "../Shared/ExportDialog";
import { galleryTitle } from "src/core/galleries";
const GalleryItemList = makeItemList({
filterMode: GQL.FilterMode.Galleries,
useResult: useFindGalleries,
getItems(result: GQL.FindGalleriesQueryResult) {
return result?.data?.findGalleries?.galleries ?? [];
},
getCount(result: GQL.FindGalleriesQueryResult) {
return result?.data?.findGalleries?.count ?? 0;
},
});
interface IGalleryList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
persistState?: PersistanceLevel;
alterQuery?: boolean;
}
export const GalleryList: React.FC<IGalleryList> = ({
filterHook,
persistState,
alterQuery,
}) => {
const intl = useIntl();
const history = useHistory();
@ -53,10 +63,10 @@ export const GalleryList: React.FC<IGalleryList> = ({
},
];
const addKeybinds = (
result: FindGalleriesQueryResult,
function addKeybinds(
result: GQL.FindGalleriesQueryResult,
filter: ListFilterModel
) => {
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
@ -64,26 +74,14 @@ export const GalleryList: React.FC<IGalleryList> = ({
return () => {
Mousetrap.unbind("p r");
};
};
const listData = useGalleriesList({
zoomable: true,
selectable: true,
otherOperations,
renderContent,
renderEditDialog: renderEditGalleriesDialog,
renderDeleteDialog: renderDeleteGalleriesDialog,
filterHook,
addKeybinds,
persistState,
});
}
async function viewRandom(
result: FindGalleriesQueryResult,
result: GQL.FindGalleriesQueryResult,
filter: ListFilterModel
) {
// query for a random image
if (result.data && result.data.findGalleries) {
if (result.data?.findGalleries) {
const { count } = result.data.findGalleries;
const index = Math.floor(Math.random() * count);
@ -109,10 +107,15 @@ export const GalleryList: React.FC<IGalleryList> = ({
setIsExportDialogOpen(true);
}
function maybeRenderGalleryExportDialog(selectedIds: Set<string>) {
if (isExportDialogOpen) {
return (
<>
function renderContent(
result: GQL.FindGalleriesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
function maybeRenderGalleryExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
galleries: {
@ -120,125 +123,119 @@ export const GalleryList: React.FC<IGalleryList> = ({
all: isExportAll,
},
}}
onClose={() => {
setIsExportDialogOpen(false);
}}
onClose={() => setIsExportDialogOpen(false)}
/>
</>
);
);
}
}
function renderGalleries() {
if (!result.data?.findGalleries) return;
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="row justify-content-center">
{result.data.findGalleries.galleries.map((gallery) => (
<GalleryCard
key={gallery.id}
gallery={gallery}
zoomIndex={filter.zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(gallery.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(gallery.id, selected, shiftKey)
}
/>
))}
</div>
);
}
if (filter.displayMode === DisplayMode.List) {
return (
<Table className="col col-sm-6 mx-auto">
<thead>
<tr>
<th>{intl.formatMessage({ id: "actions.preview" })}</th>
<th className="d-none d-sm-none">
{intl.formatMessage({ id: "title" })}
</th>
</tr>
</thead>
<tbody>
{result.data.findGalleries.galleries.map((gallery) => (
<tr key={gallery.id}>
<td>
<Link to={`/galleries/${gallery.id}`}>
{gallery.cover ? (
<img
alt={gallery.title ?? ""}
className="w-100 w-sm-auto"
src={`${gallery.cover.paths.thumbnail}`}
/>
) : undefined}
</Link>
</td>
<td className="d-none d-sm-block">
<Link to={`/galleries/${gallery.id}`}>
{galleryTitle(gallery)} ({gallery.image_count}{" "}
{gallery.image_count === 1 ? "image" : "images"})
</Link>
</td>
</tr>
))}
</tbody>
</Table>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<div className="row">
<div className="GalleryWall">
{result.data.findGalleries.galleries.map((gallery) => (
<GalleryWallCard key={gallery.id} gallery={gallery} />
))}
</div>
</div>
);
}
}
}
function renderEditGalleriesDialog(
selectedImages: SlimGalleryDataFragment[],
onClose: (applied: boolean) => void
) {
return (
<>
<EditGalleriesDialog selected={selectedImages} onClose={onClose} />
{maybeRenderGalleryExportDialog()}
{renderGalleries()}
</>
);
}
function renderDeleteGalleriesDialog(
selectedImages: SlimGalleryDataFragment[],
function renderEditDialog(
selectedImages: GQL.SlimGalleryDataFragment[],
onClose: (applied: boolean) => void
) {
return <EditGalleriesDialog selected={selectedImages} onClose={onClose} />;
}
function renderDeleteDialog(
selectedImages: GQL.SlimGalleryDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<>
<DeleteGalleriesDialog selected={selectedImages} onClose={onClose} />
</>
<DeleteGalleriesDialog selected={selectedImages} onClose={onClose} />
);
}
function renderGalleries(
result: FindGalleriesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
if (!result.data || !result.data.findGalleries) {
return;
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="row justify-content-center">
{result.data.findGalleries.galleries.map((gallery) => (
<GalleryCard
key={gallery.id}
gallery={gallery}
zoomIndex={filter.zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(gallery.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
listData.onSelectChange(gallery.id, selected, shiftKey)
}
/>
))}
</div>
);
}
if (filter.displayMode === DisplayMode.List) {
return (
<Table className="col col-sm-6 mx-auto">
<thead>
<tr>
<th>{intl.formatMessage({ id: "actions.preview" })}</th>
<th className="d-none d-sm-none">
{intl.formatMessage({ id: "title" })}
</th>
</tr>
</thead>
<tbody>
{result.data.findGalleries.galleries.map((gallery) => (
<tr key={gallery.id}>
<td>
<Link to={`/galleries/${gallery.id}`}>
{gallery.cover ? (
<img
alt={gallery.title ?? ""}
className="w-100 w-sm-auto"
src={`${gallery.cover.paths.thumbnail}`}
/>
) : undefined}
</Link>
</td>
<td className="d-none d-sm-block">
<Link to={`/galleries/${gallery.id}`}>
{galleryTitle(gallery)} ({gallery.image_count}{" "}
{gallery.image_count === 1 ? "image" : "images"})
</Link>
</td>
</tr>
))}
</tbody>
</Table>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<div className="row">
<div className="GalleryWall">
{result.data.findGalleries.galleries.map((gallery) => (
<GalleryWallCard key={gallery.id} gallery={gallery} />
))}
</div>
</div>
);
}
}
function renderContent(
result: FindGalleriesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
return (
<>
{maybeRenderGalleryExportDialog(selectedIds)}
{renderGalleries(result, filter, selectedIds)}
</>
);
}
return listData.template;
return (
<GalleryItemList
zoomable
selectable
filterHook={filterHook}
persistState={persistState}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
);
};

View file

@ -1,23 +1,19 @@
import React, { useCallback, useState, useMemo, MouseEvent } from "react";
import { useIntl } from "react-intl";
import { FormattedNumber, useIntl } from "react-intl";
import cloneDeep from "lodash-es/cloneDeep";
import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap";
import {
FindImagesQueryResult,
SlimImageDataFragment,
} from "src/core/generated-graphql";
import * as GQL from "src/core/generated-graphql";
import { queryFindImages } from "src/core/StashService";
import { queryFindImages, useFindImages } from "src/core/StashService";
import {
makeItemList,
IItemListOperation,
PersistanceLevel,
showWhenSelected,
} from "../List/ItemList";
import { useLightbox } from "src/hooks/Lightbox/hooks";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import {
IListHookOperation,
showWhenSelected,
PersistanceLevel,
useImagesList,
} from "src/hooks/ListHook";
import { ImageCard } from "./ImageCard";
import { EditImagesDialog } from "./EditImagesDialog";
@ -25,6 +21,7 @@ import { DeleteImagesDialog } from "./DeleteImagesDialog";
import "flexbin/flexbin.css";
import { ExportDialog } from "../Shared/ExportDialog";
import { objectTitle } from "src/core/files";
import TextUtils from "src/utils/text";
interface IImageWallProps {
images: GQL.SlimImageDataFragment[];
@ -60,7 +57,7 @@ const ImageWall: React.FC<IImageWallProps> = ({ images, handleImageOpen }) => {
};
interface IImageListImages {
images: SlimImageDataFragment[];
images: GQL.SlimImageDataFragment[];
filter: ListFilterModel;
selectedIds: Set<string>;
onChangePage: (page: number) => void;
@ -139,7 +136,7 @@ const ImageListImages: React.FC<IImageListImages> = ({
function renderImageCard(
index: number,
image: SlimImageDataFragment,
image: GQL.SlimImageDataFragment,
zoomIndex: number
) {
return (
@ -184,17 +181,65 @@ const ImageListImages: React.FC<IImageListImages> = ({
return <></>;
};
const ImageItemList = makeItemList({
filterMode: GQL.FilterMode.Images,
useResult: useFindImages,
getItems(result: GQL.FindImagesQueryResult) {
return result?.data?.findImages?.images ?? [];
},
getCount(result: GQL.FindImagesQueryResult) {
return result?.data?.findImages?.count ?? 0;
},
renderMetadataByline(result: GQL.FindImagesQueryResult) {
const megapixels = result?.data?.findImages?.megapixels;
const size = result?.data?.findImages?.filesize;
const filesize = size ? TextUtils.fileSize(size) : undefined;
if (!megapixels && !size) {
return;
}
const separator = megapixels && size ? " - " : "";
return (
<span className="images-stats">
&nbsp;(
{megapixels ? (
<span className="images-megapixels">
<FormattedNumber value={megapixels} /> Megapixels
</span>
) : undefined}
{separator}
{size && filesize ? (
<span className="images-size">
<FormattedNumber
value={filesize.size}
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
filesize.unit
)}
/>
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
</span>
) : undefined}
)
</span>
);
},
});
interface IImageList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
persistState?: PersistanceLevel;
persistanceKey?: string;
extraOperations?: IListHookOperation<FindImagesQueryResult>[];
alterQuery?: boolean;
extraOperations?: IItemListOperation<GQL.FindImagesQueryResult>[];
}
export const ImageList: React.FC<IImageList> = ({
filterHook,
persistState,
persistanceKey,
alterQuery,
extraOperations,
}) => {
const intl = useIntl();
@ -203,7 +248,8 @@ export const ImageList: React.FC<IImageList> = ({
const [isExportAll, setIsExportAll] = useState(false);
const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false);
const otherOperations = (extraOperations ?? []).concat([
const otherOperations = [
...(extraOperations ?? []),
{
text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom,
@ -217,12 +263,12 @@ export const ImageList: React.FC<IImageList> = ({
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll,
},
]);
];
const addKeybinds = (
result: FindImagesQueryResult,
function addKeybinds(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel
) => {
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
@ -230,27 +276,14 @@ export const ImageList: React.FC<IImageList> = ({
return () => {
Mousetrap.unbind("p r");
};
};
const { template, onSelectChange } = useImagesList({
zoomable: true,
selectable: true,
otherOperations,
renderContent,
renderEditDialog: renderEditImagesDialog,
renderDeleteDialog: renderDeleteImagesDialog,
filterHook,
addKeybinds,
persistState,
persistanceKey,
});
}
async function viewRandom(
result: FindImagesQueryResult,
result: GQL.FindImagesQueryResult,
filter: ListFilterModel
) {
// query for a random image
if (result.data && result.data.findImages) {
if (result.data?.findImages) {
const { count } = result.data.findImages;
const index = Math.floor(Math.random() * count);
@ -259,7 +292,7 @@ export const ImageList: React.FC<IImageList> = ({
filterCopy.currentPage = index + 1;
const singleResult = await queryFindImages(filterCopy);
if (singleResult.data.findImages.images.length === 1) {
const { id } = singleResult!.data!.findImages!.images[0];
const { id } = singleResult.data.findImages.images[0];
// navigate to the image player page
history.push(`/images/${id}`);
}
@ -276,10 +309,17 @@ export const ImageList: React.FC<IImageList> = ({
setIsExportDialogOpen(true);
}
function maybeRenderImageExportDialog(selectedIds: Set<string>) {
if (isExportDialogOpen) {
return (
<>
function renderContent(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void,
onChangePage: (page: number) => void,
pageCount: number
) {
function maybeRenderImageExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
images: {
@ -287,80 +327,64 @@ export const ImageList: React.FC<IImageList> = ({
all: isExportAll,
},
}}
onClose={() => {
setIsExportDialogOpen(false);
}}
onClose={() => setIsExportDialogOpen(false)}
/>
</>
);
}
}
function renderImages() {
if (!result.data?.findImages) return;
return (
<ImageListImages
filter={filter}
images={result.data.findImages.images}
onChangePage={onChangePage}
onSelectChange={onSelectChange}
pageCount={pageCount}
selectedIds={selectedIds}
slideshowRunning={slideshowRunning}
setSlideshowRunning={setSlideshowRunning}
/>
);
}
return (
<>
{maybeRenderImageExportDialog()}
{renderImages()}
</>
);
}
function renderEditImagesDialog(
selectedImages: SlimImageDataFragment[],
function renderEditDialog(
selectedImages: GQL.SlimImageDataFragment[],
onClose: (applied: boolean) => void
) {
return (
<>
<EditImagesDialog selected={selectedImages} onClose={onClose} />
</>
);
return <EditImagesDialog selected={selectedImages} onClose={onClose} />;
}
function renderDeleteImagesDialog(
selectedImages: SlimImageDataFragment[],
function renderDeleteDialog(
selectedImages: GQL.SlimImageDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<>
<DeleteImagesDialog selected={selectedImages} onClose={onClose} />
</>
);
return <DeleteImagesDialog selected={selectedImages} onClose={onClose} />;
}
function selectChange(id: string, selected: boolean, shiftKey: boolean) {
onSelectChange(id, selected, shiftKey);
}
function renderImages(
result: FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onChangePage: (page: number) => void,
pageCount: number
) {
if (!result.data || !result.data.findImages) {
return;
}
return (
<ImageListImages
filter={filter}
images={result.data.findImages.images}
onChangePage={onChangePage}
onSelectChange={selectChange}
pageCount={pageCount}
selectedIds={selectedIds}
slideshowRunning={slideshowRunning}
setSlideshowRunning={setSlideshowRunning}
/>
);
}
function renderContent(
result: FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onChangePage: (page: number) => void,
pageCount: number
) {
return (
<>
{maybeRenderImageExportDialog(selectedIds)}
{renderImages(result, filter, selectedIds, onChangePage, pageCount)}
</>
);
}
return template;
return (
<ImageItemList
zoomable
selectable
filterHook={filterHook}
persistState={persistState}
persistanceKey={persistanceKey}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
);
};

View file

@ -3,7 +3,7 @@ import { Route, Switch } from "react-router-dom";
import { useIntl } from "react-intl";
import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "../Shared/constants";
import { PersistanceLevel } from "src/hooks/ListHook";
import { PersistanceLevel } from "../List/ItemList";
import { Image } from "./ImageDetails/Image";
import { ImageList } from "./ImageList";

View file

@ -0,0 +1,756 @@
import React, {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import clone from "lodash-es/clone";
import cloneDeep from "lodash-es/cloneDeep";
import isEqual from "lodash-es/isEqual";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import { QueryResult } from "@apollo/client";
import {
Criterion,
CriterionValue,
} from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { useInterfaceLocalForage } from "src/hooks/LocalForage";
import { useHistory, useLocation } from "react-router-dom";
import { ConfigurationContext } from "src/hooks/Config";
import { getFilterOptions } from "src/models/list-filter/factory";
import { useFindDefaultFilter } from "src/core/StashService";
import { Pagination, PaginationIndex } from "./Pagination";
import { AddFilterDialog } from "./AddFilterDialog";
import { ListFilter } from "./ListFilter";
import { FilterTags } from "./FilterTags";
import { ListViewOptions } from "./ListViewOptions";
import { ListOperationButtons } from "./ListOperationButtons";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { DisplayMode } from "src/models/list-filter/types";
import { ButtonToolbar } from "react-bootstrap";
export enum PersistanceLevel {
// do not load default query or persist display mode
NONE,
// load default query, don't load or persist display mode
ALL,
// load and persist display mode only
VIEW,
}
interface IDataItem {
id: string;
}
export interface IItemListOperation<T extends QueryResult> {
text: string;
onClick: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => Promise<void>;
isDisplayed?: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => boolean;
postRefetch?: boolean;
icon?: IconDefinition;
buttonVariant?: string;
}
interface IItemListOptions<T extends QueryResult, E extends IDataItem> {
filterMode: GQL.FilterMode;
useResult: (filter: ListFilterModel) => T;
getCount: (data: T) => number;
renderMetadataByline?: (data: T) => React.ReactNode;
getItems: (data: T) => E[];
}
interface IRenderListProps {
filter: ListFilterModel;
onChangePage: (page: number) => void;
updateFilter: (filter: ListFilterModel) => void;
}
interface IItemListProps<T extends QueryResult, E extends IDataItem> {
persistState?: PersistanceLevel;
persistanceKey?: string;
defaultSort?: string;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
filterDialog?: (
criteria: Criterion<CriterionValue>[],
setCriteria: (v: Criterion<CriterionValue>[]) => void
) => React.ReactNode;
zoomable?: boolean;
selectable?: boolean;
alterQuery?: boolean;
defaultZoomIndex?: number;
otherOperations?: IItemListOperation<T>[];
renderContent: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void,
onChangePage: (page: number) => void,
pageCount: number
) => React.ReactNode;
renderEditDialog?: (
selected: E[],
onClose: (applied: boolean) => void
) => React.ReactNode;
renderDeleteDialog?: (
selected: E[],
onClose: (confirmed: boolean) => void
) => React.ReactNode;
addKeybinds?: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => () => void;
}
const getSelectedData = <I extends IDataItem>(
data: I[],
selectedIds: Set<string>
) => data.filter((value) => selectedIds.has(value.id));
/**
* A factory function for ItemList components.
* IMPORTANT: as the component manipulates the URL query string, if there are
* ever multiple ItemLists rendered at once, all but one of them need to have
* `alterQuery` set to false to prevent conflicts.
*/
export function makeItemList<T extends QueryResult, E extends IDataItem>({
filterMode,
useResult,
getCount,
renderMetadataByline,
getItems,
}: IItemListOptions<T, E>) {
const filterOptions = getFilterOptions(filterMode);
const RenderList: React.FC<IItemListProps<T, E> & IRenderListProps> = ({
filter,
onChangePage: _onChangePage,
updateFilter,
persistState,
filterDialog,
zoomable,
selectable,
otherOperations,
renderContent,
renderEditDialog,
renderDeleteDialog,
addKeybinds,
}) => {
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string>();
const [editingCriterion, setEditingCriterion] =
useState<Criterion<CriterionValue>>();
const [newCriterion, setNewCriterion] = useState(false);
const result = useResult(filter);
const [totalCount, setTotalCount] = useState(0);
const [metadataByline, setMetadataByline] = useState<React.ReactNode>();
const items = useMemo(() => getItems(result), [result]);
const [arePaging, setArePaging] = useState(false);
const hidePagination = !arePaging && result.loading;
// useLayoutEffect to set total count before paint, avoiding a 0 being displayed
useLayoutEffect(() => {
if (result.loading) return;
setArePaging(false);
setTotalCount(getCount(result));
setMetadataByline(renderMetadataByline?.(result));
}, [result]);
const onChangePage = useCallback(
(page: number) => {
setArePaging(true);
_onChangePage(page);
},
[_onChangePage]
);
// handle case where page is more than there are pages
useEffect(() => {
const pages = Math.ceil(totalCount / filter.itemsPerPage);
if (pages > 0 && filter.currentPage > pages) {
onChangePage(pages);
}
}, [filter, onChangePage, totalCount]);
// set up hotkeys
useEffect(() => {
Mousetrap.bind("f", () => setNewCriterion(true));
return () => {
Mousetrap.unbind("f");
};
}, []);
useEffect(() => {
const pages = Math.ceil(totalCount / filter.itemsPerPage);
Mousetrap.bind("right", () => {
if (filter.currentPage < pages) {
onChangePage(filter.currentPage + 1);
}
});
Mousetrap.bind("left", () => {
if (filter.currentPage > 1) {
onChangePage(filter.currentPage - 1);
}
});
Mousetrap.bind("shift+right", () => {
onChangePage(Math.min(pages, filter.currentPage + 10));
});
Mousetrap.bind("shift+left", () => {
onChangePage(Math.max(1, filter.currentPage - 10));
});
Mousetrap.bind("ctrl+end", () => {
onChangePage(pages);
});
Mousetrap.bind("ctrl+home", () => {
onChangePage(1);
});
return () => {
Mousetrap.unbind("right");
Mousetrap.unbind("left");
Mousetrap.unbind("shift+right");
Mousetrap.unbind("shift+left");
Mousetrap.unbind("ctrl+end");
Mousetrap.unbind("ctrl+home");
};
}, [filter, onChangePage, totalCount]);
useEffect(() => {
if (addKeybinds) {
const unbindExtras = addKeybinds(result, filter, selectedIds);
return () => {
unbindExtras();
};
}
}, [addKeybinds, result, filter, selectedIds]);
function singleSelect(id: string, selected: boolean) {
setLastClickedId(id);
const newSelectedIds = clone(selectedIds);
if (selected) {
newSelectedIds.add(id);
} else {
newSelectedIds.delete(id);
}
setSelectedIds(newSelectedIds);
}
function selectRange(startIndex: number, endIndex: number) {
let start = startIndex;
let end = endIndex;
if (start > end) {
const tmp = start;
start = end;
end = tmp;
}
const subset = items.slice(start, end + 1);
const newSelectedIds = new Set<string>();
subset.forEach((item) => {
newSelectedIds.add(item.id);
});
setSelectedIds(newSelectedIds);
}
function multiSelect(id: string) {
let startIndex = 0;
let thisIndex = -1;
if (lastClickedId) {
startIndex = items.findIndex((item) => {
return item.id === lastClickedId;
});
}
thisIndex = items.findIndex((item) => {
return item.id === id;
});
selectRange(startIndex, thisIndex);
}
function onSelectChange(id: string, selected: boolean, shiftKey: boolean) {
if (shiftKey) {
multiSelect(id);
} else {
singleSelect(id, selected);
}
}
function onSelectAll() {
const newSelectedIds = new Set<string>();
items.forEach((item) => {
newSelectedIds.add(item.id);
});
setSelectedIds(newSelectedIds);
setLastClickedId(undefined);
}
function onSelectNone() {
const newSelectedIds = new Set<string>();
setSelectedIds(newSelectedIds);
setLastClickedId(undefined);
}
function onChangeZoom(newZoomIndex: number) {
const newFilter = cloneDeep(filter);
newFilter.zoomIndex = newZoomIndex;
updateFilter(newFilter);
}
async function onOperationClicked(o: IItemListOperation<T>) {
await o.onClick(result, filter, selectedIds);
if (o.postRefetch) {
result.refetch();
}
}
const operations = otherOperations?.map((o) => ({
text: o.text,
onClick: () => {
onOperationClicked(o);
},
isDisplayed: () => {
if (o.isDisplayed) {
return o.isDisplayed(result, filter, selectedIds);
}
return true;
},
icon: o.icon,
buttonVariant: o.buttonVariant,
}));
function onEdit() {
setIsEditDialogOpen(true);
}
function onEditDialogClosed(applied: boolean) {
if (applied) {
onSelectNone();
}
setIsEditDialogOpen(false);
// refetch
result.refetch();
}
function onDelete() {
setIsDeleteDialogOpen(true);
}
function onDeleteDialogClosed(deleted: boolean) {
if (deleted) {
onSelectNone();
}
setIsDeleteDialogOpen(false);
// refetch
result.refetch();
}
function renderPagination() {
if (hidePagination) return;
return (
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
onChangePage={onChangePage}
/>
);
}
function renderPaginationIndex() {
if (hidePagination) return;
return (
<PaginationIndex
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
/>
);
}
function maybeRenderContent() {
if (result.loading) {
return <LoadingIndicator />;
}
if (result.error) {
return <h1>{result.error.message}</h1>;
}
const pages = Math.ceil(totalCount / filter.itemsPerPage);
return (
<>
{renderContent(
result,
filter,
selectedIds,
onSelectChange,
onChangePage,
pages
)}
{!!pages && (
<>
{renderPaginationIndex()}
{renderPagination()}
</>
)}
</>
);
}
function onChangeDisplayMode(displayMode: DisplayMode) {
const newFilter = cloneDeep(filter);
newFilter.displayMode = displayMode;
updateFilter(newFilter);
}
function onAddCriterion(
criterion: Criterion<CriterionValue>,
oldId?: string
) {
const newFilter = cloneDeep(filter);
// Find if we are editing an existing criteria, then modify that. Or create a new one.
const existingIndex = newFilter.criteria.findIndex((c) => {
// If we modified an existing criterion, then look for the old id.
const id = oldId || criterion.getId();
return c.getId() === id;
});
if (existingIndex === -1) {
newFilter.criteria.push(criterion);
} else {
newFilter.criteria[existingIndex] = criterion;
}
// Remove duplicate modifiers
newFilter.criteria = newFilter.criteria.filter((obj, pos, arr) => {
return arr.map((mapObj) => mapObj.getId()).indexOf(obj.getId()) === pos;
});
newFilter.currentPage = 1;
updateFilter(newFilter);
setEditingCriterion(undefined);
setNewCriterion(false);
}
function onCancelAddCriterion() {
setEditingCriterion(undefined);
setNewCriterion(false);
}
function onRemoveCriterion(removedCriterion: Criterion<CriterionValue>) {
const newFilter = cloneDeep(filter);
newFilter.criteria = newFilter.criteria.filter(
(criterion) => criterion.getId() !== removedCriterion.getId()
);
newFilter.currentPage = 1;
updateFilter(newFilter);
}
function updateCriteria(c: Criterion<CriterionValue>[]) {
const newFilter = cloneDeep(filter);
newFilter.criteria = c.slice();
setNewCriterion(false);
}
return (
<div>
<ButtonToolbar className="justify-content-center">
<ListFilter
onFilterUpdate={updateFilter}
filter={filter}
filterOptions={filterOptions}
openFilterDialog={() => setNewCriterion(true)}
filterDialogOpen={newCriterion}
persistState={persistState}
/>
<ListOperationButtons
onSelectAll={selectable ? onSelectAll : undefined}
onSelectNone={selectable ? onSelectNone : undefined}
otherOperations={operations}
itemsSelected={selectedIds.size > 0}
onEdit={renderEditDialog ? onEdit : undefined}
onDelete={renderDeleteDialog ? onDelete : undefined}
/>
<ListViewOptions
displayMode={filter.displayMode}
displayModeOptions={filterOptions.displayModeOptions}
onSetDisplayMode={onChangeDisplayMode}
zoomIndex={zoomable ? filter.zoomIndex : undefined}
onSetZoom={zoomable ? onChangeZoom : undefined}
/>
</ButtonToolbar>
<FilterTags
criteria={filter.criteria}
onEditCriterion={(c) => setEditingCriterion(c)}
onRemoveCriterion={onRemoveCriterion}
/>
{(newCriterion || editingCriterion) && !filterDialog && (
<AddFilterDialog
filterOptions={filterOptions}
onAddCriterion={onAddCriterion}
onCancel={onCancelAddCriterion}
editingCriterion={editingCriterion}
existingCriterions={filter.criteria}
/>
)}
{newCriterion &&
filterDialog &&
filterDialog(filter.criteria, (c) => updateCriteria(c))}
{isEditDialogOpen &&
renderEditDialog &&
renderEditDialog(getSelectedData(items, selectedIds), (applied) =>
onEditDialogClosed(applied)
)}
{isDeleteDialogOpen &&
renderDeleteDialog &&
renderDeleteDialog(getSelectedData(items, selectedIds), (deleted) =>
onDeleteDialogClosed(deleted)
)}
{renderPagination()}
{renderPaginationIndex()}
{maybeRenderContent()}
</div>
);
};
const ItemList: React.FC<IItemListProps<T, E>> = (props) => {
const {
persistState,
persistanceKey = filterMode,
defaultSort = filterOptions.defaultSortBy,
filterHook,
defaultZoomIndex,
alterQuery = true,
} = props;
const history = useHistory();
const location = useLocation();
const [interfaceState, setInterfaceState] = useInterfaceLocalForage();
const [filterInitialised, setFilterInitialised] = useState(false);
const { configuration: config } = useContext(ConfigurationContext);
const lastPathname = useRef(location.pathname);
const defaultDisplayMode = filterOptions.displayModeOptions[0];
const [filter, setFilter] = useState<ListFilterModel>(
() => new ListFilterModel(filterMode)
);
const updateSavedFilter = useCallback(
(updatedFilter: ListFilterModel) => {
setInterfaceState((prevState) => {
if (!prevState.queryConfig) {
prevState.queryConfig = {};
}
const oldFilter = prevState.queryConfig[persistanceKey]?.filter ?? "";
const newFilter = new URLSearchParams(oldFilter);
newFilter.set("disp", String(updatedFilter.displayMode));
return {
...prevState,
queryConfig: {
...prevState.queryConfig,
[persistanceKey]: {
...prevState.queryConfig[persistanceKey],
filter: newFilter.toString(),
},
},
};
});
},
[persistanceKey, setInterfaceState]
);
const { data: defaultFilter, loading: defaultFilterLoading } =
useFindDefaultFilter(filterMode);
const updateQueryParams = useCallback(
(newFilter: ListFilterModel) => {
if (!alterQuery) return;
const newParams = newFilter.makeQueryParameters();
history.replace({ ...history.location, search: newParams });
},
[alterQuery, history]
);
const updateFilter = useCallback(
(newFilter: ListFilterModel) => {
setFilter(newFilter);
updateQueryParams(newFilter);
if (persistState === PersistanceLevel.VIEW) {
updateSavedFilter(newFilter);
}
},
[persistState, updateSavedFilter, updateQueryParams]
);
// 'Startup' hook, initialises the filters
useEffect(() => {
// Only run once
if (filterInitialised) return;
let newFilter = new ListFilterModel(
filterMode,
config,
defaultSort,
defaultDisplayMode,
defaultZoomIndex
);
let loadDefault = true;
if (alterQuery && location.search) {
loadDefault = false;
newFilter.configureFromQueryString(location.search);
}
if (persistState === PersistanceLevel.ALL) {
// only set default filter if uninitialised
if (loadDefault) {
// wait until default filter is loaded
if (defaultFilterLoading) return;
if (defaultFilter?.findDefaultFilter) {
newFilter.currentPage = 1;
try {
newFilter.configureFromJSON(
defaultFilter.findDefaultFilter.filter
);
} catch (err) {
console.log(err);
// ignore
}
// #1507 - reset random seed when loaded
newFilter.randomSeed = -1;
}
}
} else if (persistState === PersistanceLevel.VIEW) {
// wait until forage is initialised
if (interfaceState.loading) return;
const storedQuery = interfaceState.data?.queryConfig?.[persistanceKey];
if (persistState === PersistanceLevel.VIEW && storedQuery) {
const displayMode = new URLSearchParams(storedQuery.filter).get(
"disp"
);
if (displayMode) {
newFilter.displayMode = Number.parseInt(displayMode, 10);
}
}
}
setFilter(newFilter);
updateQueryParams(newFilter);
setFilterInitialised(true);
}, [
filterInitialised,
location,
config,
defaultSort,
defaultDisplayMode,
defaultZoomIndex,
alterQuery,
persistState,
updateQueryParams,
defaultFilter,
defaultFilterLoading,
interfaceState,
persistanceKey,
]);
// This hook runs on every page location change (ie navigation),
// and updates the filter accordingly.
useEffect(() => {
if (!filterInitialised || !alterQuery) return;
// re-init if the pathname has changed
if (location.pathname !== lastPathname.current) {
lastPathname.current = location.pathname;
setFilterInitialised(false);
return;
}
// re-init to load default filter on empty new query params
if (!location.search) {
setFilterInitialised(false);
return;
}
// the query has changed, update filter if necessary
setFilter((prevFilter) => {
let newFilter = prevFilter.clone();
newFilter.configureFromQueryString(location.search);
if (!isEqual(newFilter, prevFilter)) {
return newFilter;
} else {
return prevFilter;
}
});
}, [filterInitialised, alterQuery, location]);
const onChangePage = useCallback(
(page: number) => {
const newFilter = cloneDeep(filter);
newFilter.currentPage = page;
updateFilter(newFilter);
window.scrollTo(0, 0);
},
[filter, updateFilter]
);
const renderFilter = useMemo(() => {
if (filterInitialised) {
return filterHook ? filterHook(cloneDeep(filter)) : filter;
}
}, [filterInitialised, filter, filterHook]);
if (!renderFilter) return null;
return (
<RenderList
filter={renderFilter}
onChangePage={onChangePage}
updateFilter={updateFilter}
{...props}
/>
);
};
return ItemList;
}
export const showWhenSelected = <T extends QueryResult>(
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => {
return selectedIds.size > 0;
};

View file

@ -1,6 +1,13 @@
import debounce from "lodash-es/debounce";
import cloneDeep from "lodash-es/cloneDeep";
import React, { HTMLAttributes, useEffect, useRef, useState } from "react";
import React, {
HTMLAttributes,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import cx from "classnames";
import Mousetrap from "mousetrap";
import { SortDirectionEnum } from "src/core/generated-graphql";
@ -22,7 +29,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import useFocus from "src/utils/focus";
import { ListFilterOptions } from "src/models/list-filter/filter-options";
import { FormattedMessage, useIntl } from "react-intl";
import { PersistanceLevel } from "src/hooks/ListHook";
import { PersistanceLevel } from "./ItemList";
import { SavedFilterList } from "./SavedFilterList";
import {
faBookmark,
@ -62,12 +69,24 @@ export const ListFilter: React.FC<IListFilterProps> = ({
const perPageSelect = useRef(null);
const [perPageInput, perPageFocus] = useFocus();
const searchCallback = debounce((value: string) => {
const newFilter = cloneDeep(filter);
newFilter.searchTerm = value;
newFilter.currentPage = 1;
onFilterUpdate(newFilter);
}, 500);
const searchQueryUpdated = useCallback(
(value: string) => {
const newFilter = cloneDeep(filter);
newFilter.searchTerm = value;
newFilter.currentPage = 1;
onFilterUpdate(newFilter);
},
[filter, onFilterUpdate]
);
// useMemo to prevent debounce from being recreated on every render
const debouncedSearchQueryUpdated = useMemo(
() =>
debounce((value: string) => {
searchQueryUpdated(value);
}, 500),
[searchQueryUpdated]
);
const intl = useIntl();
@ -93,8 +112,9 @@ export const ListFilter: React.FC<IListFilterProps> = ({
// clear search input when filter is cleared
useEffect(() => {
if (filter.searchTerm === "") {
if (!filter.searchTerm) {
queryRef.current.value = "";
setQueryClearShowing(false);
}
}, [filter.searchTerm, queryRef]);
@ -125,13 +145,13 @@ export const ListFilter: React.FC<IListFilterProps> = ({
}
function onChangeQuery(event: React.FormEvent<HTMLInputElement>) {
searchCallback(event.currentTarget.value);
debouncedSearchQueryUpdated(event.currentTarget.value);
setQueryClearShowing(!!event.currentTarget.value);
}
function onClearQuery() {
queryRef.current.value = "";
searchCallback("");
searchQueryUpdated("");
setQueryFocus();
setQueryClearShowing(false);
}

View file

@ -18,10 +18,10 @@ import {
import { useToast } from "src/hooks/Toast";
import { ListFilterModel } from "src/models/list-filter/filter";
import { SavedFilterDataFragment } from "src/core/generated-graphql";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { PersistanceLevel } from "src/hooks/ListHook";
import { PersistanceLevel } from "./ItemList";
import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "../Shared/Icon";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { faSave, faTimes } from "@fortawesome/free-solid-svg-icons";
interface ISavedFilterListProps {
@ -162,7 +162,10 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
function filterClicked(f: SavedFilterDataFragment) {
const newFilter = filter.clone();
newFilter.currentPage = 1;
// #1795 - reset search term if not present in saved filter
newFilter.searchTerm = "";
newFilter.configureFromJSON(f.filter);
// #1507 - reset random seed when loaded
newFilter.randomSeed = -1;

View file

@ -220,7 +220,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
</div>
<div className="col-xl-8 col-lg-6">
<MovieScenesPanel movie={movie} />
<MovieScenesPanel active={true} movie={movie} />
</div>
{renderDeleteAlert()}
</div>

View file

@ -5,10 +5,14 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { SceneList } from "src/components/Scenes/SceneList";
interface IMovieScenesPanel {
active: boolean;
movie: GQL.MovieDataFragment;
}
export const MovieScenesPanel: React.FC<IMovieScenesPanel> = ({ movie }) => {
export const MovieScenesPanel: React.FC<IMovieScenesPanel> = ({
active,
movie,
}) => {
function filterHook(filter: ListFilterModel) {
const movieValue = { id: movie.id, label: movie.name };
// if movie is already present, then we modify it, otherwise add
@ -43,7 +47,11 @@ export const MovieScenesPanel: React.FC<IMovieScenesPanel> = ({ movie }) => {
if (movie && movie.id) {
return (
<SceneList filterHook={filterHook} defaultSort="movie_scene_number" />
<SceneList
filterHook={filterHook}
defaultSort="movie_scene_number"
alterQuery={active}
/>
);
}
return <></>;

View file

@ -3,29 +3,41 @@ import { useIntl } from "react-intl";
import cloneDeep from "lodash-es/cloneDeep";
import Mousetrap from "mousetrap";
import { useHistory } from "react-router-dom";
import {
FindMoviesQueryResult,
SlimMovieDataFragment,
MovieDataFragment,
} from "src/core/generated-graphql";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { queryFindMovies, useMoviesDestroy } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import {
showWhenSelected,
useMoviesList,
queryFindMovies,
useFindMovies,
useMoviesDestroy,
} from "src/core/StashService";
import {
makeItemList,
PersistanceLevel,
} from "src/hooks/ListHook";
showWhenSelected,
} from "../List/ItemList";
import { ExportDialog } from "../Shared/ExportDialog";
import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog";
import { MovieCard } from "./MovieCard";
import { EditMoviesDialog } from "./EditMoviesDialog";
const MovieItemList = makeItemList({
filterMode: GQL.FilterMode.Movies,
useResult: useFindMovies,
getItems(result: GQL.FindMoviesQueryResult) {
return result?.data?.findMovies?.movies ?? [];
},
getCount(result: GQL.FindMoviesQueryResult) {
return result?.data?.findMovies?.count ?? 0;
},
});
interface IMovieList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
alterQuery?: boolean;
}
export const MovieList: React.FC<IMovieList> = ({ filterHook }) => {
export const MovieList: React.FC<IMovieList> = ({ filterHook, alterQuery }) => {
const intl = useIntl();
const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
@ -47,10 +59,10 @@ export const MovieList: React.FC<IMovieList> = ({ filterHook }) => {
},
];
const addKeybinds = (
result: FindMoviesQueryResult,
function addKeybinds(
result: GQL.FindMoviesQueryResult,
filter: ListFilterModel
) => {
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
@ -58,49 +70,14 @@ export const MovieList: React.FC<IMovieList> = ({ filterHook }) => {
return () => {
Mousetrap.unbind("p r");
};
};
function renderEditDialog(
selectedMovies: MovieDataFragment[],
onClose: (applied: boolean) => void
) {
return (
<>
<EditMoviesDialog selected={selectedMovies} onClose={onClose} />
</>
);
}
const renderDeleteDialog = (
selectedMovies: SlimMovieDataFragment[],
onClose: (confirmed: boolean) => void
) => (
<DeleteEntityDialog
selected={selectedMovies}
onClose={onClose}
singularEntity={intl.formatMessage({ id: "movie" })}
pluralEntity={intl.formatMessage({ id: "movies" })}
destroyMutation={useMoviesDestroy}
/>
);
const listData = useMoviesList({
renderContent,
addKeybinds,
otherOperations,
selectable: true,
persistState: PersistanceLevel.ALL,
renderEditDialog,
renderDeleteDialog,
filterHook,
});
async function viewRandom(
result: FindMoviesQueryResult,
result: GQL.FindMoviesQueryResult,
filter: ListFilterModel
) {
// query for a random image
if (result.data && result.data.findMovies) {
if (result.data?.findMovies) {
const { count } = result.data.findMovies;
const index = Math.floor(Math.random() * count);
@ -108,13 +85,8 @@ export const MovieList: React.FC<IMovieList> = ({ filterHook }) => {
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindMovies(filterCopy);
if (
singleResult &&
singleResult.data &&
singleResult.data.findMovies &&
singleResult.data.findMovies.movies.length === 1
) {
const { id } = singleResult!.data!.findMovies!.movies[0];
if (singleResult.data.findMovies.movies.length === 1) {
const { id } = singleResult.data.findMovies.movies[0];
// navigate to the movie page
history.push(`/movies/${id}`);
}
@ -131,10 +103,15 @@ export const MovieList: React.FC<IMovieList> = ({ filterHook }) => {
setIsExportDialogOpen(true);
}
function maybeRenderMovieExportDialog(selectedIds: Set<string>) {
if (isExportDialogOpen) {
return (
<>
function renderContent(
result: GQL.FindMoviesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
function maybeRenderMovieExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
movies: {
@ -142,27 +119,17 @@ export const MovieList: React.FC<IMovieList> = ({ filterHook }) => {
all: isExportAll,
},
}}
onClose={() => {
setIsExportDialogOpen(false);
}}
onClose={() => setIsExportDialogOpen(false)}
/>
</>
);
);
}
}
}
function renderContent(
result: FindMoviesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
if (!result.data?.findMovies) {
return;
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<>
{maybeRenderMovieExportDialog(selectedIds)}
function renderMovies() {
if (!result.data?.findMovies) return;
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="row justify-content-center">
{result.data.findMovies.movies.map((p) => (
<MovieCard
@ -171,18 +138,58 @@ export const MovieList: React.FC<IMovieList> = ({ filterHook }) => {
selecting={selectedIds.size > 0}
selected={selectedIds.has(p.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
listData.onSelectChange(p.id, selected, shiftKey)
onSelectChange(p.id, selected, shiftKey)
}
/>
))}
</div>
</>
);
}
if (filter.displayMode === DisplayMode.List) {
return <h1>TODO</h1>;
);
}
if (filter.displayMode === DisplayMode.List) {
return <h1>TODO</h1>;
}
}
return (
<>
{maybeRenderMovieExportDialog()}
{renderMovies()}
</>
);
}
return listData.template;
function renderEditDialog(
selectedMovies: GQL.MovieDataFragment[],
onClose: (applied: boolean) => void
) {
return <EditMoviesDialog selected={selectedMovies} onClose={onClose} />;
}
function renderDeleteDialog(
selectedMovies: GQL.SlimMovieDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<DeleteEntityDialog
selected={selectedMovies}
onClose={onClose}
singularEntity={intl.formatMessage({ id: "movie" })}
pluralEntity={intl.formatMessage({ id: "movies" })}
destroyMutation={useMoviesDestroy}
/>
);
}
return (
<MovieItemList
selectable
filterHook={filterHook}
persistState={PersistanceLevel.ALL}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
);
};

View file

@ -188,7 +188,10 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
</React.Fragment>
}
>
<PerformerScenesPanel performer={performer} />
<PerformerScenesPanel
active={activeTabKey == "scenes"}
performer={performer}
/>
</Tab>
<Tab
eventKey="galleries"
@ -202,7 +205,10 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
</React.Fragment>
}
>
<PerformerGalleriesPanel performer={performer} />
<PerformerGalleriesPanel
active={activeTabKey == "galleries"}
performer={performer}
/>
</Tab>
<Tab
eventKey="images"
@ -216,7 +222,10 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
</React.Fragment>
}
>
<PerformerImagesPanel performer={performer} />
<PerformerImagesPanel
active={activeTabKey == "images"}
performer={performer}
/>
</Tab>
<Tab
eventKey="movies"
@ -230,7 +239,10 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
</React.Fragment>
}
>
<PerformerMoviesPanel performer={performer} />
<PerformerMoviesPanel
active={activeTabKey == "movies"}
performer={performer}
/>
</Tab>
</Tabs>
</React.Fragment>

View file

@ -4,12 +4,14 @@ import { GalleryList } from "src/components/Galleries/GalleryList";
import { usePerformerFilterHook } from "src/core/performers";
interface IPerformerDetailsProps {
active: boolean;
performer: GQL.PerformerDataFragment;
}
export const PerformerGalleriesPanel: React.FC<IPerformerDetailsProps> = ({
active,
performer,
}) => {
const filterHook = usePerformerFilterHook(performer);
return <GalleryList filterHook={filterHook} />;
return <GalleryList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -4,12 +4,14 @@ import { ImageList } from "src/components/Images/ImageList";
import { usePerformerFilterHook } from "src/core/performers";
interface IPerformerImagesPanel {
active: boolean;
performer: GQL.PerformerDataFragment;
}
export const PerformerImagesPanel: React.FC<IPerformerImagesPanel> = ({
active,
performer,
}) => {
const filterHook = usePerformerFilterHook(performer);
return <ImageList filterHook={filterHook} />;
return <ImageList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -4,12 +4,14 @@ import { MovieList } from "src/components/Movies/MovieList";
import { usePerformerFilterHook } from "src/core/performers";
interface IPerformerDetailsProps {
active: boolean;
performer: GQL.PerformerDataFragment;
}
export const PerformerMoviesPanel: React.FC<IPerformerDetailsProps> = ({
active,
performer,
}) => {
const filterHook = usePerformerFilterHook(performer);
return <MovieList filterHook={filterHook} />;
return <MovieList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -4,12 +4,14 @@ import { SceneList } from "src/components/Scenes/SceneList";
import { usePerformerFilterHook } from "src/core/performers";
interface IPerformerDetailsProps {
active: boolean;
performer: GQL.PerformerDataFragment;
}
export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> = ({
active,
performer,
}) => {
const filterHook = usePerformerFilterHook(performer);
return <SceneList filterHook={filterHook} />;
return <SceneList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -3,19 +3,17 @@ import React, { useState } from "react";
import { useIntl } from "react-intl";
import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap";
import {
FindPerformersQueryResult,
SlimPerformerDataFragment,
} from "src/core/generated-graphql";
import * as GQL from "src/core/generated-graphql";
import {
queryFindPerformers,
useFindPerformers,
usePerformersDestroy,
} from "src/core/StashService";
import {
showWhenSelected,
makeItemList,
PersistanceLevel,
usePerformersList,
} from "src/hooks/ListHook";
showWhenSelected,
} from "../List/ItemList";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { PerformerTagger } from "../Tagger/performers/PerformerTagger";
@ -25,15 +23,28 @@ import { IPerformerCardExtraCriteria, PerformerCard } from "./PerformerCard";
import { PerformerListTable } from "./PerformerListTable";
import { EditPerformersDialog } from "./EditPerformersDialog";
const PerformerItemList = makeItemList({
filterMode: GQL.FilterMode.Performers,
useResult: useFindPerformers,
getItems(result: GQL.FindPerformersQueryResult) {
return result?.data?.findPerformers?.performers ?? [];
},
getCount(result: GQL.FindPerformersQueryResult) {
return result?.data?.findPerformers?.count ?? 0;
},
});
interface IPerformerList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
persistState?: PersistanceLevel;
alterQuery?: boolean;
extraCriteria?: IPerformerCardExtraCriteria;
}
export const PerformerList: React.FC<IPerformerList> = ({
filterHook,
persistState,
alterQuery,
extraCriteria,
}) => {
const intl = useIntl();
@ -44,7 +55,7 @@ export const PerformerList: React.FC<IPerformerList> = ({
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.open_random" }),
onClick: getRandom,
onClick: openRandom,
},
{
text: intl.formatMessage({ id: "actions.export" }),
@ -57,18 +68,36 @@ export const PerformerList: React.FC<IPerformerList> = ({
},
];
const addKeybinds = (
result: FindPerformersQueryResult,
function addKeybinds(
result: GQL.FindPerformersQueryResult,
filter: ListFilterModel
) => {
) {
Mousetrap.bind("p r", () => {
getRandom(result, filter);
openRandom(result, filter);
});
return () => {
Mousetrap.unbind("p r");
};
};
}
async function openRandom(
result: GQL.FindPerformersQueryResult,
filter: ListFilterModel
) {
if (result.data?.findPerformers) {
const { count } = result.data.findPerformers;
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindPerformers(filterCopy);
if (singleResult.data.findPerformers.performers.length === 1) {
const { id } = singleResult.data.findPerformers.performers[0]!;
history.push(`/performers/${id}`);
}
}
}
async function onExport() {
setIsExportAll(false);
@ -80,96 +109,35 @@ export const PerformerList: React.FC<IPerformerList> = ({
setIsExportDialogOpen(true);
}
function maybeRenderPerformerExportDialog(selectedIds: Set<string>) {
if (isExportDialogOpen) {
return (
<>
<ExportDialog
exportInput={{
performers: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={() => {
setIsExportDialogOpen(false);
}}
/>
</>
);
}
}
function renderEditPerformersDialog(
selectedPerformers: SlimPerformerDataFragment[],
onClose: (applied: boolean) => void
function renderContent(
result: GQL.FindPerformersQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
return (
<>
<EditPerformersDialog selected={selectedPerformers} onClose={onClose} />
</>
);
}
const renderDeleteDialog = (
selectedPerformers: SlimPerformerDataFragment[],
onClose: (confirmed: boolean) => void
) => (
<DeleteEntityDialog
selected={selectedPerformers}
onClose={onClose}
singularEntity={intl.formatMessage({ id: "performer" })}
pluralEntity={intl.formatMessage({ id: "performers" })}
destroyMutation={usePerformersDestroy}
/>
);
const listData = usePerformersList({
otherOperations,
renderContent,
renderEditDialog: renderEditPerformersDialog,
filterHook,
addKeybinds,
selectable: true,
persistState,
renderDeleteDialog,
});
async function getRandom(
result: FindPerformersQueryResult,
filter: ListFilterModel
) {
if (result.data?.findPerformers) {
const { count } = result.data.findPerformers;
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindPerformers(filterCopy);
if (
singleResult &&
singleResult.data &&
singleResult.data.findPerformers &&
singleResult.data.findPerformers.performers.length === 1
) {
const { id } = singleResult!.data!.findPerformers!.performers[0]!;
history.push(`/performers/${id}`);
function maybeRenderPerformerExportDialog() {
if (isExportDialogOpen) {
return (
<>
<ExportDialog
exportInput={{
performers: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={() => setIsExportDialogOpen(false)}
/>
</>
);
}
}
}
function renderContent(
result: FindPerformersQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
if (!result.data?.findPerformers) {
return;
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<>
{maybeRenderPerformerExportDialog(selectedIds)}
function renderPerformers() {
if (!result.data?.findPerformers) return;
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="row justify-content-center">
{result.data.findPerformers.performers.map((p) => (
<PerformerCard
@ -178,28 +146,71 @@ export const PerformerList: React.FC<IPerformerList> = ({
selecting={selectedIds.size > 0}
selected={selectedIds.has(p.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
listData.onSelectChange(p.id, selected, shiftKey)
onSelectChange(p.id, selected, shiftKey)
}
extraCriteria={extraCriteria}
/>
))}
</div>
</>
);
}
if (filter.displayMode === DisplayMode.List) {
return (
<PerformerListTable
performers={result.data.findPerformers.performers}
/>
);
}
if (filter.displayMode === DisplayMode.Tagger) {
return (
<PerformerTagger performers={result.data.findPerformers.performers} />
);
);
}
if (filter.displayMode === DisplayMode.List) {
return (
<PerformerListTable
performers={result.data.findPerformers.performers}
/>
);
}
if (filter.displayMode === DisplayMode.Tagger) {
return (
<PerformerTagger performers={result.data.findPerformers.performers} />
);
}
}
return (
<>
{maybeRenderPerformerExportDialog()}
{renderPerformers()}
</>
);
}
return listData.template;
function renderEditDialog(
selectedPerformers: GQL.SlimPerformerDataFragment[],
onClose: (applied: boolean) => void
) {
return (
<EditPerformersDialog selected={selectedPerformers} onClose={onClose} />
);
}
function renderDeleteDialog(
selectedPerformers: GQL.SlimPerformerDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<DeleteEntityDialog
selected={selectedPerformers}
onClose={onClose}
singularEntity={intl.formatMessage({ id: "performer" })}
pluralEntity={intl.formatMessage({ id: "performers" })}
destroyMutation={usePerformersDestroy}
/>
);
}
return (
<PerformerItemList
selectable
filterHook={filterHook}
persistState={persistState}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
);
};

View file

@ -3,7 +3,7 @@ import { Route, Switch } from "react-router-dom";
import { useIntl } from "react-intl";
import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "src/components/Shared/constants";
import { PersistanceLevel } from "src/hooks/ListHook";
import { PersistanceLevel } from "../List/ItemList";
import Performer from "./PerformerDetails/Performer";
import PerformerCreate from "./PerformerDetails/PerformerCreate";
import { PerformerList } from "./PerformerList";

View file

@ -1,20 +1,17 @@
import React, { useState } from "react";
import cloneDeep from "lodash-es/cloneDeep";
import { useIntl } from "react-intl";
import { FormattedNumber, 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 {
FindScenesQueryResult,
SlimSceneDataFragment,
} from "src/core/generated-graphql";
import { queryFindScenes } from "src/core/StashService";
makeItemList,
PersistanceLevel,
showWhenSelected,
} from "../List/ItemList";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import {
showWhenSelected,
PersistanceLevel,
useScenesList,
} from "src/hooks/ListHook";
import { Tagger } from "../Tagger/scenes/SceneTagger";
import { IPlaySceneOptions, SceneQueue } from "src/models/sceneQueue";
import { WallPanel } from "../Wall/WallPanel";
@ -30,17 +27,66 @@ import { ConfigurationContext } from "src/hooks/Config";
import { faPlay } from "@fortawesome/free-solid-svg-icons";
import { SceneMergeModal } from "./SceneMergeDialog";
import { objectTitle } from "src/core/files";
import TextUtils from "src/utils/text";
const SceneItemList = makeItemList({
filterMode: GQL.FilterMode.Scenes,
useResult: useFindScenes,
getItems(result: GQL.FindScenesQueryResult) {
return result?.data?.findScenes?.scenes ?? [];
},
getCount(result: GQL.FindScenesQueryResult) {
return result?.data?.findScenes?.count ?? 0;
},
renderMetadataByline(result: GQL.FindScenesQueryResult) {
const duration = result?.data?.findScenes?.duration;
const size = result?.data?.findScenes?.filesize;
const filesize = size ? TextUtils.fileSize(size) : undefined;
if (!duration && !size) {
return;
}
const separator = duration && size ? " - " : "";
return (
<span className="scenes-stats">
&nbsp;(
{duration ? (
<span className="scenes-duration">
{TextUtils.secondsAsTimeString(duration, 3)}
</span>
) : undefined}
{separator}
{size && filesize ? (
<span className="scenes-size">
<FormattedNumber
value={filesize.size}
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
filesize.unit
)}
/>
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
</span>
) : undefined}
)
</span>
);
},
});
interface ISceneList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
defaultSort?: string;
persistState?: PersistanceLevel.ALL;
persistState?: PersistanceLevel;
alterQuery?: boolean;
}
export const SceneList: React.FC<ISceneList> = ({
filterHook,
defaultSort,
persistState,
alterQuery,
}) => {
const intl = useIntl();
const history = useHistory();
@ -65,17 +111,17 @@ export const SceneList: React.FC<ISceneList> = ({
},
{
text: `${intl.formatMessage({ id: "actions.generate" })}…`,
onClick: generate,
onClick: async () => setIsGenerateDialogOpen(true),
isDisplayed: showWhenSelected,
},
{
text: `${intl.formatMessage({ id: "actions.identify" })}…`,
onClick: identify,
onClick: async () => setIsIdentifyDialogOpen(true),
isDisplayed: showWhenSelected,
},
{
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
onClick: merge,
onClick: onMerge,
isDisplayed: showWhenSelected,
},
{
@ -89,10 +135,10 @@ export const SceneList: React.FC<ISceneList> = ({
},
];
const addKeybinds = (
result: FindScenesQueryResult,
function addKeybinds(
result: GQL.FindScenesQueryResult,
filter: ListFilterModel
) => {
) {
Mousetrap.bind("p r", () => {
playRandom(result, filter);
});
@ -100,25 +146,7 @@ export const SceneList: React.FC<ISceneList> = ({
return () => {
Mousetrap.unbind("p r");
};
};
const renderDeleteDialog = (
selectedScenes: SlimSceneDataFragment[],
onClose: (confirmed: boolean) => void
) => <DeleteScenesDialog selected={selectedScenes} onClose={onClose} />;
const listData = useScenesList({
zoomable: true,
selectable: true,
otherOperations,
defaultSort,
renderContent,
renderEditDialog: renderEditScenesDialog,
renderDeleteDialog,
filterHook,
addKeybinds,
persistState,
});
}
function playScene(
queue: SceneQueue,
@ -129,7 +157,7 @@ export const SceneList: React.FC<ISceneList> = ({
}
async function playSelected(
result: FindScenesQueryResult,
result: GQL.FindScenesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
@ -142,44 +170,35 @@ export const SceneList: React.FC<ISceneList> = ({
}
async function playRandom(
result: FindScenesQueryResult,
result: GQL.FindScenesQueryResult,
filter: ListFilterModel
) {
// query for a random scene
if (result.data && result.data.findScenes) {
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 =
filter.itemsPerPage < count ? filter.itemsPerPage : count;
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);
if (queryResults.data.findScenes.scenes.length > index) {
const { id } = queryResults.data.findScenes.scenes[index];
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, id, { sceneIndex: index, autoPlay });
playScene(queue, scene.id, { sceneIndex: index, autoPlay });
}
}
}
async function generate() {
setIsGenerateDialogOpen(true);
}
async function identify() {
setIsIdentifyDialogOpen(true);
}
async function merge(
result: FindScenesQueryResult,
async function onMerge(
result: GQL.FindScenesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
@ -206,40 +225,37 @@ export const SceneList: React.FC<ISceneList> = ({
setIsExportDialogOpen(true);
}
function maybeRenderSceneGenerateDialog(selectedIds: Set<string>) {
if (isGenerateDialogOpen) {
return (
<>
function renderContent(
result: GQL.FindScenesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
function maybeRenderSceneGenerateDialog() {
if (isGenerateDialogOpen) {
return (
<GenerateDialog
selectedIds={Array.from(selectedIds.values())}
onClose={() => {
setIsGenerateDialogOpen(false);
}}
onClose={() => setIsGenerateDialogOpen(false)}
/>
</>
);
);
}
}
}
function maybeRenderSceneIdentifyDialog(selectedIds: Set<string>) {
if (isIdentifyDialogOpen) {
return (
<>
function maybeRenderSceneIdentifyDialog() {
if (isIdentifyDialogOpen) {
return (
<IdentifyDialog
selectedIds={Array.from(selectedIds.values())}
onClose={() => {
setIsIdentifyDialogOpen(false);
}}
onClose={() => setIsIdentifyDialogOpen(false)}
/>
</>
);
);
}
}
}
function maybeRenderSceneExportDialog(selectedIds: Set<string>) {
if (isExportDialogOpen) {
return (
<>
function maybeRenderSceneExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
scenes: {
@ -247,106 +263,110 @@ export const SceneList: React.FC<ISceneList> = ({
all: isExportAll,
},
}}
onClose={() => {
setIsExportDialogOpen(false);
}}
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) {
return (
<SceneCardsGrid
scenes={result.data.findScenes.scenes}
queue={queue}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.List) {
return (
<SceneListTable
scenes={result.data.findScenes.scenes}
queue={queue}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<WallPanel
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 renderEditScenesDialog(
selectedScenes: SlimSceneDataFragment[],
function renderEditDialog(
selectedScenes: GQL.SlimSceneDataFragment[],
onClose: (applied: boolean) => void
) {
return (
<>
<EditScenesDialog selected={selectedScenes} onClose={onClose} />
</>
);
return <EditScenesDialog selected={selectedScenes} onClose={onClose} />;
}
function renderMergeDialog() {
if (mergeScenes) {
return (
<SceneMergeModal
scenes={mergeScenes}
onClose={(mergedID?: string) => {
setMergeScenes(undefined);
if (mergedID) {
history.push(`/scenes/${mergedID}`);
}
}}
show
/>
);
}
}
function renderScenes(
result: FindScenesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
function renderDeleteDialog(
selectedScenes: GQL.SlimSceneDataFragment[],
onClose: (confirmed: boolean) => void
) {
if (!result.data || !result.data.findScenes) {
return;
}
const queue = SceneQueue.fromListFilterModel(filter);
if (filter.displayMode === DisplayMode.Grid) {
return (
<SceneCardsGrid
scenes={result.data.findScenes.scenes}
queue={queue}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={(id, selected, shiftKey) =>
listData.onSelectChange(id, selected, shiftKey)
}
/>
);
}
if (filter.displayMode === DisplayMode.List) {
return (
<SceneListTable
scenes={result.data.findScenes.scenes}
queue={queue}
selectedIds={selectedIds}
onSelectChange={(id, selected, shiftKey) =>
listData.onSelectChange(id, selected, shiftKey)
}
/>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<WallPanel scenes={result.data.findScenes.scenes} sceneQueue={queue} />
);
}
if (filter.displayMode === DisplayMode.Tagger) {
return <Tagger scenes={result.data.findScenes.scenes} queue={queue} />;
}
return <DeleteScenesDialog selected={selectedScenes} onClose={onClose} />;
}
function renderContent(
result: FindScenesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
return (
<>
{maybeRenderSceneGenerateDialog(selectedIds)}
{maybeRenderSceneIdentifyDialog(selectedIds)}
{maybeRenderSceneExportDialog(selectedIds)}
{renderMergeDialog()}
{renderScenes(result, filter, selectedIds)}
</>
);
}
return <TaggerContext>{listData.template}</TaggerContext>;
return (
<TaggerContext>
<SceneItemList
zoomable
selectable
filterHook={filterHook}
persistState={persistState}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
defaultSort={defaultSort}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
</TaggerContext>
);
};
export default SceneList;

View file

@ -2,24 +2,41 @@ import cloneDeep from "lodash-es/cloneDeep";
import React from "react";
import { useHistory } from "react-router-dom";
import { useIntl } from "react-intl";
import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "src/components/Shared/constants";
import Mousetrap from "mousetrap";
import { FindSceneMarkersQueryResult } from "src/core/generated-graphql";
import { queryFindSceneMarkers } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import {
queryFindSceneMarkers,
useFindSceneMarkers,
} from "src/core/StashService";
import NavUtils from "src/utils/navigation";
import { PersistanceLevel, useSceneMarkersList } from "src/hooks/ListHook";
import { makeItemList, PersistanceLevel } from "../List/ItemList";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { WallPanel } from "../Wall/WallPanel";
const SceneMarkerItemList = makeItemList({
filterMode: GQL.FilterMode.SceneMarkers,
useResult: useFindSceneMarkers,
getItems(result: GQL.FindSceneMarkersQueryResult) {
return result?.data?.findSceneMarkers?.scene_markers ?? [];
},
getCount(result: GQL.FindSceneMarkersQueryResult) {
return result?.data?.findSceneMarkers?.count ?? 0;
},
});
interface ISceneMarkerList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
alterQuery?: boolean;
}
export const SceneMarkerList: React.FC<ISceneMarkerList> = ({ filterHook }) => {
export const SceneMarkerList: React.FC<ISceneMarkerList> = ({
filterHook,
alterQuery,
}) => {
const intl = useIntl();
const history = useHistory();
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.play_random" }),
@ -27,10 +44,10 @@ export const SceneMarkerList: React.FC<ISceneMarkerList> = ({ filterHook }) => {
},
];
const addKeybinds = (
result: FindSceneMarkersQueryResult,
function addKeybinds(
result: GQL.FindSceneMarkersQueryResult,
filter: ListFilterModel
) => {
) {
Mousetrap.bind("p r", () => {
playRandom(result, filter);
});
@ -38,18 +55,10 @@ export const SceneMarkerList: React.FC<ISceneMarkerList> = ({ filterHook }) => {
return () => {
Mousetrap.unbind("p r");
};
};
const listData = useSceneMarkersList({
otherOperations,
renderContent,
filterHook,
addKeybinds,
persistState: PersistanceLevel.ALL,
});
}
async function playRandom(
result: FindSceneMarkersQueryResult,
result: GQL.FindSceneMarkersQueryResult,
filter: ListFilterModel
) {
// query for a random scene
@ -61,7 +70,7 @@ export const SceneMarkerList: React.FC<ISceneMarkerList> = ({ filterHook }) => {
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindSceneMarkers(filterCopy);
if (singleResult?.data?.findSceneMarkers?.scene_markers?.length === 1) {
if (singleResult.data.findSceneMarkers.scene_markers.length === 1) {
// navigate to the scene player page
const url = NavUtils.makeSceneMarkerUrl(
singleResult.data.findSceneMarkers.scene_markers[0]
@ -72,29 +81,27 @@ export const SceneMarkerList: React.FC<ISceneMarkerList> = ({ filterHook }) => {
}
function renderContent(
result: FindSceneMarkersQueryResult,
result: GQL.FindSceneMarkersQueryResult,
filter: ListFilterModel
) {
if (!result?.data?.findSceneMarkers) return;
if (!result.data?.findSceneMarkers) return;
if (filter.displayMode === DisplayMode.Wall) {
return (
<WallPanel sceneMarkers={result.data.findSceneMarkers.scene_markers} />
);
}
}
const title_template = `${intl.formatMessage({
id: "markers",
})} ${TITLE_SUFFIX}`;
return (
<>
<Helmet
defaultTitle={title_template}
titleTemplate={`%s | ${title_template}`}
/>
{listData.template}
</>
<SceneMarkerItemList
filterHook={filterHook}
persistState={PersistanceLevel.ALL}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
/>
);
};

View file

@ -3,7 +3,7 @@ import { Route, Switch } from "react-router-dom";
import { useIntl } from "react-intl";
import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "src/components/Shared/constants";
import { PersistanceLevel } from "src/hooks/ListHook";
import { PersistanceLevel } from "../List/ItemList";
import { lazyComponent } from "src/utils/lazyComponent";
const SceneList = lazyComponent(() => import("./SceneList"));
@ -17,6 +17,10 @@ const Scenes: React.FC = () => {
const title_template = `${intl.formatMessage({
id: "scenes",
})} ${TITLE_SUFFIX}`;
const marker_title_template = `${intl.formatMessage({
id: "markers",
})} ${TITLE_SUFFIX}`;
return (
<>
<Helmet
@ -31,7 +35,19 @@ const Scenes: React.FC = () => {
<SceneList persistState={PersistanceLevel.ALL} {...props} />
)}
/>
<Route exact path="/scenes/markers" component={SceneMarkerList} />
<Route
exact
path="/scenes/markers"
render={() => (
<>
<Helmet
defaultTitle={marker_title_template}
titleTemplate={`%s | ${marker_title_template}`}
/>
<SceneMarkerList />
</>
)}
/>
<Route exact path="/scenes/new" component={SceneCreate} />
<Route path="/scenes/:id" component={Scene} />
</Switch>

View file

@ -237,7 +237,10 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
</React.Fragment>
}
>
<StudioScenesPanel studio={studio} />
<StudioScenesPanel
active={activeTabKey == "scenes"}
studio={studio}
/>
</Tab>
<Tab
eventKey="galleries"
@ -251,7 +254,10 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
</React.Fragment>
}
>
<StudioGalleriesPanel studio={studio} />
<StudioGalleriesPanel
active={activeTabKey == "galleries"}
studio={studio}
/>
</Tab>
<Tab
eventKey="images"
@ -265,7 +271,10 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
</React.Fragment>
}
>
<StudioImagesPanel studio={studio} />
<StudioImagesPanel
active={activeTabKey == "images"}
studio={studio}
/>
</Tab>
<Tab
eventKey="performers"
@ -279,7 +288,10 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
</React.Fragment>
}
>
<StudioPerformersPanel studio={studio} />
<StudioPerformersPanel
active={activeTabKey == "performers"}
studio={studio}
/>
</Tab>
<Tab
eventKey="movies"
@ -293,7 +305,10 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
</React.Fragment>
}
>
<StudioMoviesPanel studio={studio} />
<StudioMoviesPanel
active={activeTabKey == "movies"}
studio={studio}
/>
</Tab>
<Tab
eventKey="childstudios"
@ -307,7 +322,10 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
</React.Fragment>
}
>
<StudioChildrenPanel studio={studio} />
<StudioChildrenPanel
active={activeTabKey == "childstudios"}
studio={studio}
/>
</Tab>
</Tabs>
</div>

View file

@ -5,10 +5,12 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { StudioList } from "../StudioList";
interface IStudioChildrenPanel {
active: boolean;
studio: GQL.StudioDataFragment;
}
export const StudioChildrenPanel: React.FC<IStudioChildrenPanel> = ({
active,
studio,
}) => {
function filterHook(filter: ListFilterModel) {
@ -43,5 +45,5 @@ export const StudioChildrenPanel: React.FC<IStudioChildrenPanel> = ({
return filter;
}
return <StudioList fromParent filterHook={filterHook} />;
return <StudioList fromParent filterHook={filterHook} alterQuery={active} />;
};

View file

@ -4,12 +4,14 @@ import { GalleryList } from "src/components/Galleries/GalleryList";
import { useStudioFilterHook } from "src/core/studios";
interface IStudioGalleriesPanel {
active: boolean;
studio: GQL.StudioDataFragment;
}
export const StudioGalleriesPanel: React.FC<IStudioGalleriesPanel> = ({
active,
studio,
}) => {
const filterHook = useStudioFilterHook(studio);
return <GalleryList filterHook={filterHook} />;
return <GalleryList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -4,10 +4,14 @@ import { useStudioFilterHook } from "src/core/studios";
import { ImageList } from "src/components/Images/ImageList";
interface IStudioImagesPanel {
active: boolean;
studio: GQL.StudioDataFragment;
}
export const StudioImagesPanel: React.FC<IStudioImagesPanel> = ({ studio }) => {
export const StudioImagesPanel: React.FC<IStudioImagesPanel> = ({
active,
studio,
}) => {
const filterHook = useStudioFilterHook(studio);
return <ImageList filterHook={filterHook} />;
return <ImageList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -4,10 +4,14 @@ import { MovieList } from "src/components/Movies/MovieList";
import { useStudioFilterHook } from "src/core/studios";
interface IStudioMoviesPanel {
active: boolean;
studio: GQL.StudioDataFragment;
}
export const StudioMoviesPanel: React.FC<IStudioMoviesPanel> = ({ studio }) => {
export const StudioMoviesPanel: React.FC<IStudioMoviesPanel> = ({
active,
studio,
}) => {
const filterHook = useStudioFilterHook(studio);
return <MovieList filterHook={filterHook} />;
return <MovieList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -5,10 +5,12 @@ import { PerformerList } from "src/components/Performers/PerformerList";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
interface IStudioPerformersPanel {
active: boolean;
studio: GQL.StudioDataFragment;
}
export const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({
active,
studio,
}) => {
const studioCriterion = new StudiosCriterion();
@ -27,6 +29,10 @@ export const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({
const filterHook = useStudioFilterHook(studio);
return (
<PerformerList filterHook={filterHook} extraCriteria={extraCriteria} />
<PerformerList
filterHook={filterHook}
extraCriteria={extraCriteria}
alterQuery={active}
/>
);
};

View file

@ -4,10 +4,14 @@ import { SceneList } from "src/components/Scenes/SceneList";
import { useStudioFilterHook } from "src/core/studios";
interface IStudioScenesPanel {
active: boolean;
studio: GQL.StudioDataFragment;
}
export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({ studio }) => {
export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({
active,
studio,
}) => {
const filterHook = useStudioFilterHook(studio);
return <SceneList filterHook={filterHook} />;
return <SceneList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -3,30 +3,44 @@ import { useIntl } from "react-intl";
import cloneDeep from "lodash-es/cloneDeep";
import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import {
FindStudiosQueryResult,
SlimStudioDataFragment,
} from "src/core/generated-graphql";
queryFindStudios,
useFindStudios,
useStudiosDestroy,
} from "src/core/StashService";
import {
showWhenSelected,
makeItemList,
PersistanceLevel,
useStudiosList,
} from "src/hooks/ListHook";
showWhenSelected,
} from "../List/ItemList";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { queryFindStudios, useStudiosDestroy } from "src/core/StashService";
import { ExportDialog } from "../Shared/ExportDialog";
import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog";
import { StudioCard } from "./StudioCard";
const StudioItemList = makeItemList({
filterMode: GQL.FilterMode.Studios,
useResult: useFindStudios,
getItems(result: GQL.FindStudiosQueryResult) {
return result?.data?.findStudios?.studios ?? [];
},
getCount(result: GQL.FindStudiosQueryResult) {
return result?.data?.findStudios?.count ?? 0;
},
});
interface IStudioList {
fromParent?: boolean;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
alterQuery?: boolean;
}
export const StudioList: React.FC<IStudioList> = ({
fromParent,
filterHook,
alterQuery,
}) => {
const intl = useIntl();
const history = useHistory();
@ -49,10 +63,10 @@ export const StudioList: React.FC<IStudioList> = ({
},
];
const addKeybinds = (
result: FindStudiosQueryResult,
function addKeybinds(
result: GQL.FindStudiosQueryResult,
filter: ListFilterModel
) => {
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
@ -60,14 +74,14 @@ export const StudioList: React.FC<IStudioList> = ({
return () => {
Mousetrap.unbind("p r");
};
};
}
async function viewRandom(
result: FindStudiosQueryResult,
result: GQL.FindStudiosQueryResult,
filter: ListFilterModel
) {
// query for a random studio
if (result.data && result.data.findStudios) {
if (result.data?.findStudios) {
const { count } = result.data.findStudios;
const index = Math.floor(Math.random() * count);
@ -75,13 +89,8 @@ export const StudioList: React.FC<IStudioList> = ({
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindStudios(filterCopy);
if (
singleResult &&
singleResult.data &&
singleResult.data.findStudios &&
singleResult.data.findStudios.studios.length === 1
) {
const { id } = singleResult!.data!.findStudios!.studios[0];
if (singleResult.data.findStudios.studios.length === 1) {
const { id } = singleResult.data.findStudios.studios[0];
// navigate to the studio page
history.push(`/studios/${id}`);
}
@ -98,10 +107,15 @@ export const StudioList: React.FC<IStudioList> = ({
setIsExportDialogOpen(true);
}
function maybeRenderExportDialog(selectedIds: Set<string>) {
if (isExportDialogOpen) {
return (
<>
function renderContent(
result: GQL.FindStudiosQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
function maybeRenderExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
studios: {
@ -109,83 +123,74 @@ export const StudioList: React.FC<IStudioList> = ({
all: isExportAll,
},
}}
onClose={() => {
setIsExportDialogOpen(false);
}}
onClose={() => setIsExportDialogOpen(false)}
/>
</>
);
);
}
}
}
const renderDeleteDialog = (
selectedStudios: SlimStudioDataFragment[],
onClose: (confirmed: boolean) => void
) => (
<DeleteEntityDialog
selected={selectedStudios}
onClose={onClose}
singularEntity={intl.formatMessage({ id: "studio" })}
pluralEntity={intl.formatMessage({ id: "studios" })}
destroyMutation={useStudiosDestroy}
/>
);
function renderStudios() {
if (!result.data?.findStudios) return;
const listData = useStudiosList({
renderContent,
filterHook,
addKeybinds,
otherOperations,
selectable: true,
persistState: !fromParent ? PersistanceLevel.ALL : PersistanceLevel.NONE,
renderDeleteDialog,
});
function renderStudios(
result: FindStudiosQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
if (!result.data?.findStudios) return;
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="row px-xl-5 justify-content-center">
{result.data.findStudios.studios.map((studio) => (
<StudioCard
key={studio.id}
studio={studio}
hideParent={fromParent}
selecting={selectedIds.size > 0}
selected={selectedIds.has(studio.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
listData.onSelectChange(studio.id, selected, shiftKey)
}
/>
))}
</div>
);
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="row px-xl-5 justify-content-center">
{result.data.findStudios.studios.map((studio) => (
<StudioCard
key={studio.id}
studio={studio}
hideParent={fromParent}
selecting={selectedIds.size > 0}
selected={selectedIds.has(studio.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(studio.id, selected, shiftKey)
}
/>
))}
</div>
);
}
if (filter.displayMode === DisplayMode.List) {
return <h1>TODO</h1>;
}
if (filter.displayMode === DisplayMode.Wall) {
return <h1>TODO</h1>;
}
}
if (filter.displayMode === DisplayMode.List) {
return <h1>TODO</h1>;
}
if (filter.displayMode === DisplayMode.Wall) {
return <h1>TODO</h1>;
}
}
function renderContent(
result: FindStudiosQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
return (
<>
{maybeRenderExportDialog(selectedIds)}
{renderStudios(result, filter, selectedIds)}
{maybeRenderExportDialog()}
{renderStudios()}
</>
);
}
return listData.template;
function renderDeleteDialog(
selectedStudios: GQL.SlimStudioDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<DeleteEntityDialog
selected={selectedStudios}
onClose={onClose}
singularEntity={intl.formatMessage({ id: "studio" })}
pluralEntity={intl.formatMessage({ id: "studios" })}
destroyMutation={useStudiosDestroy}
/>
);
}
return (
<StudioItemList
selectable
filterHook={filterHook}
persistState={fromParent ? PersistanceLevel.NONE : PersistanceLevel.ALL}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderDeleteDialog={renderDeleteDialog}
/>
);
};

View file

@ -310,6 +310,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
<Tabs
id="tag-tabs"
mountOnEnter
unmountOnExit
activeKey={activeTabKey}
onSelect={setActiveTabKey}
>
@ -325,7 +326,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
</React.Fragment>
}
>
<TagScenesPanel tag={tag} />
<TagScenesPanel active={activeTabKey == "scenes"} tag={tag} />
</Tab>
<Tab
eventKey="images"
@ -339,7 +340,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
</React.Fragment>
}
>
<TagImagesPanel tag={tag} />
<TagImagesPanel active={activeTabKey == "images"} tag={tag} />
</Tab>
<Tab
eventKey="galleries"
@ -353,7 +354,10 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
</React.Fragment>
}
>
<TagGalleriesPanel tag={tag} />
<TagGalleriesPanel
active={activeTabKey == "galleries"}
tag={tag}
/>
</Tab>
<Tab
eventKey="markers"
@ -367,7 +371,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
</React.Fragment>
}
>
<TagMarkersPanel tag={tag} />
<TagMarkersPanel active={activeTabKey == "markers"} tag={tag} />
</Tab>
<Tab
eventKey="performers"
@ -381,7 +385,10 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
</React.Fragment>
}
>
<TagPerformersPanel tag={tag} />
<TagPerformersPanel
active={activeTabKey == "performers"}
tag={tag}
/>
</Tab>
</Tabs>
</div>

View file

@ -4,10 +4,14 @@ import { useTagFilterHook } from "src/core/tags";
import { GalleryList } from "src/components/Galleries/GalleryList";
interface ITagGalleriesPanel {
active: boolean;
tag: GQL.TagDataFragment;
}
export const TagGalleriesPanel: React.FC<ITagGalleriesPanel> = ({ tag }) => {
export const TagGalleriesPanel: React.FC<ITagGalleriesPanel> = ({
active,
tag,
}) => {
const filterHook = useTagFilterHook(tag);
return <GalleryList filterHook={filterHook} />;
return <GalleryList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -4,10 +4,11 @@ import { useTagFilterHook } from "src/core/tags";
import { ImageList } from "src/components/Images/ImageList";
interface ITagImagesPanel {
active: boolean;
tag: GQL.TagDataFragment;
}
export const TagImagesPanel: React.FC<ITagImagesPanel> = ({ tag }) => {
export const TagImagesPanel: React.FC<ITagImagesPanel> = ({ active, tag }) => {
const filterHook = useTagFilterHook(tag);
return <ImageList filterHook={filterHook} />;
return <ImageList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -8,10 +8,14 @@ import {
import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList";
interface ITagMarkersPanel {
active: boolean;
tag: GQL.TagDataFragment;
}
export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({ tag }) => {
export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({
active,
tag,
}) => {
function filterHook(filter: ListFilterModel) {
const tagValue = { id: tag.id, label: tag.name };
// if tag is already present, then we modify it, otherwise add
@ -47,5 +51,5 @@ export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({ tag }) => {
return filter;
}
return <SceneMarkerList filterHook={filterHook} />;
return <SceneMarkerList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -4,10 +4,14 @@ import { useTagFilterHook } from "src/core/tags";
import { PerformerList } from "src/components/Performers/PerformerList";
interface ITagPerformersPanel {
active: boolean;
tag: GQL.TagDataFragment;
}
export const TagPerformersPanel: React.FC<ITagPerformersPanel> = ({ tag }) => {
export const TagPerformersPanel: React.FC<ITagPerformersPanel> = ({
active,
tag,
}) => {
const filterHook = useTagFilterHook(tag);
return <PerformerList filterHook={filterHook} />;
return <PerformerList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -4,10 +4,11 @@ import { SceneList } from "src/components/Scenes/SceneList";
import { useTagFilterHook } from "src/core/tags";
interface ITagScenesPanel {
active: boolean;
tag: GQL.TagDataFragment;
}
export const TagScenesPanel: React.FC<ITagScenesPanel> = ({ tag }) => {
export const TagScenesPanel: React.FC<ITagScenesPanel> = ({ active, tag }) => {
const filterHook = useTagFilterHook(tag);
return <SceneList filterHook={filterHook} />;
return <SceneList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -1,20 +1,20 @@
import React, { useState } from "react";
import cloneDeep from "lodash-es/cloneDeep";
import Mousetrap from "mousetrap";
import { FindTagsQueryResult } from "src/core/generated-graphql";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import {
showWhenSelected,
useTagsList,
makeItemList,
PersistanceLevel,
} from "src/hooks/ListHook";
showWhenSelected,
} from "../List/ItemList";
import { Button } from "react-bootstrap";
import { Link, useHistory } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import {
queryFindTags,
mutateMetadataAutoTag,
useFindTags,
useTagDestroy,
useTagsDestroy,
} from "src/core/StashService";
@ -31,14 +31,33 @@ import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
interface ITagList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
alterQuery?: boolean;
}
export const TagList: React.FC<ITagList> = ({ filterHook }) => {
const TagItemList = makeItemList({
filterMode: GQL.FilterMode.Tags,
useResult: useFindTags,
getItems(result: GQL.FindTagsQueryResult) {
return result?.data?.findTags?.tags ?? [];
},
getCount(result: GQL.FindTagsQueryResult) {
return result?.data?.findTags?.count ?? 0;
},
});
export const TagList: React.FC<ITagList> = ({ filterHook, alterQuery }) => {
const Toast = useToast();
const [deletingTag, setDeletingTag] =
useState<Partial<GQL.TagDataFragment> | null>(null);
const [deleteTag] = useTagDestroy(getDeleteTagInput() as GQL.TagDestroyInput);
function getDeleteTagInput() {
const tagInput: Partial<GQL.TagDestroyInput> = {};
if (deletingTag) {
tagInput.id = deletingTag.id;
}
return tagInput as GQL.TagDestroyInput;
}
const [deleteTag] = useTagDestroy(getDeleteTagInput());
const intl = useIntl();
const history = useHistory();
@ -61,10 +80,10 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
},
];
const addKeybinds = (
result: FindTagsQueryResult,
function addKeybinds(
result: GQL.FindTagsQueryResult,
filter: ListFilterModel
) => {
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
@ -72,14 +91,14 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
return () => {
Mousetrap.unbind("p r");
};
};
}
async function viewRandom(
result: FindTagsQueryResult,
result: GQL.FindTagsQueryResult,
filter: ListFilterModel
) {
// query for a random tag
if (result.data && result.data.findTags) {
if (result.data?.findTags) {
const { count } = result.data.findTags;
const index = Math.floor(Math.random() * count);
@ -87,13 +106,8 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindTags(filterCopy);
if (
singleResult &&
singleResult.data &&
singleResult.data.findTags &&
singleResult.data.findTags.tags.length === 1
) {
const { id } = singleResult!.data!.findTags!.tags[0];
if (singleResult.data.findTags.tags.length === 1) {
const { id } = singleResult.data.findTags.tags[0];
// navigate to the tag page
history.push(`/tags/${id}`);
}
@ -110,68 +124,6 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
setIsExportDialogOpen(true);
}
function maybeRenderExportDialog(selectedIds: Set<string>) {
if (isExportDialogOpen) {
return (
<>
<ExportDialog
exportInput={{
tags: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={() => {
setIsExportDialogOpen(false);
}}
/>
</>
);
}
}
const renderDeleteDialog = (
selectedTags: GQL.TagDataFragment[],
onClose: (confirmed: boolean) => void
) => (
<DeleteEntityDialog
selected={selectedTags}
onClose={onClose}
singularEntity={intl.formatMessage({ id: "tag" })}
pluralEntity={intl.formatMessage({ id: "tags" })}
destroyMutation={useTagsDestroy}
onDeleted={() => {
selectedTags.forEach((t) =>
tagRelationHook(
t,
{ parents: t.parents ?? [], children: t.children ?? [] },
{ parents: [], children: [] }
)
);
}}
/>
);
const listData = useTagsList({
renderContent,
filterHook,
addKeybinds,
otherOperations,
selectable: true,
zoomable: true,
defaultZoomIndex: 0,
persistState: PersistanceLevel.ALL,
renderDeleteDialog,
});
function getDeleteTagInput() {
const tagInput: Partial<GQL.TagDestroyInput> = {};
if (deletingTag) {
tagInput.id = deletingTag.id;
}
return tagInput;
}
async function onAutoTag(tag: GQL.TagDataFragment) {
if (!tag) return;
try {
@ -211,165 +163,214 @@ export const TagList: React.FC<ITagList> = ({ filterHook }) => {
}
}
function renderTags(
result: FindTagsQueryResult,
function renderContent(
result: GQL.FindTagsQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
if (!result.data?.findTags) return;
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="row px-xl-5 justify-content-center">
{result.data.findTags.tags.map((tag) => (
<TagCard
key={tag.id}
tag={tag}
zoomIndex={filter.zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(tag.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
listData.onSelectChange(tag.id, selected, shiftKey)
}
/>
))}
</div>
);
}
if (filter.displayMode === DisplayMode.List) {
const deleteAlert = (
<ModalComponent
onHide={() => {}}
show={!!deletingTag}
icon={faTrashAlt}
accept={{
onClick: onDelete,
variant: "danger",
text: intl.formatMessage({ id: "actions.delete" }),
}}
cancel={{ onClick: () => setDeletingTag(null) }}
>
<span>
<FormattedMessage
id="dialogs.delete_confirm"
values={{ entityName: deletingTag && deletingTag.name }}
/>
</span>
</ModalComponent>
);
const tagElements = result.data.findTags.tags.map((tag) => {
function maybeRenderExportDialog() {
if (isExportDialogOpen) {
return (
<div key={tag.id} className="tag-list-row row">
<Link to={`/tags/${tag.id}`}>{tag.name}</Link>
<ExportDialog
exportInput={{
tags: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={() => setIsExportDialogOpen(false)}
/>
);
}
}
<div className="ml-auto">
<Button
variant="secondary"
className="tag-list-button"
onClick={() => onAutoTag(tag)}
>
<FormattedMessage id="actions.auto_tag" />
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagScenesUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.scenes"
values={{
count: tag.scene_count ?? 0,
}}
/>
: <FormattedNumber value={tag.scene_count ?? 0} />
</Link>
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagImagesUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.images"
values={{
count: tag.image_count ?? 0,
}}
/>
: <FormattedNumber value={tag.image_count ?? 0} />
</Link>
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagGalleriesUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.galleries"
values={{
count: tag.gallery_count ?? 0,
}}
/>
: <FormattedNumber value={tag.gallery_count ?? 0} />
</Link>
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagSceneMarkersUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.markers"
values={{
count: tag.scene_marker_count ?? 0,
}}
/>
: <FormattedNumber value={tag.scene_marker_count ?? 0} />
</Link>
</Button>
<span className="tag-list-count">
<FormattedMessage id="total" />:{" "}
<FormattedNumber
value={
(tag.scene_count || 0) +
(tag.scene_marker_count || 0) +
(tag.image_count || 0) +
(tag.gallery_count || 0)
}
/>
</span>
<Button variant="danger" onClick={() => setDeletingTag(tag)}>
<Icon icon={faTrashAlt} color="danger" />
</Button>
</div>
function renderTags() {
if (!result.data?.findTags) return;
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="row px-xl-5 justify-content-center">
{result.data.findTags.tags.map((tag) => (
<TagCard
key={tag.id}
tag={tag}
zoomIndex={filter.zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(tag.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(tag.id, selected, shiftKey)
}
/>
))}
</div>
);
});
}
if (filter.displayMode === DisplayMode.List) {
const deleteAlert = (
<ModalComponent
onHide={() => {}}
show={!!deletingTag}
icon={faTrashAlt}
accept={{
onClick: onDelete,
variant: "danger",
text: intl.formatMessage({ id: "actions.delete" }),
}}
cancel={{ onClick: () => setDeletingTag(null) }}
>
<span>
<FormattedMessage
id="dialogs.delete_confirm"
values={{ entityName: deletingTag && deletingTag.name }}
/>
</span>
</ModalComponent>
);
return (
<div className="col col-sm-8 m-auto">
{tagElements}
{deleteAlert}
</div>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return <h1>TODO</h1>;
}
}
const tagElements = result.data.findTags.tags.map((tag) => {
return (
<div key={tag.id} className="tag-list-row row">
<Link to={`/tags/${tag.id}`}>{tag.name}</Link>
function renderContent(
result: FindTagsQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
<div className="ml-auto">
<Button
variant="secondary"
className="tag-list-button"
onClick={() => onAutoTag(tag)}
>
<FormattedMessage id="actions.auto_tag" />
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagScenesUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.scenes"
values={{
count: tag.scene_count ?? 0,
}}
/>
: <FormattedNumber value={tag.scene_count ?? 0} />
</Link>
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagImagesUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.images"
values={{
count: tag.image_count ?? 0,
}}
/>
: <FormattedNumber value={tag.image_count ?? 0} />
</Link>
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagGalleriesUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.galleries"
values={{
count: tag.gallery_count ?? 0,
}}
/>
: <FormattedNumber value={tag.gallery_count ?? 0} />
</Link>
</Button>
<Button variant="secondary" className="tag-list-button">
<Link
to={NavUtils.makeTagSceneMarkersUrl(tag)}
className="tag-list-anchor"
>
<FormattedMessage
id="countables.markers"
values={{
count: tag.scene_marker_count ?? 0,
}}
/>
: <FormattedNumber value={tag.scene_marker_count ?? 0} />
</Link>
</Button>
<span className="tag-list-count">
<FormattedMessage id="total" />:{" "}
<FormattedNumber
value={
(tag.scene_count || 0) +
(tag.scene_marker_count || 0) +
(tag.image_count || 0) +
(tag.gallery_count || 0)
}
/>
</span>
<Button variant="danger" onClick={() => setDeletingTag(tag)}>
<Icon icon={faTrashAlt} color="danger" />
</Button>
</div>
</div>
);
});
return (
<div className="col col-sm-8 m-auto">
{tagElements}
{deleteAlert}
</div>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return <h1>TODO</h1>;
}
}
return (
<>
{maybeRenderExportDialog(selectedIds)}
{renderTags(result, filter, selectedIds)}
{maybeRenderExportDialog()}
{renderTags()}
</>
);
}
return listData.template;
function renderDeleteDialog(
selectedTags: GQL.TagDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<DeleteEntityDialog
selected={selectedTags}
onClose={onClose}
singularEntity={intl.formatMessage({ id: "tag" })}
pluralEntity={intl.formatMessage({ id: "tags" })}
destroyMutation={useTagsDestroy}
onDeleted={() => {
selectedTags.forEach((t) =>
tagRelationHook(
t,
{ parents: t.parents ?? [], children: t.children ?? [] },
{ parents: [], children: [] }
)
);
}}
/>
);
}
return (
<TagItemList
selectable
zoomable
defaultZoomIndex={0}
filterHook={filterHook}
persistState={PersistanceLevel.ALL}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderDeleteDialog={renderDeleteDialog}
/>
);
};

View file

@ -1,973 +0,0 @@
import clone from "lodash-es/clone";
import cloneDeep from "lodash-es/cloneDeep";
import isEqual from "lodash-es/isEqual";
import React, {
useCallback,
useRef,
useState,
useEffect,
useMemo,
useContext,
} from "react";
import { ApolloError } from "@apollo/client";
import { useHistory, useLocation } from "react-router-dom";
import Mousetrap from "mousetrap";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
SlimSceneDataFragment,
SceneMarkerDataFragment,
SlimGalleryDataFragment,
StudioDataFragment,
PerformerDataFragment,
FindScenesQueryResult,
FindSceneMarkersQueryResult,
FindGalleriesQueryResult,
FindStudiosQueryResult,
FindPerformersQueryResult,
FindMoviesQueryResult,
MovieDataFragment,
FindTagsQueryResult,
TagDataFragment,
FindImagesQueryResult,
SlimImageDataFragment,
FilterMode,
} from "src/core/generated-graphql";
import { useInterfaceLocalForage } from "src/hooks/LocalForage";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { ListFilter } from "src/components/List/ListFilter";
import { FilterTags } from "src/components/List/FilterTags";
import { Pagination, PaginationIndex } from "src/components/List/Pagination";
import {
useFindDefaultFilter,
useFindScenes,
useFindSceneMarkers,
useFindImages,
useFindMovies,
useFindStudios,
useFindGalleries,
useFindPerformers,
useFindTags,
} from "src/core/StashService";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { ListFilterOptions } from "src/models/list-filter/filter-options";
import { getFilterOptions } from "src/models/list-filter/factory";
import { ButtonToolbar } from "react-bootstrap";
import { ListViewOptions } from "src/components/List/ListViewOptions";
import { ListOperationButtons } from "src/components/List/ListOperationButtons";
import {
Criterion,
CriterionValue,
} from "src/models/list-filter/criteria/criterion";
import { AddFilterDialog } from "src/components/List/AddFilterDialog";
import TextUtils from "src/utils/text";
import { FormattedNumber } from "react-intl";
import { ConfigurationContext } from "./Config";
const getSelectedData = <I extends IDataItem>(
result: I[],
selectedIds: Set<string>
) => {
// find the selected items from the ids
const selectedResults: I[] = [];
selectedIds.forEach((id) => {
const item = result.find((s) => s.id === id);
if (item) {
selectedResults.push(item);
}
});
return selectedResults;
};
interface IListHookData {
filter: ListFilterModel;
template: React.ReactElement;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
onChangePage: (page: number) => void;
}
export interface IListHookOperation<T> {
text: string;
onClick: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => Promise<void>;
isDisplayed?: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => boolean;
postRefetch?: boolean;
icon?: IconDefinition;
buttonVariant?: string;
}
export enum PersistanceLevel {
// do not load default query or persist display mode
NONE,
// load default query, don't load or persist display mode
ALL,
// load and persist display mode only
VIEW,
}
interface IListHookOptions<T, E> {
persistState?: PersistanceLevel;
persistanceKey?: string;
defaultSort?: string;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
filterDialog?: (
criteria: Criterion<CriterionValue>[],
setCriteria: (v: Criterion<CriterionValue>[]) => void
) => React.ReactNode;
zoomable?: boolean;
selectable?: boolean;
defaultZoomIndex?: number;
otherOperations?: IListHookOperation<T>[];
renderContent: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>,
onChangePage: (page: number) => void,
pageCount: number
) => React.ReactNode;
renderEditDialog?: (
selected: E[],
onClose: (applied: boolean) => void
) => React.ReactNode;
renderDeleteDialog?: (
selected: E[],
onClose: (confirmed: boolean) => void
) => React.ReactNode;
addKeybinds?: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => () => void;
}
interface IDataItem {
id: string;
}
interface IQueryResult {
error?: ApolloError;
loading: boolean;
refetch: () => void;
}
interface IQuery<T extends IQueryResult, T2 extends IDataItem> {
filterMode: FilterMode;
useData: (filter?: ListFilterModel) => T;
getData: (data: T) => T2[];
getCount: (data: T) => number;
getMetadataByline: (data: T) => React.ReactNode;
}
interface IRenderListProps {
filter?: ListFilterModel;
filterOptions: ListFilterOptions;
onChangePage: (page: number) => void;
updateFilter: (filter: ListFilterModel) => void;
}
const useRenderList = <
QueryResult extends IQueryResult,
QueryData extends IDataItem
>({
filter,
filterOptions,
onChangePage,
addKeybinds,
useData,
getCount,
getData,
getMetadataByline,
otherOperations,
renderContent,
zoomable,
selectable,
renderEditDialog,
renderDeleteDialog,
updateFilter,
filterDialog,
persistState,
}: IListHookOptions<QueryResult, QueryData> &
IQuery<QueryResult, QueryData> &
IRenderListProps) => {
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string>();
const [editingCriterion, setEditingCriterion] =
useState<Criterion<CriterionValue>>();
const [newCriterion, setNewCriterion] = useState(false);
const result = useData(filter);
const totalCount = getCount(result);
const metadataByline = getMetadataByline(result);
const items = getData(result);
// handle case where page is more than there are pages
useEffect(() => {
if (filter === undefined) return;
const pages = Math.ceil(totalCount / filter.itemsPerPage);
if (pages > 0 && filter.currentPage > pages) {
onChangePage(pages);
}
}, [filter, onChangePage, totalCount]);
// set up hotkeys
useEffect(() => {
if (filter === undefined) return;
Mousetrap.bind("f", () => setNewCriterion(true));
return () => {
Mousetrap.unbind("f");
};
}, [filter]);
useEffect(() => {
if (filter === undefined) return;
const pages = Math.ceil(totalCount / filter.itemsPerPage);
Mousetrap.bind("right", () => {
if (filter.currentPage < pages) {
onChangePage(filter.currentPage + 1);
}
});
Mousetrap.bind("left", () => {
if (filter.currentPage > 1) {
onChangePage(filter.currentPage - 1);
}
});
Mousetrap.bind("shift+right", () => {
onChangePage(Math.min(pages, filter.currentPage + 10));
});
Mousetrap.bind("shift+left", () => {
onChangePage(Math.max(1, filter.currentPage - 10));
});
Mousetrap.bind("ctrl+end", () => {
onChangePage(pages);
});
Mousetrap.bind("ctrl+home", () => {
onChangePage(1);
});
return () => {
Mousetrap.unbind("right");
Mousetrap.unbind("left");
Mousetrap.unbind("shift+right");
Mousetrap.unbind("shift+left");
Mousetrap.unbind("ctrl+end");
Mousetrap.unbind("ctrl+home");
};
}, [filter, onChangePage, totalCount]);
useEffect(() => {
if (filter === undefined) return;
if (addKeybinds) {
const unbindExtras = addKeybinds(result, filter, selectedIds);
return () => {
unbindExtras();
};
}
}, [addKeybinds, filter, result, selectedIds]);
// Don't continue if filter is undefined
// There are no hooks below this point so this is valid
if (filter === undefined) return;
function singleSelect(id: string, selected: boolean) {
setLastClickedId(id);
const newSelectedIds = clone(selectedIds);
if (selected) {
newSelectedIds.add(id);
} else {
newSelectedIds.delete(id);
}
setSelectedIds(newSelectedIds);
}
function selectRange(startIndex: number, endIndex: number) {
let start = startIndex;
let end = endIndex;
if (start > end) {
const tmp = start;
start = end;
end = tmp;
}
const subset = items.slice(start, end + 1);
const newSelectedIds: Set<string> = new Set();
subset.forEach((item) => {
newSelectedIds.add(item.id);
});
setSelectedIds(newSelectedIds);
}
function multiSelect(id: string) {
let startIndex = 0;
let thisIndex = -1;
if (lastClickedId) {
startIndex = items.findIndex((item) => {
return item.id === lastClickedId;
});
}
thisIndex = items.findIndex((item) => {
return item.id === id;
});
selectRange(startIndex, thisIndex);
}
function onSelectChange(id: string, selected: boolean, shiftKey: boolean) {
if (shiftKey) {
multiSelect(id);
} else {
singleSelect(id, selected);
}
}
function onSelectAll() {
const newSelectedIds: Set<string> = new Set();
items.forEach((item) => {
newSelectedIds.add(item.id);
});
setSelectedIds(newSelectedIds);
setLastClickedId(undefined);
}
const onSelectNone = () => {
const newSelectedIds: Set<string> = new Set();
setSelectedIds(newSelectedIds);
setLastClickedId(undefined);
};
const onChangeZoom = (newZoomIndex: number) => {
const newFilter = cloneDeep(filter);
newFilter.zoomIndex = newZoomIndex;
updateFilter(newFilter);
};
const onOperationClicked = async (o: IListHookOperation<QueryResult>) => {
await o.onClick(result, filter, selectedIds);
if (o.postRefetch) {
result.refetch();
}
};
const operations =
otherOperations &&
otherOperations.map((o) => ({
text: o.text,
onClick: () => {
onOperationClicked(o);
},
isDisplayed: () => {
if (o.isDisplayed) {
return o.isDisplayed(result, filter, selectedIds);
}
return true;
},
icon: o.icon,
buttonVariant: o.buttonVariant,
}));
function onEdit() {
setIsEditDialogOpen(true);
}
function onEditDialogClosed(applied: boolean) {
if (applied) {
onSelectNone();
}
setIsEditDialogOpen(false);
// refetch
result.refetch();
}
function onDelete() {
setIsDeleteDialogOpen(true);
}
function onDeleteDialogClosed(deleted: boolean) {
if (deleted) {
onSelectNone();
}
setIsDeleteDialogOpen(false);
// refetch
result.refetch();
}
const renderPagination = () => (
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
onChangePage={onChangePage}
/>
);
const maybeRenderContent = () => {
if (result.loading || result.error) {
return;
}
const pages = Math.ceil(totalCount / filter.itemsPerPage);
return (
<>
{renderPagination()}
<PaginationIndex
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
/>
{renderContent(result, filter, selectedIds, onChangePage, pages)}
<PaginationIndex
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
/>
{renderPagination()}
</>
);
};
const onChangeDisplayMode = (displayMode: DisplayMode) => {
const newFilter = cloneDeep(filter);
newFilter.displayMode = displayMode;
updateFilter(newFilter);
};
const onAddCriterion = (
criterion: Criterion<CriterionValue>,
oldId?: string
) => {
const newFilter = cloneDeep(filter);
// Find if we are editing an existing criteria, then modify that. Or create a new one.
const existingIndex = newFilter.criteria.findIndex((c) => {
// If we modified an existing criterion, then look for the old id.
const id = oldId || criterion.getId();
return c.getId() === id;
});
if (existingIndex === -1) {
newFilter.criteria.push(criterion);
} else {
newFilter.criteria[existingIndex] = criterion;
}
// Remove duplicate modifiers
newFilter.criteria = newFilter.criteria.filter((obj, pos, arr) => {
return arr.map((mapObj) => mapObj.getId()).indexOf(obj.getId()) === pos;
});
newFilter.currentPage = 1;
updateFilter(newFilter);
setEditingCriterion(undefined);
setNewCriterion(false);
};
const onRemoveCriterion = (removedCriterion: Criterion<CriterionValue>) => {
const newFilter = cloneDeep(filter);
newFilter.criteria = newFilter.criteria.filter(
(criterion) => criterion.getId() !== removedCriterion.getId()
);
newFilter.currentPage = 1;
updateFilter(newFilter);
};
const updateCriteria = (c: Criterion<CriterionValue>[]) => {
const newFilter = cloneDeep(filter);
newFilter.criteria = c.slice();
setNewCriterion(false);
};
function onCancelAddCriterion() {
setEditingCriterion(undefined);
setNewCriterion(false);
}
const content = (
<div>
<ButtonToolbar className="justify-content-center">
<ListFilter
onFilterUpdate={updateFilter}
filter={filter}
filterOptions={filterOptions}
openFilterDialog={() => setNewCriterion(true)}
filterDialogOpen={newCriterion ?? editingCriterion}
persistState={persistState}
/>
<ListOperationButtons
onSelectAll={selectable ? onSelectAll : undefined}
onSelectNone={selectable ? onSelectNone : undefined}
otherOperations={operations}
itemsSelected={selectedIds.size > 0}
onEdit={renderEditDialog ? onEdit : undefined}
onDelete={renderDeleteDialog ? onDelete : undefined}
/>
<ListViewOptions
displayMode={filter.displayMode}
displayModeOptions={filterOptions.displayModeOptions}
onSetDisplayMode={onChangeDisplayMode}
zoomIndex={zoomable ? filter.zoomIndex : undefined}
onSetZoom={zoomable ? onChangeZoom : undefined}
/>
</ButtonToolbar>
<FilterTags
criteria={filter.criteria}
onEditCriterion={(c) => setEditingCriterion(c)}
onRemoveCriterion={onRemoveCriterion}
/>
{(newCriterion || editingCriterion) && !filterDialog && (
<AddFilterDialog
filterOptions={filterOptions}
onAddCriterion={onAddCriterion}
onCancel={onCancelAddCriterion}
editingCriterion={editingCriterion}
existingCriterions={filter.criteria}
/>
)}
{newCriterion &&
filterDialog &&
filterDialog(filter.criteria, (c) => updateCriteria(c))}
{isEditDialogOpen &&
renderEditDialog &&
renderEditDialog(
getSelectedData(getData(result), selectedIds),
(applied) => onEditDialogClosed(applied)
)}
{isDeleteDialogOpen &&
renderDeleteDialog &&
renderDeleteDialog(
getSelectedData(getData(result), selectedIds),
(deleted) => onDeleteDialogClosed(deleted)
)}
{result.loading ? <LoadingIndicator /> : undefined}
{result.error ? <h1>{result.error.message}</h1> : undefined}
{maybeRenderContent()}
</div>
);
return { contentTemplate: content, onSelectChange };
};
const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
options: IListHookOptions<QueryResult, QueryData> &
IQuery<QueryResult, QueryData>
): IListHookData => {
const filterOptions = getFilterOptions(options.filterMode);
const history = useHistory();
const location = useLocation();
const [interfaceState, setInterfaceState] = useInterfaceLocalForage();
const [filterInitialised, setFilterInitialised] = useState(false);
const { configuration: config } = useContext(ConfigurationContext);
// Store initial pathname to prevent hooks from operating outside this page
const originalPathName = useRef(location.pathname);
const persistanceKey = options.persistanceKey ?? options.filterMode;
const defaultSort = options.defaultSort ?? filterOptions.defaultSortBy;
const defaultDisplayMode = filterOptions.displayModeOptions[0];
const createNewFilter = useCallback(() => {
const filter = new ListFilterModel(
options.filterMode,
config,
defaultSort,
defaultDisplayMode,
options.defaultZoomIndex
);
filter.configureFromQueryString(history.location.search);
return filter;
}, [
options.filterMode,
config,
history,
defaultSort,
defaultDisplayMode,
options.defaultZoomIndex,
]);
const [filter, setFilter] = useState<ListFilterModel>(createNewFilter);
const updateSavedFilter = useCallback(
(updatedFilter: ListFilterModel) => {
setInterfaceState((prevState) => {
if (!prevState.queryConfig) {
prevState.queryConfig = {};
}
const oldFilter = prevState.queryConfig[persistanceKey]?.filter ?? "";
const newFilter = new URLSearchParams(oldFilter);
newFilter.set("disp", String(updatedFilter.displayMode));
return {
...prevState,
queryConfig: {
...prevState.queryConfig,
[persistanceKey]: {
...prevState.queryConfig[persistanceKey],
filter: newFilter.toString(),
},
},
};
});
},
[persistanceKey, setInterfaceState]
);
const { data: defaultFilter, loading: defaultFilterLoading } =
useFindDefaultFilter(options.filterMode);
const updateQueryParams = useCallback(
(newFilter: ListFilterModel) => {
const newParams = newFilter.makeQueryParameters();
history.replace({ ...history.location, search: newParams });
},
[history]
);
const updateFilter = useCallback(
(newFilter: ListFilterModel) => {
setFilter(newFilter);
updateQueryParams(newFilter);
if (options.persistState === PersistanceLevel.VIEW) {
updateSavedFilter(newFilter);
}
},
[options.persistState, updateSavedFilter, updateQueryParams]
);
// 'Startup' hook, initialises the filters
useEffect(() => {
// Only run once
if (filterInitialised) return;
let newFilter = filter.clone();
if (options.persistState === PersistanceLevel.ALL) {
// only set default filter if query params are empty
if (!history.location.search) {
// wait until default filter is loaded
if (defaultFilterLoading) return;
if (defaultFilter?.findDefaultFilter) {
newFilter.currentPage = 1;
try {
newFilter.configureFromJSON(defaultFilter.findDefaultFilter.filter);
} catch (err) {
console.log(err);
// ignore
}
// #1507 - reset random seed when loaded
newFilter.randomSeed = -1;
}
}
} else if (options.persistState === PersistanceLevel.VIEW) {
// wait until forage is initialised
if (interfaceState.loading) return;
const storedQuery = interfaceState.data?.queryConfig?.[persistanceKey];
if (options.persistState === PersistanceLevel.VIEW && storedQuery) {
const displayMode = new URLSearchParams(storedQuery.filter).get("disp");
if (displayMode) {
newFilter.displayMode = Number.parseInt(displayMode, 10);
}
}
}
setFilter(newFilter);
updateQueryParams(newFilter);
setFilterInitialised(true);
}, [
filterInitialised,
filter,
history,
options.persistState,
updateQueryParams,
defaultFilter,
defaultFilterLoading,
interfaceState,
persistanceKey,
]);
// This hook runs on every page location change (ie navigation),
// and updates the filter accordingly.
useEffect(() => {
if (!filterInitialised) return;
// Only update on page the hook was mounted on
if (location.pathname !== originalPathName.current) {
return;
}
// Re-init filters on empty new query params
if (!location.search) {
setFilter(createNewFilter);
setFilterInitialised(false);
return;
}
setFilter((prevFilter) => {
let newFilter = prevFilter.clone();
newFilter.configureFromQueryString(location.search);
if (!isEqual(newFilter, prevFilter)) {
return newFilter;
} else {
return prevFilter;
}
});
}, [filterInitialised, createNewFilter, location]);
const onChangePage = useCallback(
(page: number) => {
const newFilter = cloneDeep(filter);
newFilter.currentPage = page;
updateFilter(newFilter);
window.scrollTo(0, 0);
},
[filter, updateFilter]
);
const renderFilter = useMemo(() => {
if (filterInitialised) {
return options.filterHook
? options.filterHook(cloneDeep(filter))
: filter;
}
}, [filterInitialised, filter, options]);
const renderList = useRenderList({
...options,
filter: renderFilter,
filterOptions,
onChangePage,
updateFilter,
});
const template = renderList ? (
renderList.contentTemplate
) : (
<LoadingIndicator />
);
function onSelectChange(id: string, selected: boolean, shiftKey: boolean) {
if (renderList) {
renderList.onSelectChange(id, selected, shiftKey);
}
}
return {
filter,
template,
onSelectChange,
onChangePage,
};
};
export const useScenesList = (
props: IListHookOptions<FindScenesQueryResult, SlimSceneDataFragment>
) =>
useList<FindScenesQueryResult, SlimSceneDataFragment>({
...props,
filterMode: FilterMode.Scenes,
useData: useFindScenes,
getData: (result: FindScenesQueryResult) =>
result?.data?.findScenes?.scenes ?? [],
getCount: (result: FindScenesQueryResult) =>
result?.data?.findScenes?.count ?? 0,
getMetadataByline: (result: FindScenesQueryResult) => {
const duration = result?.data?.findScenes?.duration;
const size = result?.data?.findScenes?.filesize;
const filesize = size ? TextUtils.fileSize(size) : undefined;
if (!duration && !size) {
return;
}
const separator = duration && size ? " - " : "";
return (
<span className="scenes-stats">
&nbsp;(
{duration ? (
<span className="scenes-duration">
{TextUtils.secondsAsTimeString(duration, 3)}
</span>
) : undefined}
{separator}
{size && filesize ? (
<span className="scenes-size">
<FormattedNumber
value={filesize.size}
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
filesize.unit
)}
/>
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
</span>
) : undefined}
)
</span>
);
},
});
export const useSceneMarkersList = (
props: IListHookOptions<FindSceneMarkersQueryResult, SceneMarkerDataFragment>
) =>
useList<FindSceneMarkersQueryResult, SceneMarkerDataFragment>({
...props,
filterMode: FilterMode.SceneMarkers,
useData: useFindSceneMarkers,
getData: (result: FindSceneMarkersQueryResult) =>
result?.data?.findSceneMarkers?.scene_markers ?? [],
getCount: (result: FindSceneMarkersQueryResult) =>
result?.data?.findSceneMarkers?.count ?? 0,
getMetadataByline: () => [],
});
export const useImagesList = (
props: IListHookOptions<FindImagesQueryResult, SlimImageDataFragment>
) =>
useList<FindImagesQueryResult, SlimImageDataFragment>({
...props,
filterMode: FilterMode.Images,
useData: useFindImages,
getData: (result: FindImagesQueryResult) =>
result?.data?.findImages?.images ?? [],
getCount: (result: FindImagesQueryResult) =>
result?.data?.findImages?.count ?? 0,
getMetadataByline: (result: FindImagesQueryResult) => {
const megapixels = result?.data?.findImages?.megapixels;
const size = result?.data?.findImages?.filesize;
const filesize = size ? TextUtils.fileSize(size) : undefined;
if (!megapixels && !size) {
return;
}
const separator = megapixels && size ? " - " : "";
return (
<span className="images-stats">
&nbsp;(
{megapixels ? (
<span className="images-megapixels">
<FormattedNumber value={megapixels} /> Megapixels
</span>
) : undefined}
{separator}
{size && filesize ? (
<span className="images-size">
<FormattedNumber
value={filesize.size}
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
filesize.unit
)}
/>
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
</span>
) : undefined}
)
</span>
);
},
});
export const useGalleriesList = (
props: IListHookOptions<FindGalleriesQueryResult, SlimGalleryDataFragment>
) =>
useList<FindGalleriesQueryResult, SlimGalleryDataFragment>({
...props,
filterMode: FilterMode.Galleries,
useData: useFindGalleries,
getData: (result: FindGalleriesQueryResult) =>
result?.data?.findGalleries?.galleries ?? [],
getCount: (result: FindGalleriesQueryResult) =>
result?.data?.findGalleries?.count ?? 0,
getMetadataByline: () => [],
});
export const useStudiosList = (
props: IListHookOptions<FindStudiosQueryResult, StudioDataFragment>
) =>
useList<FindStudiosQueryResult, StudioDataFragment>({
...props,
filterMode: FilterMode.Studios,
useData: useFindStudios,
getData: (result: FindStudiosQueryResult) =>
result?.data?.findStudios?.studios ?? [],
getCount: (result: FindStudiosQueryResult) =>
result?.data?.findStudios?.count ?? 0,
getMetadataByline: () => [],
});
export const usePerformersList = (
props: IListHookOptions<FindPerformersQueryResult, PerformerDataFragment>
) =>
useList<FindPerformersQueryResult, PerformerDataFragment>({
...props,
filterMode: FilterMode.Performers,
useData: useFindPerformers,
getData: (result: FindPerformersQueryResult) =>
result?.data?.findPerformers?.performers ?? [],
getCount: (result: FindPerformersQueryResult) =>
result?.data?.findPerformers?.count ?? 0,
getMetadataByline: () => [],
});
export const useMoviesList = (
props: IListHookOptions<FindMoviesQueryResult, MovieDataFragment>
) =>
useList<FindMoviesQueryResult, MovieDataFragment>({
...props,
filterMode: FilterMode.Movies,
useData: useFindMovies,
getData: (result: FindMoviesQueryResult) =>
result?.data?.findMovies?.movies ?? [],
getCount: (result: FindMoviesQueryResult) =>
result?.data?.findMovies?.count ?? 0,
getMetadataByline: () => [],
});
export const useTagsList = (
props: IListHookOptions<FindTagsQueryResult, TagDataFragment>
) =>
useList<FindTagsQueryResult, TagDataFragment>({
...props,
filterMode: FilterMode.Tags,
useData: useFindTags,
getData: (result: FindTagsQueryResult) =>
result?.data?.findTags?.tags ?? [],
getCount: (result: FindTagsQueryResult) =>
result?.data?.findTags?.count ?? 0,
getMetadataByline: () => [],
});
export const showWhenSelected = <T extends IQueryResult>(
_result: T,
_filter: ListFilterModel,
selectedIds: Set<string>
) => {
return selectedIds.size > 0;
};

View file

@ -40,8 +40,8 @@ const DEFAULT_PARAMS = {
// TODO: handle customCriteria
export class ListFilterModel {
public mode: FilterMode;
private config: ConfigDataFragment | undefined;
public searchTerm?: string;
private config?: ConfigDataFragment;
public searchTerm: string = "";
public currentPage = DEFAULT_PARAMS.currentPage;
public itemsPerPage = DEFAULT_PARAMS.itemsPerPage;
public sortDirection: SortDirectionEnum = SortDirectionEnum.Asc;
@ -54,7 +54,7 @@ export class ListFilterModel {
public constructor(
mode: FilterMode,
config: ConfigDataFragment | undefined,
config?: ConfigDataFragment,
defaultSort?: string,
defaultDisplayMode?: DisplayMode,
defaultZoomIndex?: number
@ -62,7 +62,9 @@ export class ListFilterModel {
this.mode = mode;
this.config = config;
this.sortBy = defaultSort;
if (defaultDisplayMode !== undefined) this.displayMode = defaultDisplayMode;
if (defaultDisplayMode !== undefined) {
this.displayMode = defaultDisplayMode;
}
if (defaultZoomIndex !== undefined) {
this.defaultZoomIndex = defaultZoomIndex;
this.zoomIndex = defaultZoomIndex;
@ -98,9 +100,6 @@ export class ListFilterModel {
}
if (params.q !== undefined) {
this.searchTerm = params.q;
} else {
// #1795 - reset search term if not provided
this.searchTerm = "";
}
this.currentPage = params.p ?? 1;
if (params.z !== undefined) {
@ -318,7 +317,7 @@ export class ListFilterModel {
sortdir:
this.sortDirection === SortDirectionEnum.Desc ? "desc" : undefined,
disp: this.displayMode,
q: this.searchTerm,
q: this.searchTerm || undefined,
z: this.zoomIndex,
c: encodedCriteria,
};