mirror of
https://github.com/stashapp/stash.git
synced 2025-12-15 21:03:22 +01:00
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:
parent
2d4384169a
commit
6b59b9643c
40 changed files with 2008 additions and 2006 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
(
|
||||
{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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
756
ui/v2.5/src/components/List/ItemList.tsx
Normal file
756
ui/v2.5/src/components/List/ItemList.tsx
Normal 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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 <></>;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
(
|
||||
{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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
(
|
||||
{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">
|
||||
(
|
||||
{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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue