Add sidebar to images list (#6607)

* Use effective filter for keybinds/view random
* Refactor ImageList to use sidebar
* Add performer age filter to gallery sidebar
* Port metadata info changes
* Fix incorrect patch component parameter
* Update plugin doc and types
This commit is contained in:
WithoutPants 2026-02-26 14:13:15 +11:00 committed by GitHub
parent c522e54805
commit ed58d18334
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 645 additions and 287 deletions

View file

@ -2,7 +2,7 @@ import React from "react";
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 { FilteredImageList } from "src/components/Images/ImageList";
import { showWhenSelected } from "src/components/List/ItemList";
import { mutateAddGalleryImages } from "src/core/StashService";
import { useToast } from "src/hooks/Toast";
@ -100,7 +100,7 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = PatchComponent(
];
return (
<ImageList
<FilteredImageList
filterHook={filterHook}
extraOperations={otherOperations}
alterQuery={active}

View file

@ -2,7 +2,7 @@ import React from "react";
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 { FilteredImageList } from "src/components/Images/ImageList";
import {
mutateRemoveGalleryImages,
mutateSetGalleryCover,
@ -142,7 +142,7 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> =
];
return (
<ImageList
<FilteredImageList
filterHook={filterHook}
alterQuery={active}
extraOperations={otherOperations}

View file

@ -49,6 +49,8 @@ import {
IItemListOperation,
} from "../List/FilteredListToolbar";
import { FilterTags } from "../List/FilterTags";
import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter";
import { PerformerAgeCriterionOption } from "src/models/list-filter/galleries";
const GalleryList: React.FC<{
galleries: GQL.SlimGalleryDataFragment[];
@ -169,6 +171,14 @@ const SidebarContent: React.FC<{
option={OrganizedCriterionOption}
filter={filter}
setFilter={setFilter}
sectionID="organized"
/>
<SidebarAgeFilter
title={<FormattedMessage id="performer_age" />}
option={PerformerAgeCriterionOption}
filter={filter}
setFilter={setFilter}
sectionID="performer_age"
/>
</GalleryFilterSidebarSections>
@ -282,7 +292,7 @@ export const FilteredGalleryList = PatchComponent(
setFilter,
});
useAddKeybinds(filter, totalCount);
useAddKeybinds(effectiveFilter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,
@ -313,7 +323,7 @@ export const FilteredGalleryList = PatchComponent(
result,
});
const viewRandom = useViewRandom(filter, totalCount);
const viewRandom = useViewRandom(effectiveFilter, totalCount);
function onExport(all: boolean) {
showModal(

View file

@ -265,7 +265,7 @@ export const FilteredGroupList = PatchComponent(
setFilter,
});
useAddKeybinds(filter, totalCount);
useAddKeybinds(effectiveFilter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,
@ -296,7 +296,7 @@ export const FilteredGroupList = PatchComponent(
result,
});
const viewRandom = useViewRandom(filter, totalCount);
const viewRandom = useViewRandom(effectiveFilter, totalCount);
function onExport(all: boolean) {
showModal(

View file

@ -1,5 +1,11 @@
import React, { useCallback, useState, useMemo, MouseEvent } from "react";
import { FormattedNumber, useIntl } from "react-intl";
import React, {
useCallback,
useState,
useMemo,
MouseEvent,
useEffect,
} from "react";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import cloneDeep from "lodash-es/cloneDeep";
import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap";
@ -9,11 +15,10 @@ import {
useFindImages,
useFindImagesMetadata,
} from "src/core/StashService";
import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
import { useFilteredItemList } 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 { ImageWallItem } from "./ImageWallItem";
import { EditImagesDialog } from "./EditImagesDialog";
import { DeleteImagesDialog } from "./DeleteImagesDialog";
@ -24,11 +29,43 @@ import { objectTitle } from "src/core/files";
import { useConfigurationContext } from "src/hooks/Config";
import { ImageCardGrid } from "./ImageCardGrid";
import { View } from "../List/views";
import { IItemListOperation } from "../List/FilteredListToolbar";
import {
FilteredListToolbar,
IItemListOperation,
} from "../List/FilteredListToolbar";
import { FileSize } from "../Shared/FileSize";
import { PatchComponent } from "src/patch";
import { PatchComponent, PatchContainerComponent } from "src/patch";
import { GenerateDialog } from "../Dialogs/GenerateDialog";
import { useModal } from "src/hooks/modal";
import {
Sidebar,
SidebarPane,
SidebarPaneContent,
SidebarStateContext,
useSidebarState,
} from "../Shared/Sidebar";
import { useCloseEditDelete, useFilterOperations } from "../List/util";
import {
FilteredSidebarHeader,
useFilteredSidebarKeybinds,
} from "../List/Filters/FilterSidebar";
import {
IListFilterOperation,
ListOperations,
} from "../List/ListOperationButtons";
import { FilterTags } from "../List/FilterTags";
import { Pagination, PaginationIndex } from "../List/Pagination";
import { LoadedContent } from "../List/PagedList";
import useFocus from "src/utils/focus";
import cx from "classnames";
import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter";
import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter";
import { SidebarTagsFilter } from "../List/Filters/TagsFilter";
import { SidebarRatingFilter } from "../List/Filters/RatingFilter";
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
import { Button } from "react-bootstrap";
import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized";
import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter";
import { PerformerAgeCriterionOption } from "src/models/list-filter/images";
interface IImageWallProps {
images: GQL.SlimImageDataFragment[];
@ -180,131 +217,125 @@ interface IImageListImages {
chapters?: GQL.GalleryChapterDataFragment[];
}
const ImageListImages: React.FC<IImageListImages> = ({
images,
filter,
selectedIds,
onChangePage,
pageCount,
onSelectChange,
slideshowRunning,
setSlideshowRunning,
chapters = [],
}) => {
const handleLightBoxPage = useCallback(
(props: { direction?: number; page?: number }) => {
const { direction, page: newPage } = props;
if (direction !== undefined) {
if (direction < 0) {
if (filter.currentPage === 1) {
onChangePage(pageCount);
} else {
onChangePage(filter.currentPage + direction);
}
} else if (direction > 0) {
if (filter.currentPage === pageCount) {
// return to the first page
onChangePage(1);
} else {
onChangePage(filter.currentPage + direction);
}
}
} else if (newPage !== undefined) {
onChangePage(newPage);
}
},
[onChangePage, filter.currentPage, pageCount]
);
const handleClose = useCallback(() => {
setSlideshowRunning(false);
}, [setSlideshowRunning]);
const lightboxState = useMemo(() => {
return {
images,
showNavigation: false,
pageCallback: pageCount > 1 ? handleLightBoxPage : undefined,
page: filter.currentPage,
pages: pageCount,
pageSize: filter.itemsPerPage,
slideshowEnabled: slideshowRunning,
onClose: handleClose,
};
}, [
const ImageList: React.FC<IImageListImages> = PatchComponent(
"ImageList",
({
images,
filter,
selectedIds,
onChangePage,
pageCount,
filter.currentPage,
filter.itemsPerPage,
onSelectChange,
slideshowRunning,
handleClose,
handleLightBoxPage,
]);
setSlideshowRunning,
chapters = [],
}) => {
const handleLightBoxPage = useCallback(
(props: { direction?: number; page?: number }) => {
const { direction, page: newPage } = props;
const showLightbox = useLightbox(
lightboxState,
filter.sortBy === "path" &&
filter.sortDirection === GQL.SortDirectionEnum.Asc
? chapters
: []
);
const handleImageOpen = useCallback(
(index) => {
setSlideshowRunning(true);
showLightbox({ initialIndex: index, slideshowEnabled: true });
},
[showLightbox, setSlideshowRunning]
);
function onPreview(index: number, ev: MouseEvent) {
handleImageOpen(index);
ev.preventDefault();
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<ImageCardGrid
images={images}
selectedIds={selectedIds}
zoomIndex={filter.zoomIndex}
onSelectChange={onSelectChange}
onPreview={onPreview}
/>
if (direction !== undefined) {
if (direction < 0) {
if (filter.currentPage === 1) {
onChangePage(pageCount);
} else {
onChangePage(filter.currentPage + direction);
}
} else if (direction > 0) {
if (filter.currentPage === pageCount) {
// return to the first page
onChangePage(1);
} else {
onChangePage(filter.currentPage + direction);
}
}
} else if (newPage !== undefined) {
onChangePage(newPage);
}
},
[onChangePage, filter.currentPage, pageCount]
);
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<ImageWall
images={images}
onChangePage={onChangePage}
currentPage={filter.currentPage}
pageCount={pageCount}
handleImageOpen={handleImageOpen}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
selecting={!!selectedIds && selectedIds.size > 0}
/>
const handleClose = useCallback(() => {
setSlideshowRunning(false);
}, [setSlideshowRunning]);
const lightboxState = useMemo(() => {
return {
images,
showNavigation: false,
pageCallback: pageCount > 1 ? handleLightBoxPage : undefined,
page: filter.currentPage,
pages: pageCount,
pageSize: filter.itemsPerPage,
slideshowEnabled: slideshowRunning,
onClose: handleClose,
};
}, [
images,
pageCount,
filter.currentPage,
filter.itemsPerPage,
slideshowRunning,
handleClose,
handleLightBoxPage,
]);
const showLightbox = useLightbox(
lightboxState,
filter.sortBy === "path" &&
filter.sortDirection === GQL.SortDirectionEnum.Asc
? chapters
: []
);
const handleImageOpen = useCallback(
(index) => {
setSlideshowRunning(true);
showLightbox({ initialIndex: index, slideshowEnabled: true });
},
[showLightbox, setSlideshowRunning]
);
function onPreview(index: number, ev: MouseEvent) {
handleImageOpen(index);
ev.preventDefault();
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<ImageCardGrid
images={images}
selectedIds={selectedIds}
zoomIndex={filter.zoomIndex}
onSelectChange={onSelectChange}
onPreview={onPreview}
/>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<ImageWall
images={images}
onChangePage={onChangePage}
currentPage={filter.currentPage}
pageCount={pageCount}
handleImageOpen={handleImageOpen}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
selecting={!!selectedIds && selectedIds.size > 0}
/>
);
}
// should not happen
return <></>;
}
// should not happen
return <></>;
};
function getItems(result: GQL.FindImagesQueryResult) {
return result?.data?.findImages?.images ?? [];
}
function getCount(result: GQL.FindImagesQueryResult) {
return result?.data?.findImages?.count ?? 0;
}
);
function renderMetadataByline(
result: GQL.FindImagesQueryResult,
metadataInfo?: GQL.FindImagesMetadataQueryResult
metadataInfo: GQL.FindImagesMetadataQueryResult | undefined
) {
const megapixels = metadataInfo?.data?.findImages?.megapixels;
const size = metadataInfo?.data?.findImages?.filesize;
@ -339,6 +370,130 @@ function renderMetadataByline(
);
}
const ImageFilterSidebarSections = PatchContainerComponent(
"FilteredImageList.SidebarSections"
);
const SidebarContent: React.FC<{
filter: ListFilterModel;
setFilter: (filter: ListFilterModel) => void;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
view?: View;
sidebarOpen: boolean;
onClose?: () => void;
showEditFilter: (editingCriterion?: string) => void;
count?: number;
focus?: ReturnType<typeof useFocus>;
}> = ({
filter,
setFilter,
filterHook,
view,
showEditFilter,
sidebarOpen,
onClose,
count,
focus,
}) => {
const showResultsId =
count !== undefined ? "actions.show_count_results" : "actions.show_results";
const hideStudios = view === View.StudioScenes;
return (
<>
<FilteredSidebarHeader
sidebarOpen={sidebarOpen}
showEditFilter={showEditFilter}
filter={filter}
setFilter={setFilter}
view={view}
focus={focus}
/>
<ImageFilterSidebarSections>
{!hideStudios && (
<SidebarStudiosFilter
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
/>
)}
<SidebarPerformersFilter
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
/>
<SidebarTagsFilter
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
/>
<SidebarRatingFilter filter={filter} setFilter={setFilter} />
<SidebarBooleanFilter
title={<FormattedMessage id="organized" />}
data-type={OrganizedCriterionOption.type}
option={OrganizedCriterionOption}
filter={filter}
setFilter={setFilter}
sectionID="organized"
/>
<SidebarAgeFilter
title={<FormattedMessage id="performer_age" />}
option={PerformerAgeCriterionOption}
filter={filter}
setFilter={setFilter}
sectionID="performer_age"
/>
</ImageFilterSidebarSections>
<div className="sidebar-footer">
<Button className="sidebar-close-button" onClick={onClose}>
<FormattedMessage id={showResultsId} values={{ count }} />
</Button>
</div>
</>
);
};
function useViewRandom(filter: ListFilterModel, count: number) {
const history = useHistory();
const viewRandom = useCallback(async () => {
// query for a random image
if (count === 0) {
return;
}
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}`);
}
}, [history, filter, count]);
return viewRandom;
}
function useAddKeybinds(filter: ListFilterModel, count: number) {
const viewRandom = useViewRandom(filter, count);
useEffect(() => {
Mousetrap.bind("p r", () => {
viewRandom();
});
return () => {
Mousetrap.unbind("p r");
};
}, [viewRandom]);
}
interface IImageList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
view?: View;
@ -347,28 +502,185 @@ interface IImageList {
chapters?: GQL.GalleryChapterDataFragment[];
}
export const ImageList: React.FC<IImageList> = PatchComponent(
"ImageList",
({ filterHook, view, alterQuery, extraOperations = [], chapters = [] }) => {
export const FilteredImageList = PatchComponent(
"FilteredImageList",
(props: IImageList) => {
const intl = useIntl();
const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false);
const filterMode = GQL.FilterMode.Images;
const searchFocus = useFocus();
const { modal, showModal, closeModal } = useModal();
const withSidebar = props.view !== View.GalleryImages;
const otherOperations: IItemListOperation<GQL.FindImagesQueryResult>[] = [
...extraOperations,
const {
filterHook,
view,
alterQuery,
extraOperations: providedOperations = [],
chapters,
} = props;
// States
const {
showSidebar,
setShowSidebar,
sectionOpen,
setSectionOpen,
loading: sidebarStateLoading,
} = useSidebarState(view);
const {
filterState,
queryResult,
metadataInfo,
modalState,
listSelect,
showEditFilter,
} = useFilteredItemList({
filterStateProps: {
filterMode: GQL.FilterMode.Images,
view,
useURL: alterQuery,
},
queryResultProps: {
useResult: useFindImages,
useMetadataInfo: useFindImagesMetadata,
getCount: (r) => r.data?.findImages.count ?? 0,
getItems: (r) => r.data?.findImages.images ?? [],
filterHook,
},
});
const { filter, setFilter } = filterState;
const { effectiveFilter, result, cachedResult, items, totalCount } =
queryResult;
const metadataByline = useMemo(() => {
if (cachedResult.loading) return null;
return renderMetadataByline(metadataInfo) ?? null;
}, [cachedResult.loading, metadataInfo]);
const {
selectedIds,
selectedItems,
onSelectChange,
onSelectAll,
onSelectNone,
onInvertSelection,
hasSelection,
} = listSelect;
const { modal, showModal, closeModal } = modalState;
// Utility hooks
const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({
filter,
setFilter,
});
useAddKeybinds(filter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,
});
useEffect(() => {
Mousetrap.bind("e", () => {
if (hasSelection) {
onEdit?.();
}
});
Mousetrap.bind("d d", () => {
if (hasSelection) {
onDelete?.();
}
});
return () => {
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
};
});
const onCloseEditDelete = useCloseEditDelete({
closeModal,
onSelectNone,
result,
});
const viewRandom = useViewRandom(effectiveFilter, totalCount);
function onExport(all: boolean) {
showModal(
<ExportDialog
exportInput={{
groups: {
ids: Array.from(selectedIds.values()),
all: all,
},
}}
onClose={() => closeModal()}
/>
);
}
function onEdit() {
showModal(
<EditImagesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}
function onDelete() {
showModal(
<DeleteImagesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}
const convertedExtraOperations: IListFilterOperation[] =
providedOperations.map((o) => ({
...o,
isDisplayed: o.isDisplayed
? () => o.isDisplayed!(result, filter, selectedIds)
: undefined,
onClick: () => {
o.onClick(result, filter, selectedIds);
},
}));
const otherOperations: IListFilterOperation[] = [
...convertedExtraOperations,
{
text: intl.formatMessage({ id: "actions.select_all" }),
onClick: () => onSelectAll(),
isDisplayed: () => totalCount > 0,
},
{
text: intl.formatMessage({ id: "actions.select_none" }),
onClick: () => onSelectNone(),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.invert_selection" }),
onClick: () => onInvertSelection(),
isDisplayed: () => totalCount > 0,
},
{
text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom,
},
{
text: `${intl.formatMessage({ id: "actions.generate" })}…`,
onClick: (result, filter, selectedIds) => {
onClick: () => {
showModal(
<GenerateDialog
type="image"
@ -376,101 +688,78 @@ export const ImageList: React.FC<IImageList> = PatchComponent(
onClose={() => closeModal()}
/>
);
return Promise.resolve();
},
isDisplayed: showWhenSelected,
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
isDisplayed: showWhenSelected,
onClick: () => onExport(false),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll,
onClick: () => onExport(true),
},
];
function addKeybinds(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
// render
if (sidebarStateLoading) return null;
return () => {
Mousetrap.unbind("p r");
};
}
const operations = (
<ListOperations
items={items.length}
hasSelection={hasSelection}
operations={otherOperations}
onEdit={onEdit}
onDelete={onDelete}
operationsMenuClassName="image-list-operations-dropdown"
/>
);
async function viewRandom(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel
) {
// query for a random image
if (result.data?.findImages) {
const { count } = result.data.findImages;
const pageCount = Math.ceil(totalCount / filter.itemsPerPage);
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}`);
}
}
}
const content = (
<>
<FilteredListToolbar
filter={filter}
listSelect={listSelect}
setFilter={setFilter}
showEditFilter={showEditFilter}
onDelete={onDelete}
onEdit={onEdit}
operationComponent={operations}
view={view}
zoomable
/>
async function onExport() {
setIsExportAll(false);
setIsExportDialogOpen(true);
}
<FilterTags
criteria={filter.criteria}
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
onRemoveCriterion={removeCriterion}
onRemoveAll={clearAllCriteria}
/>
async function onExportAll() {
setIsExportAll(true);
setIsExportDialogOpen(true);
}
<div className="pagination-index-container">
<Pagination
currentPage={filter.currentPage}
itemsPerPage={filter.itemsPerPage}
totalItems={totalCount}
onChangePage={(page) => setFilter(filter.changePage(page))}
/>
<PaginationIndex
loading={cachedResult.loading}
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
metadataByline={metadataByline}
/>
</div>
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
<LoadedContent loading={result.loading} error={result.error}>
<ImageList
filter={filter}
images={result.data.findImages.images}
onChangePage={onChangePage}
images={items}
onChangePage={(page) => setFilter(filter.changePage(page))}
onSelectChange={onSelectChange}
pageCount={pageCount}
selectedIds={selectedIds}
@ -478,54 +767,60 @@ export const ImageList: React.FC<IImageList> = PatchComponent(
setSlideshowRunning={setSlideshowRunning}
chapters={chapters}
/>
);
}
</LoadedContent>
return (
<>
{maybeRenderImageExportDialog()}
{renderImages()}
</>
);
}
{totalCount > filter.itemsPerPage && (
<div className="pagination-footer-container">
<div className="pagination-footer">
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
onChangePage={setPage}
pagePopupPlacement="top"
metadataByline={metadataByline}
/>
</div>
</div>
)}
</>
);
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} />;
if (!withSidebar) {
return content;
}
return (
<ItemListContext
filterMode={filterMode}
useResult={useFindImages}
useMetadataInfo={useFindImagesMetadata}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
selectable
<div
className={cx("item-list-container image-list", {
"hide-sidebar": !showSidebar,
})}
>
{modal}
<ItemList
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
renderMetadataByline={renderMetadataByline}
/>
</ItemListContext>
<SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>
<SidebarPane hideSidebar={!showSidebar}>
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
<SidebarContent
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
showEditFilter={showEditFilter}
view={view}
sidebarOpen={showSidebar}
onClose={() => setShowSidebar(false)}
count={cachedResult.loading ? undefined : totalCount}
focus={searchFocus}
/>
</Sidebar>
<SidebarPaneContent
onSidebarToggle={() => setShowSidebar(!showSidebar)}
>
{content}
</SidebarPaneContent>
</SidebarPane>
</SidebarStateContext.Provider>
</div>
);
}
);

View file

@ -3,11 +3,11 @@ import { Route, Switch } from "react-router-dom";
import { Helmet } from "react-helmet";
import { useTitleProps } from "src/hooks/title";
import Image from "./ImageDetails/Image";
import { ImageList } from "./ImageList";
import { FilteredImageList } from "./ImageList";
import { View } from "../List/views";
const Images: React.FC = () => {
return <ImageList view={View.Images} />;
return <FilteredImageList view={View.Images} />;
};
const ImageRoutes: React.FC = () => {

View file

@ -20,6 +20,7 @@ import {
FilterMode,
GalleryFilterType,
GroupFilterType,
ImageFilterType,
InputMaybe,
IntCriterionInput,
PerformerFilterType,
@ -524,6 +525,8 @@ interface IFilterType {
performer_count?: InputMaybe<IntCriterionInput>;
galleries_filter?: InputMaybe<GalleryFilterType>;
gallery_count?: InputMaybe<IntCriterionInput>;
images_filter?: InputMaybe<ImageFilterType>;
image_count?: InputMaybe<IntCriterionInput>;
groups_filter?: InputMaybe<GroupFilterType>;
group_count?: InputMaybe<IntCriterionInput>;
studios_filter?: InputMaybe<StudioFilterType>;
@ -578,6 +581,17 @@ export function setObjectFilter(
}
out.galleries_filter = relatedFilterOutput as GalleryFilterType;
break;
case FilterMode.Images:
// if empty, only get objects with galleries
if (empty) {
out.image_count = {
modifier: CriterionModifier.GreaterThan,
value: 0,
};
break;
}
out.images_filter = relatedFilterOutput as ImageFilterType;
break;
case FilterMode.Groups:
// if empty, only get objects with groups
if (empty) {

View file

@ -46,16 +46,21 @@ import { useConfigurationContext } from "src/hooks/Config";
import { useZoomKeybinds } from "./ZoomSlider";
import { DisplayMode } from "src/models/list-filter/types";
interface IFilteredItemList<T extends QueryResult, E extends IHasID = IHasID> {
interface IFilteredItemList<
T extends QueryResult,
E extends IHasID = IHasID,
M = unknown
> {
filterStateProps: IFilterStateHook;
queryResultProps: IQueryResultHook<T, E>;
queryResultProps: IQueryResultHook<T, E, M>;
}
// Provides the common state and behaviour for filtered item list components
export function useFilteredItemList<
T extends QueryResult,
E extends IHasID = IHasID
>(props: IFilteredItemList<T, E>) {
E extends IHasID = IHasID,
M = unknown
>(props: IFilteredItemList<T, E, M>) {
const { configuration: config } = useConfigurationContext();
// States
@ -70,7 +75,7 @@ export function useFilteredItemList<
filter,
...props.queryResultProps,
});
const { result, items, totalCount, pages } = queryResult;
const { result, items, totalCount, pages, metadataInfo } = queryResult;
const listSelect = useListSelect(items);
const { onSelectAll, onSelectNone, onInvertSelection } = listSelect;
@ -107,6 +112,7 @@ export function useFilteredItemList<
return {
filterState,
queryResult,
metadataInfo,
listSelect,
modalState,
showEditFilter,

View file

@ -509,23 +509,27 @@ export function useCachedQueryResult<T extends QueryResult>(
export interface IQueryResultHook<
T extends QueryResult,
E extends IHasID = IHasID
E extends IHasID = IHasID,
M = unknown
> {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
useResult: (filter: ListFilterModel) => T;
useMetadataInfo?: (filter: ListFilterModel) => M;
getCount: (data: T) => number;
getItems: (data: T) => E[];
}
export function useQueryResult<
T extends QueryResult,
E extends IHasID = IHasID
E extends IHasID = IHasID,
M = unknown
>(
props: IQueryResultHook<T, E> & {
props: IQueryResultHook<T, E, M> & {
filter: ListFilterModel;
}
) {
const { filter, filterHook, useResult, getItems, getCount } = props;
const { filter, filterHook, useResult, useMetadataInfo, getItems, getCount } =
props;
const effectiveFilter = useMemo(() => {
if (filterHook) {
@ -534,7 +538,14 @@ export function useQueryResult<
return filter;
}, [filter, filterHook]);
// metadata filter is the effective filter with the sort, page size and page number removed
const metadataFilter = useMemo(
() => effectiveFilter.metadataInfo(),
[effectiveFilter]
);
const result = useResult(effectiveFilter);
const metadataInfo = useMetadataInfo?.(metadataFilter);
// use cached query result for pagination and metadata rendering
const cachedResult = useCachedQueryResult(effectiveFilter, result);
@ -549,6 +560,7 @@ export function useQueryResult<
return {
effectiveFilter,
metadataInfo,
result,
cachedResult,
items,

View file

@ -1,6 +1,6 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { ImageList } from "src/components/Images/ImageList";
import { FilteredImageList } from "src/components/Images/ImageList";
import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
@ -14,7 +14,7 @@ export const PerformerImagesPanel: React.FC<IPerformerImagesPanel> =
PatchComponent("PerformerImagesPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
<ImageList
<FilteredImageList
filterHook={filterHook}
alterQuery={active}
view={View.PerformerImages}

View file

@ -423,7 +423,7 @@ export const FilteredPerformerList = PatchComponent(
setFilter,
});
useAddKeybinds(filter, totalCount);
useAddKeybinds(effectiveFilter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,
@ -454,7 +454,7 @@ export const FilteredPerformerList = PatchComponent(
result,
});
const viewRandom = useViewRandom(filter, totalCount);
const viewRandom = useViewRandom(effectiveFilter, totalCount);
function onExport(all: boolean) {
showModal(

View file

@ -412,7 +412,7 @@ export const FilteredSceneList = PatchComponent(
setFilter,
});
useAddKeybinds(filter, totalCount);
useAddKeybinds(effectiveFilter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,

View file

@ -54,7 +54,7 @@ const SceneMarkerList: React.FC<{
selectedIds: Set<string>;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}> = PatchComponent(
"SceneList",
"SceneMarkerList",
({ markers, filter, selectedIds, onSelectChange }) => {
if (markers.length === 0) {
return null;

View file

@ -1,7 +1,7 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { useStudioFilterHook } from "src/core/studios";
import { ImageList } from "src/components/Images/ImageList";
import { FilteredImageList } from "src/components/Images/ImageList";
import { View } from "src/components/List/views";
interface IStudioImagesPanel {
@ -17,7 +17,7 @@ export const StudioImagesPanel: React.FC<IStudioImagesPanel> = ({
}) => {
const filterHook = useStudioFilterHook(studio, showChildStudioContent);
return (
<ImageList
<FilteredImageList
filterHook={filterHook}
alterQuery={active}
view={View.StudioImages}

View file

@ -251,7 +251,7 @@ export const FilteredStudioList = PatchComponent(
setFilter,
});
useAddKeybinds(filter, totalCount);
useAddKeybinds(effectiveFilter, totalCount);
useFilteredSidebarKeybinds({
showSidebar,
setShowSidebar,
@ -282,7 +282,7 @@ export const FilteredStudioList = PatchComponent(
result,
});
const viewRandom = useViewRandom(filter, totalCount);
const viewRandom = useViewRandom(effectiveFilter, totalCount);
function onExport(all: boolean) {
showModal(

View file

@ -1,7 +1,7 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { useTagFilterHook } from "src/core/tags";
import { ImageList } from "src/components/Images/ImageList";
import { FilteredImageList } from "src/components/Images/ImageList";
import { View } from "src/components/List/views";
interface ITagImagesPanel {
@ -17,7 +17,7 @@ export const TagImagesPanel: React.FC<ITagImagesPanel> = ({
}) => {
const filterHook = useTagFilterHook(tag, showSubTagContent);
return (
<ImageList
<FilteredImageList
filterHook={filterHook}
alterQuery={active}
view={View.TagImages}

View file

@ -230,7 +230,12 @@ Returns `void`.
- `ExternalLinkButtons`
- `ExternalLinksButton`
- `FilteredGalleryList`
- `FilteredGroupList`
- `FilteredImageList`
- `FilteredPerformerList`
- `FilteredSceneList`
- `FilteredSceneMarkerList`
- `FilteredStudioList`
- `FolderSelect`
- `FrontPage`
- `GalleryCard`
@ -248,6 +253,7 @@ Returns `void`.
- `GroupCard`
- `GroupCardGrid`
- `GroupIDSelect`
- `GroupList`
- `GroupRecommendationRow`
- `GroupSelect`
- `GroupSelect.sort`
@ -262,6 +268,7 @@ Returns `void`.
- `ImageDetailPanel`
- `ImageGridCard`
- `ImageInput`
- `ImageList`
- `ImageRecommendationRow`
- `LightboxLink`
- `LoadingIndicator`
@ -286,6 +293,7 @@ Returns `void`.
- `PerformerHeaderImage`
- `PerformerIDSelect`
- `PerformerImagesPanel`
- `PerformerList`
- `PerformerPage`
- `PerformerRecommendationRow`
- `PerformerScenesPanel`
@ -310,6 +318,7 @@ Returns `void`.
- `SceneMarkerCard.Image`
- `SceneMarkerCard.Popovers`
- `SceneMarkerCardsGrid`
- `SceneMarkerList`
- `SceneMarkerRecommendationRow`
- `SceneList`
- `ScenePage`
@ -329,6 +338,7 @@ Returns `void`.
- `StudioCardGrid`
- `StudioDetailsPanel`
- `StudioIDSelect`
- `StudioList`
- `StudioRecommendationRow`
- `StudioSelect`
- `StudioSelect.sort`

View file

@ -44,6 +44,9 @@ const displayModeOptions = [
DisplayMode.Wall,
];
export const PerformerAgeCriterionOption =
createMandatoryNumberCriterionOption("performer_age");
const criterionOptions = [
createStringCriterionOption("title"),
createStringCriterionOption("code", "scene_code"),
@ -61,7 +64,7 @@ const criterionOptions = [
PerformerTagsCriterionOption,
PerformersCriterionOption,
createMandatoryNumberCriterionOption("performer_count"),
createMandatoryNumberCriterionOption("performer_age"),
PerformerAgeCriterionOption,
PerformerFavoriteCriterionOption,
createMandatoryNumberCriterionOption("image_count"),
// StudioTagsCriterionOption,

View file

@ -16,7 +16,6 @@ import { OrientationCriterionOption } from "./criteria/orientation";
import { StudiosCriterionOption } from "./criteria/studios";
import {
PerformerTagsCriterionOption,
// StudioTagsCriterionOption,
TagsCriterionOption,
} from "./criteria/tags";
import { ListFilterOptions, MediaSortByOptions } from "./filter-options";
@ -43,6 +42,10 @@ const sortByOptions = [
},
]);
const displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall];
export const PerformerAgeCriterionOption =
createMandatoryNumberCriterionOption("performer_age");
const criterionOptions = [
createStringCriterionOption("title"),
createStringCriterionOption("code", "scene_code"),
@ -65,7 +68,7 @@ const criterionOptions = [
PerformerTagsCriterionOption,
PerformersCriterionOption,
createMandatoryNumberCriterionOption("performer_count"),
createMandatoryNumberCriterionOption("performer_age"),
PerformerAgeCriterionOption,
PerformerFavoriteCriterionOption,
// StudioTagsCriterionOption,
StudiosCriterionOption,

View file

@ -667,7 +667,12 @@ declare namespace PluginApi {
ExternalLinkButtons: React.FC<any>;
ExternalLinksButton: React.FC<any>;
FilteredGalleryList: React.FC<any>;
FilteredGroupList: React.FC<any>;
FilteredImageList: React.FC<any>;
FilteredPerformerList: React.FC<any>;
FilteredSceneList: React.FC<any>;
FilteredSceneMarkerList: React.FC<any>;
FilteredStudioList: React.FC<any>;
FolderSelect: React.FC<any>;
FrontPage: React.FC<any>;
GalleryCard: React.FC<any>;