mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
* 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>
391 lines
10 KiB
TypeScript
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">
|
|
(
|
|
{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}
|
|
/>
|
|
);
|
|
};
|