From ed58d183341502fcdd198594ce33d3a07568c396 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:13:15 +1100 Subject: [PATCH] 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 --- .../GalleryDetails/GalleryAddPanel.tsx | 4 +- .../GalleryDetails/GalleryImagesPanel.tsx | 4 +- .../src/components/Galleries/GalleryList.tsx | 14 +- ui/v2.5/src/components/Groups/GroupList.tsx | 4 +- ui/v2.5/src/components/Images/ImageList.tsx | 801 ++++++++++++------ ui/v2.5/src/components/Images/Images.tsx | 4 +- .../List/Filters/LabeledIdFilter.tsx | 14 + ui/v2.5/src/components/List/ItemList.tsx | 16 +- ui/v2.5/src/components/List/util.ts | 20 +- .../PerformerDetails/PerformerImagesPanel.tsx | 4 +- .../components/Performers/PerformerList.tsx | 4 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 2 +- .../src/components/Scenes/SceneMarkerList.tsx | 2 +- .../StudioDetails/StudioImagesPanel.tsx | 4 +- ui/v2.5/src/components/Studios/StudioList.tsx | 4 +- .../Tags/TagDetails/TagImagesPanel.tsx | 4 +- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 10 + ui/v2.5/src/models/list-filter/galleries.ts | 5 +- ui/v2.5/src/models/list-filter/images.ts | 7 +- ui/v2.5/src/pluginApi.d.ts | 5 + 20 files changed, 645 insertions(+), 287 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx index 275c4263b..6fbb12f15 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx @@ -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 = PatchComponent( ]; return ( - = ]; return ( - + } + option={PerformerAgeCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="performer_age" /> @@ -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( diff --git a/ui/v2.5/src/components/Groups/GroupList.tsx b/ui/v2.5/src/components/Groups/GroupList.tsx index 6ce00831c..69961f783 100644 --- a/ui/v2.5/src/components/Groups/GroupList.tsx +++ b/ui/v2.5/src/components/Groups/GroupList.tsx @@ -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( diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index eee789bb1..8c11abdee 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -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 = ({ - 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 = 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 ( - + 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 ( - 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 ( + + ); + } + if (filter.displayMode === DisplayMode.Wall) { + return ( + 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; +}> = ({ + 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 ( + <> + + + + {!hideStudios && ( + + )} + + + + } + data-type={OrganizedCriterionOption.type} + option={OrganizedCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="organized" + /> + } + option={PerformerAgeCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="performer_age" + /> + + +
+ +
+ + ); +}; + +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 = 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(false); - const filterMode = GQL.FilterMode.Images; + const searchFocus = useFocus(); - const { modal, showModal, closeModal } = useModal(); + const withSidebar = props.view !== View.GalleryImages; - const otherOperations: IItemListOperation[] = [ - ...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( + closeModal()} + /> + ); + } + + function onEdit() { + showModal( + + ); + } + + function onDelete() { + showModal( + + ); + } + + 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( = 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 = ( + + ); - 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 = ( + <> + - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } +
+ setFilter(filter.changePage(page))} + /> + +
- function renderContent( - result: GQL.FindImagesQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: ( - id: string, - selected: boolean, - shiftKey: boolean - ) => void, - onChangePage: (page: number) => void, - pageCount: number - ) { - function maybeRenderImageExportDialog() { - if (isExportDialogOpen) { - return ( - setIsExportDialogOpen(false)} - /> - ); - } - } - - function renderImages() { - if (!result.data?.findImages) return; - - return ( - + setFilter(filter.changePage(page))} onSelectChange={onSelectChange} pageCount={pageCount} selectedIds={selectedIds} @@ -478,54 +767,60 @@ export const ImageList: React.FC = PatchComponent( setSlideshowRunning={setSlideshowRunning} chapters={chapters} /> - ); - } + - return ( - <> - {maybeRenderImageExportDialog()} - {renderImages()} - - ); - } + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} + + ); - function renderEditDialog( - selectedImages: GQL.SlimImageDataFragment[], - onClose: (applied: boolean) => void - ) { - return ; - } - - function renderDeleteDialog( - selectedImages: GQL.SlimImageDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ; + if (!withSidebar) { + return content; } return ( - {modal} - - + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + {content} + + + + ); } ); diff --git a/ui/v2.5/src/components/Images/Images.tsx b/ui/v2.5/src/components/Images/Images.tsx index 91edfdf79..932bbc2c1 100644 --- a/ui/v2.5/src/components/Images/Images.tsx +++ b/ui/v2.5/src/components/Images/Images.tsx @@ -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 ; + return ; }; const ImageRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index f19472d64..e006d6b50 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -20,6 +20,7 @@ import { FilterMode, GalleryFilterType, GroupFilterType, + ImageFilterType, InputMaybe, IntCriterionInput, PerformerFilterType, @@ -524,6 +525,8 @@ interface IFilterType { performer_count?: InputMaybe; galleries_filter?: InputMaybe; gallery_count?: InputMaybe; + images_filter?: InputMaybe; + image_count?: InputMaybe; groups_filter?: InputMaybe; group_count?: InputMaybe; studios_filter?: InputMaybe; @@ -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) { diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 67d09e721..962e3fc4c 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -46,16 +46,21 @@ import { useConfigurationContext } from "src/hooks/Config"; import { useZoomKeybinds } from "./ZoomSlider"; import { DisplayMode } from "src/models/list-filter/types"; -interface IFilteredItemList { +interface IFilteredItemList< + T extends QueryResult, + E extends IHasID = IHasID, + M = unknown +> { filterStateProps: IFilterStateHook; - queryResultProps: IQueryResultHook; + queryResultProps: IQueryResultHook; } // Provides the common state and behaviour for filtered item list components export function useFilteredItemList< T extends QueryResult, - E extends IHasID = IHasID ->(props: IFilteredItemList) { + E extends IHasID = IHasID, + M = unknown +>(props: IFilteredItemList) { 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, diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index d870c631f..89c32222f 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -509,23 +509,27 @@ export function useCachedQueryResult( 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 & { + props: IQueryResultHook & { 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, diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx index 7b088e5be..bd1484a17 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx @@ -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 = PatchComponent("PerformerImagesPanel", ({ active, performer }) => { const filterHook = usePerformerFilterHook(performer); return ( - ; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; }> = PatchComponent( - "SceneList", + "SceneMarkerList", ({ markers, filter, selectedIds, onSelectChange }) => { if (markers.length === 0) { return null; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx index a81c91462..f81599ceb 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx @@ -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 = ({ }) => { const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( - = ({ }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); return ( - ; ExternalLinksButton: React.FC; FilteredGalleryList: React.FC; + FilteredGroupList: React.FC; + FilteredImageList: React.FC; + FilteredPerformerList: React.FC; FilteredSceneList: React.FC; + FilteredSceneMarkerList: React.FC; + FilteredStudioList: React.FC; FolderSelect: React.FC; FrontPage: React.FC; GalleryCard: React.FC;