Files
stash/ui/v2.5/src/components/Images/ImageList.tsx
DingDongSoLong4 6b59b9643c 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>
2023-03-07 13:42:51 +11:00

391 lines
10 KiB
TypeScript

import React, { useCallback, useState, useMemo, MouseEvent } from "react";
import { FormattedNumber, 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 { 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 { ImageCard } from "./ImageCard";
import { EditImagesDialog } from "./EditImagesDialog";
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[];
onChangePage: (page: number) => void;
currentPage: number;
pageCount: number;
handleImageOpen: (index: number) => void;
}
const ImageWall: React.FC<IImageWallProps> = ({ images, handleImageOpen }) => {
const thumbs = images.map((image, index) => (
<div
role="link"
tabIndex={index}
key={image.id}
onClick={() => handleImageOpen(index)}
onKeyPress={() => handleImageOpen(index)}
>
<img
src={image.paths.thumbnail ?? ""}
loading="lazy"
className="gallery-image"
alt={objectTitle(image)}
/>
</div>
));
return (
<div className="gallery">
<div className="flexbin">{thumbs}</div>
</div>
);
};
interface IImageListImages {
images: GQL.SlimImageDataFragment[];
filter: ListFilterModel;
selectedIds: Set<string>;
onChangePage: (page: number) => void;
pageCount: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
slideshowRunning: boolean;
setSlideshowRunning: (running: boolean) => void;
}
const ImageListImages: React.FC<IImageListImages> = ({
images,
filter,
selectedIds,
onChangePage,
pageCount,
onSelectChange,
slideshowRunning,
setSlideshowRunning,
}) => {
const handleLightBoxPage = useCallback(
(direction: number) => {
if (direction === -1) {
if (filter.currentPage === 1) {
onChangePage(pageCount);
} else {
onChangePage(filter.currentPage - 1);
}
} else if (direction === 1) {
if (filter.currentPage === pageCount) {
// return to the first page
onChangePage(1);
} else {
onChangePage(filter.currentPage + 1);
}
}
},
[onChangePage, filter.currentPage, pageCount]
);
const handleClose = useCallback(() => {
setSlideshowRunning(false);
}, [setSlideshowRunning]);
const lightboxState = useMemo(() => {
return {
images,
showNavigation: false,
pageCallback: pageCount > 1 ? handleLightBoxPage : undefined,
pageHeader: `Page ${filter.currentPage} / ${pageCount}`,
slideshowEnabled: slideshowRunning,
onClose: handleClose,
};
}, [
images,
pageCount,
filter.currentPage,
slideshowRunning,
handleClose,
handleLightBoxPage,
]);
const showLightbox = useLightbox(lightboxState);
const handleImageOpen = useCallback(
(index) => {
setSlideshowRunning(true);
showLightbox(index, true);
},
[showLightbox, setSlideshowRunning]
);
function onPreview(index: number, ev: MouseEvent) {
handleImageOpen(index);
ev.preventDefault();
}
function renderImageCard(
index: number,
image: GQL.SlimImageDataFragment,
zoomIndex: number
) {
return (
<ImageCard
key={image.id}
image={image}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(image.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(image.id, selected, shiftKey)
}
onPreview={
selectedIds.size < 1 ? (ev) => onPreview(index, ev) : undefined
}
/>
);
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="row justify-content-center">
{images.map((image, index) =>
renderImageCard(index, image, filter.zoomIndex)
)}
</div>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<ImageWall
images={images}
onChangePage={onChangePage}
currentPage={filter.currentPage}
pageCount={pageCount}
handleImageOpen={handleImageOpen}
/>
);
}
// should not happen
return <></>;
};
const ImageItemList = makeItemList({
filterMode: GQL.FilterMode.Images,
useResult: useFindImages,
getItems(result: GQL.FindImagesQueryResult) {
return result?.data?.findImages?.images ?? [];
},
getCount(result: GQL.FindImagesQueryResult) {
return result?.data?.findImages?.count ?? 0;
},
renderMetadataByline(result: GQL.FindImagesQueryResult) {
const megapixels = result?.data?.findImages?.megapixels;
const size = result?.data?.findImages?.filesize;
const filesize = size ? TextUtils.fileSize(size) : undefined;
if (!megapixels && !size) {
return;
}
const separator = megapixels && size ? " - " : "";
return (
<span className="images-stats">
&nbsp;(
{megapixels ? (
<span className="images-megapixels">
<FormattedNumber value={megapixels} /> Megapixels
</span>
) : undefined}
{separator}
{size && filesize ? (
<span className="images-size">
<FormattedNumber
value={filesize.size}
maximumFractionDigits={TextUtils.fileSizeFractionalDigits(
filesize.unit
)}
/>
{` ${TextUtils.formatFileSizeUnit(filesize.unit)}`}
</span>
) : undefined}
)
</span>
);
},
});
interface IImageList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
persistState?: PersistanceLevel;
persistanceKey?: string;
alterQuery?: boolean;
extraOperations?: IItemListOperation<GQL.FindImagesQueryResult>[];
}
export const ImageList: React.FC<IImageList> = ({
filterHook,
persistState,
persistanceKey,
alterQuery,
extraOperations,
}) => {
const intl = useIntl();
const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false);
const otherOperations = [
...(extraOperations ?? []),
{
text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll,
},
];
function addKeybinds(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
return () => {
Mousetrap.unbind("p r");
};
}
async function viewRandom(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel
) {
// query for a random image
if (result.data?.findImages) {
const { count } = result.data.findImages;
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindImages(filterCopy);
if (singleResult.data.findImages.images.length === 1) {
const { id } = singleResult.data.findImages.images[0];
// navigate to the image player page
history.push(`/images/${id}`);
}
}
}
async function onExport() {
setIsExportAll(false);
setIsExportDialogOpen(true);
}
async function onExportAll() {
setIsExportAll(true);
setIsExportDialogOpen(true);
}
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: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
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 renderEditDialog(
selectedImages: GQL.SlimImageDataFragment[],
onClose: (applied: boolean) => void
) {
return <EditImagesDialog selected={selectedImages} onClose={onClose} />;
}
function renderDeleteDialog(
selectedImages: GQL.SlimImageDataFragment[],
onClose: (confirmed: boolean) => void
) {
return <DeleteImagesDialog selected={selectedImages} onClose={onClose} />;
}
return (
<ImageItemList
zoomable
selectable
filterHook={filterHook}
persistState={persistState}
persistanceKey={persistanceKey}
alterQuery={alterQuery}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
);
};