diff --git a/ui/v2.5/src/components/Galleries/Galleries.tsx b/ui/v2.5/src/components/Galleries/Galleries.tsx index 8f4591ac0..c2c6e9236 100644 --- a/ui/v2.5/src/components/Galleries/Galleries.tsx +++ b/ui/v2.5/src/components/Galleries/Galleries.tsx @@ -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"; diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 651ef066c..77d436664 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -260,10 +260,16 @@ export const GalleryPage: React.FC = ({ gallery }) => { - + - + diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx index 4f54ac445..b423b1104 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx @@ -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 = ({ gallery }) => { +export const GalleryAddPanel: React.FC = ({ + active, + gallery, +}) => { const Toast = useToast(); const intl = useIntl(); @@ -93,6 +97,10 @@ export const GalleryAddPanel: React.FC = ({ gallery }) => { ]; return ( - + ); }; diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx index 69fd7a6ee..af393a0a5 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx @@ -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 = ({ + active, gallery, }) => { const intl = useIntl(); @@ -95,6 +100,7 @@ export const GalleryImagesPanel: React.FC = ({ return ( ListFilterModel; persistState?: PersistanceLevel; + alterQuery?: boolean; } export const GalleryList: React.FC = ({ filterHook, persistState, + alterQuery, }) => { const intl = useIntl(); const history = useHistory(); @@ -53,10 +63,10 @@ export const GalleryList: React.FC = ({ }, ]; - 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 = ({ 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 = ({ setIsExportDialogOpen(true); } - function maybeRenderGalleryExportDialog(selectedIds: Set) { - if (isExportDialogOpen) { - return ( - <> + function renderContent( + result: GQL.FindGalleriesQueryResult, + filter: ListFilterModel, + selectedIds: Set, + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void + ) { + function maybeRenderGalleryExportDialog() { + if (isExportDialogOpen) { + return ( = ({ all: isExportAll, }, }} - onClose={() => { - setIsExportDialogOpen(false); - }} + onClose={() => setIsExportDialogOpen(false)} /> - - ); + ); + } + } + + function renderGalleries() { + if (!result.data?.findGalleries) return; + + if (filter.displayMode === DisplayMode.Grid) { + return ( +
+ {result.data.findGalleries.galleries.map((gallery) => ( + 0} + selected={selectedIds.has(gallery.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(gallery.id, selected, shiftKey) + } + /> + ))} +
+ ); + } + if (filter.displayMode === DisplayMode.List) { + return ( + + + + + + + + + {result.data.findGalleries.galleries.map((gallery) => ( + + + + + ))} + +
{intl.formatMessage({ id: "actions.preview" })} + {intl.formatMessage({ id: "title" })} +
+ + {gallery.cover ? ( + {gallery.title + ) : undefined} + + + + {galleryTitle(gallery)} ({gallery.image_count}{" "} + {gallery.image_count === 1 ? "image" : "images"}) + +
+ ); + } + if (filter.displayMode === DisplayMode.Wall) { + return ( +
+
+ {result.data.findGalleries.galleries.map((gallery) => ( + + ))} +
+
+ ); + } } - } - function renderEditGalleriesDialog( - selectedImages: SlimGalleryDataFragment[], - onClose: (applied: boolean) => void - ) { return ( <> - + {maybeRenderGalleryExportDialog()} + {renderGalleries()} ); } - function renderDeleteGalleriesDialog( - selectedImages: SlimGalleryDataFragment[], + function renderEditDialog( + selectedImages: GQL.SlimGalleryDataFragment[], + onClose: (applied: boolean) => void + ) { + return ; + } + + function renderDeleteDialog( + selectedImages: GQL.SlimGalleryDataFragment[], onClose: (confirmed: boolean) => void ) { return ( - <> - - + ); } - function renderGalleries( - result: FindGalleriesQueryResult, - filter: ListFilterModel, - selectedIds: Set - ) { - if (!result.data || !result.data.findGalleries) { - return; - } - if (filter.displayMode === DisplayMode.Grid) { - return ( -
- {result.data.findGalleries.galleries.map((gallery) => ( - 0} - selected={selectedIds.has(gallery.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - listData.onSelectChange(gallery.id, selected, shiftKey) - } - /> - ))} -
- ); - } - if (filter.displayMode === DisplayMode.List) { - return ( - - - - - - - - - {result.data.findGalleries.galleries.map((gallery) => ( - - - - - ))} - -
{intl.formatMessage({ id: "actions.preview" })} - {intl.formatMessage({ id: "title" })} -
- - {gallery.cover ? ( - {gallery.title - ) : undefined} - - - - {galleryTitle(gallery)} ({gallery.image_count}{" "} - {gallery.image_count === 1 ? "image" : "images"}) - -
- ); - } - if (filter.displayMode === DisplayMode.Wall) { - return ( -
-
- {result.data.findGalleries.galleries.map((gallery) => ( - - ))} -
-
- ); - } - } - - function renderContent( - result: FindGalleriesQueryResult, - filter: ListFilterModel, - selectedIds: Set - ) { - return ( - <> - {maybeRenderGalleryExportDialog(selectedIds)} - {renderGalleries(result, filter, selectedIds)} - - ); - } - - return listData.template; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 99103f521..19223bb02 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -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 = ({ images, handleImageOpen }) => { }; interface IImageListImages { - images: SlimImageDataFragment[]; + images: GQL.SlimImageDataFragment[]; filter: ListFilterModel; selectedIds: Set; onChangePage: (page: number) => void; @@ -139,7 +136,7 @@ const ImageListImages: React.FC = ({ function renderImageCard( index: number, - image: SlimImageDataFragment, + image: GQL.SlimImageDataFragment, zoomIndex: number ) { return ( @@ -184,17 +181,65 @@ const ImageListImages: React.FC = ({ 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 ( + +  ( + {megapixels ? ( + + Megapixels + + ) : undefined} + {separator} + {size && filesize ? ( + + + {` ${TextUtils.formatFileSizeUnit(filesize.unit)}`} + + ) : undefined} + ) + + ); + }, +}); + interface IImageList { filterHook?: (filter: ListFilterModel) => ListFilterModel; persistState?: PersistanceLevel; persistanceKey?: string; - extraOperations?: IListHookOperation[]; + alterQuery?: boolean; + extraOperations?: IItemListOperation[]; } export const ImageList: React.FC = ({ filterHook, persistState, persistanceKey, + alterQuery, extraOperations, }) => { const intl = useIntl(); @@ -203,7 +248,8 @@ export const ImageList: React.FC = ({ const [isExportAll, setIsExportAll] = useState(false); const [slideshowRunning, setSlideshowRunning] = useState(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 = ({ 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 = ({ 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 = ({ 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 = ({ setIsExportDialogOpen(true); } - function maybeRenderImageExportDialog(selectedIds: Set) { - if (isExportDialogOpen) { - return ( - <> + 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 ( = ({ all: isExportAll, }, }} - onClose={() => { - setIsExportDialogOpen(false); - }} + onClose={() => setIsExportDialogOpen(false)} /> - + ); + } + } + + function renderImages() { + if (!result.data?.findImages) return; + + return ( + ); } + + return ( + <> + {maybeRenderImageExportDialog()} + {renderImages()} + + ); } - function renderEditImagesDialog( - selectedImages: SlimImageDataFragment[], + function renderEditDialog( + selectedImages: GQL.SlimImageDataFragment[], onClose: (applied: boolean) => void ) { - return ( - <> - - - ); + return ; } - function renderDeleteImagesDialog( - selectedImages: SlimImageDataFragment[], + function renderDeleteDialog( + selectedImages: GQL.SlimImageDataFragment[], onClose: (confirmed: boolean) => void ) { - return ( - <> - - - ); + return ; } - function selectChange(id: string, selected: boolean, shiftKey: boolean) { - onSelectChange(id, selected, shiftKey); - } - - function renderImages( - result: FindImagesQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onChangePage: (page: number) => void, - pageCount: number - ) { - if (!result.data || !result.data.findImages) { - return; - } - - return ( - - ); - } - - function renderContent( - result: FindImagesQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onChangePage: (page: number) => void, - pageCount: number - ) { - return ( - <> - {maybeRenderImageExportDialog(selectedIds)} - {renderImages(result, filter, selectedIds, onChangePage, pageCount)} - - ); - } - - return template; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Images/Images.tsx b/ui/v2.5/src/components/Images/Images.tsx index 29538a6bb..dae428a5c 100644 --- a/ui/v2.5/src/components/Images/Images.tsx +++ b/ui/v2.5/src/components/Images/Images.tsx @@ -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"; diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx new file mode 100644 index 000000000..386d765c6 --- /dev/null +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -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 { + text: string; + onClick: ( + result: T, + filter: ListFilterModel, + selectedIds: Set + ) => Promise; + isDisplayed?: ( + result: T, + filter: ListFilterModel, + selectedIds: Set + ) => boolean; + postRefetch?: boolean; + icon?: IconDefinition; + buttonVariant?: string; +} + +interface IItemListOptions { + 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 { + persistState?: PersistanceLevel; + persistanceKey?: string; + defaultSort?: string; + filterHook?: (filter: ListFilterModel) => ListFilterModel; + filterDialog?: ( + criteria: Criterion[], + setCriteria: (v: Criterion[]) => void + ) => React.ReactNode; + zoomable?: boolean; + selectable?: boolean; + alterQuery?: boolean; + defaultZoomIndex?: number; + otherOperations?: IItemListOperation[]; + renderContent: ( + result: T, + filter: ListFilterModel, + selectedIds: Set, + 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 + ) => () => void; +} + +const getSelectedData = ( + data: I[], + selectedIds: Set +) => 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({ + filterMode, + useResult, + getCount, + renderMetadataByline, + getItems, +}: IItemListOptions) { + const filterOptions = getFilterOptions(filterMode); + + const RenderList: React.FC & 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>(new Set()); + const [lastClickedId, setLastClickedId] = useState(); + + const [editingCriterion, setEditingCriterion] = + useState>(); + const [newCriterion, setNewCriterion] = useState(false); + + const result = useResult(filter); + const [totalCount, setTotalCount] = useState(0); + const [metadataByline, setMetadataByline] = useState(); + 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(); + + 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(); + items.forEach((item) => { + newSelectedIds.add(item.id); + }); + + setSelectedIds(newSelectedIds); + setLastClickedId(undefined); + } + + function onSelectNone() { + const newSelectedIds = new Set(); + setSelectedIds(newSelectedIds); + setLastClickedId(undefined); + } + + function onChangeZoom(newZoomIndex: number) { + const newFilter = cloneDeep(filter); + newFilter.zoomIndex = newZoomIndex; + updateFilter(newFilter); + } + + async function onOperationClicked(o: IItemListOperation) { + 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 ( + + ); + } + + function renderPaginationIndex() { + if (hidePagination) return; + return ( + + ); + } + + function maybeRenderContent() { + if (result.loading) { + return ; + } + if (result.error) { + return

{result.error.message}

; + } + + 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, + 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) { + const newFilter = cloneDeep(filter); + newFilter.criteria = newFilter.criteria.filter( + (criterion) => criterion.getId() !== removedCriterion.getId() + ); + newFilter.currentPage = 1; + updateFilter(newFilter); + } + + function updateCriteria(c: Criterion[]) { + const newFilter = cloneDeep(filter); + newFilter.criteria = c.slice(); + setNewCriterion(false); + } + + return ( +
+ + setNewCriterion(true)} + filterDialogOpen={newCriterion} + persistState={persistState} + /> + 0} + onEdit={renderEditDialog ? onEdit : undefined} + onDelete={renderDeleteDialog ? onDelete : undefined} + /> + + + setEditingCriterion(c)} + onRemoveCriterion={onRemoveCriterion} + /> + {(newCriterion || editingCriterion) && !filterDialog && ( + + )} + {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()} +
+ ); + }; + + const ItemList: React.FC> = (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( + () => 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 ( + + ); + }; + + return ItemList; +} + +export const showWhenSelected = ( + result: T, + filter: ListFilterModel, + selectedIds: Set +) => { + return selectedIds.size > 0; +}; diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index 646bb9a28..018e3473a 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -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 = ({ 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 = ({ // 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 = ({ } function onChangeQuery(event: React.FormEvent) { - searchCallback(event.currentTarget.value); + debouncedSearchQueryUpdated(event.currentTarget.value); setQueryClearShowing(!!event.currentTarget.value); } function onClearQuery() { queryRef.current.value = ""; - searchCallback(""); + searchQueryUpdated(""); setQueryFocus(); setQueryClearShowing(false); } diff --git a/ui/v2.5/src/components/List/SavedFilterList.tsx b/ui/v2.5/src/components/List/SavedFilterList.tsx index 0f0d497be..8a5da0473 100644 --- a/ui/v2.5/src/components/List/SavedFilterList.tsx +++ b/ui/v2.5/src/components/List/SavedFilterList.tsx @@ -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 = ({ 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; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index 87bfb42eb..eaaacaa1e 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -220,7 +220,7 @@ const MoviePage: React.FC = ({ movie }) => {
- +
{renderDeleteAlert()} diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx index 1750bc5f9..22215de5a 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx @@ -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 = ({ movie }) => { +export const MovieScenesPanel: React.FC = ({ + 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 = ({ movie }) => { if (movie && movie.id) { return ( - + ); } return <>; diff --git a/ui/v2.5/src/components/Movies/MovieList.tsx b/ui/v2.5/src/components/Movies/MovieList.tsx index ec367d74f..c5b6c8ded 100644 --- a/ui/v2.5/src/components/Movies/MovieList.tsx +++ b/ui/v2.5/src/components/Movies/MovieList.tsx @@ -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 = ({ filterHook }) => { +export const MovieList: React.FC = ({ filterHook, alterQuery }) => { const intl = useIntl(); const history = useHistory(); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); @@ -47,10 +59,10 @@ export const MovieList: React.FC = ({ 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 = ({ filterHook }) => { return () => { Mousetrap.unbind("p r"); }; - }; - - function renderEditDialog( - selectedMovies: MovieDataFragment[], - onClose: (applied: boolean) => void - ) { - return ( - <> - - - ); } - const renderDeleteDialog = ( - selectedMovies: SlimMovieDataFragment[], - onClose: (confirmed: boolean) => void - ) => ( - - ); - - 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 = ({ 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 = ({ filterHook }) => { setIsExportDialogOpen(true); } - function maybeRenderMovieExportDialog(selectedIds: Set) { - if (isExportDialogOpen) { - return ( - <> + function renderContent( + result: GQL.FindMoviesQueryResult, + filter: ListFilterModel, + selectedIds: Set, + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void + ) { + function maybeRenderMovieExportDialog() { + if (isExportDialogOpen) { + return ( = ({ filterHook }) => { all: isExportAll, }, }} - onClose={() => { - setIsExportDialogOpen(false); - }} + onClose={() => setIsExportDialogOpen(false)} /> - - ); + ); + } } - } - function renderContent( - result: FindMoviesQueryResult, - filter: ListFilterModel, - selectedIds: Set - ) { - 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 (
{result.data.findMovies.movies.map((p) => ( = ({ 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) } /> ))}
- - ); - } - if (filter.displayMode === DisplayMode.List) { - return

TODO

; + ); + } + if (filter.displayMode === DisplayMode.List) { + return

TODO

; + } } + return ( + <> + {maybeRenderMovieExportDialog()} + {renderMovies()} + + ); } - return listData.template; + function renderEditDialog( + selectedMovies: GQL.MovieDataFragment[], + onClose: (applied: boolean) => void + ) { + return ; + } + + function renderDeleteDialog( + selectedMovies: GQL.SlimMovieDataFragment[], + onClose: (confirmed: boolean) => void + ) { + return ( + + ); + } + + return ( + + ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 357886ffb..7255dc435 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -188,7 +188,10 @@ const PerformerPage: React.FC = ({ performer }) => { } > - + = ({ performer }) => { } > - + = ({ performer }) => { } > - + = ({ performer }) => { } > - + diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx index 94c00051c..f1ea3db2c 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx @@ -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 = ({ + active, performer, }) => { const filterHook = usePerformerFilterHook(performer); - return ; + return ; }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx index e78035bbb..478f7027f 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx @@ -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 = ({ + active, performer, }) => { const filterHook = usePerformerFilterHook(performer); - return ; + return ; }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx index 6e2609511..4c417bac8 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx @@ -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 = ({ + active, performer, }) => { const filterHook = usePerformerFilterHook(performer); - return ; + return ; }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx index 991908e8b..d05db77c4 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx @@ -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 = ({ + active, performer, }) => { const filterHook = usePerformerFilterHook(performer); - return ; + return ; }; diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index 1fa96304e..cdfc37f09 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -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 = ({ filterHook, persistState, + alterQuery, extraCriteria, }) => { const intl = useIntl(); @@ -44,7 +55,7 @@ export const PerformerList: React.FC = ({ 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 = ({ }, ]; - 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 = ({ setIsExportDialogOpen(true); } - function maybeRenderPerformerExportDialog(selectedIds: Set) { - if (isExportDialogOpen) { - return ( - <> - { - setIsExportDialogOpen(false); - }} - /> - - ); - } - } - - function renderEditPerformersDialog( - selectedPerformers: SlimPerformerDataFragment[], - onClose: (applied: boolean) => void + function renderContent( + result: GQL.FindPerformersQueryResult, + filter: ListFilterModel, + selectedIds: Set, + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void ) { - return ( - <> - - - ); - } - - const renderDeleteDialog = ( - selectedPerformers: SlimPerformerDataFragment[], - onClose: (confirmed: boolean) => void - ) => ( - - ); - - 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 ( + <> + setIsExportDialogOpen(false)} + /> + + ); } } - } - function renderContent( - result: FindPerformersQueryResult, - filter: ListFilterModel, - selectedIds: Set - ) { - 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 (
{result.data.findPerformers.performers.map((p) => ( = ({ 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} /> ))}
- - ); - } - if (filter.displayMode === DisplayMode.List) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.Tagger) { - return ( - - ); + ); + } + if (filter.displayMode === DisplayMode.List) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.Tagger) { + return ( + + ); + } } + + return ( + <> + {maybeRenderPerformerExportDialog()} + {renderPerformers()} + + ); } - return listData.template; + function renderEditDialog( + selectedPerformers: GQL.SlimPerformerDataFragment[], + onClose: (applied: boolean) => void + ) { + return ( + + ); + } + + function renderDeleteDialog( + selectedPerformers: GQL.SlimPerformerDataFragment[], + onClose: (confirmed: boolean) => void + ) { + return ( + + ); + } + + return ( + + ); }; diff --git a/ui/v2.5/src/components/Performers/Performers.tsx b/ui/v2.5/src/components/Performers/Performers.tsx index f919fdba5..741a84cfe 100644 --- a/ui/v2.5/src/components/Performers/Performers.tsx +++ b/ui/v2.5/src/components/Performers/Performers.tsx @@ -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"; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 3392f5626..ada2d69db 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -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 ( + +  ( + {duration ? ( + + {TextUtils.secondsAsTimeString(duration, 3)} + + ) : undefined} + {separator} + {size && filesize ? ( + + + {` ${TextUtils.formatFileSizeUnit(filesize.unit)}`} + + ) : undefined} + ) + + ); + }, +}); interface ISceneList { filterHook?: (filter: ListFilterModel) => ListFilterModel; defaultSort?: string; - persistState?: PersistanceLevel.ALL; + persistState?: PersistanceLevel; + alterQuery?: boolean; } export const SceneList: React.FC = ({ filterHook, defaultSort, persistState, + alterQuery, }) => { const intl = useIntl(); const history = useHistory(); @@ -65,17 +111,17 @@ export const SceneList: React.FC = ({ }, { 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 = ({ }, ]; - 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 = ({ return () => { Mousetrap.unbind("p r"); }; - }; - - const renderDeleteDialog = ( - selectedScenes: SlimSceneDataFragment[], - onClose: (confirmed: boolean) => void - ) => ; - - 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 = ({ } async function playSelected( - result: FindScenesQueryResult, + result: GQL.FindScenesQueryResult, filter: ListFilterModel, selectedIds: Set ) { @@ -142,44 +170,35 @@ export const SceneList: React.FC = ({ } 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 ) { @@ -206,40 +225,37 @@ export const SceneList: React.FC = ({ setIsExportDialogOpen(true); } - function maybeRenderSceneGenerateDialog(selectedIds: Set) { - if (isGenerateDialogOpen) { - return ( - <> + function renderContent( + result: GQL.FindScenesQueryResult, + filter: ListFilterModel, + selectedIds: Set, + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void + ) { + function maybeRenderSceneGenerateDialog() { + if (isGenerateDialogOpen) { + return ( { - setIsGenerateDialogOpen(false); - }} + onClose={() => setIsGenerateDialogOpen(false)} /> - - ); + ); + } } - } - function maybeRenderSceneIdentifyDialog(selectedIds: Set) { - if (isIdentifyDialogOpen) { - return ( - <> + function maybeRenderSceneIdentifyDialog() { + if (isIdentifyDialogOpen) { + return ( { - setIsIdentifyDialogOpen(false); - }} + onClose={() => setIsIdentifyDialogOpen(false)} /> - - ); + ); + } } - } - function maybeRenderSceneExportDialog(selectedIds: Set) { - if (isExportDialogOpen) { - return ( - <> + function maybeRenderSceneExportDialog() { + if (isExportDialogOpen) { + return ( = ({ all: isExportAll, }, }} - onClose={() => { - setIsExportDialogOpen(false); - }} + onClose={() => setIsExportDialogOpen(false)} /> - - ); + ); + } } + + function renderMergeDialog() { + if (mergeScenes) { + return ( + { + 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 ( + + ); + } + if (filter.displayMode === DisplayMode.List) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.Wall) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.Tagger) { + return ; + } + } + + return ( + <> + {maybeRenderSceneGenerateDialog()} + {maybeRenderSceneIdentifyDialog()} + {maybeRenderSceneExportDialog()} + {renderMergeDialog()} + {renderScenes()} + + ); } - function renderEditScenesDialog( - selectedScenes: SlimSceneDataFragment[], + function renderEditDialog( + selectedScenes: GQL.SlimSceneDataFragment[], onClose: (applied: boolean) => void ) { - return ( - <> - - - ); + return ; } - function renderMergeDialog() { - if (mergeScenes) { - return ( - { - setMergeScenes(undefined); - if (mergedID) { - history.push(`/scenes/${mergedID}`); - } - }} - show - /> - ); - } - } - - function renderScenes( - result: FindScenesQueryResult, - filter: ListFilterModel, - selectedIds: Set + 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 ( - - listData.onSelectChange(id, selected, shiftKey) - } - /> - ); - } - if (filter.displayMode === DisplayMode.List) { - return ( - - listData.onSelectChange(id, selected, shiftKey) - } - /> - ); - } - if (filter.displayMode === DisplayMode.Wall) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.Tagger) { - return ; - } + return ; } - function renderContent( - result: FindScenesQueryResult, - filter: ListFilterModel, - selectedIds: Set - ) { - return ( - <> - {maybeRenderSceneGenerateDialog(selectedIds)} - {maybeRenderSceneIdentifyDialog(selectedIds)} - {maybeRenderSceneExportDialog(selectedIds)} - {renderMergeDialog()} - {renderScenes(result, filter, selectedIds)} - - ); - } - - return {listData.template}; + return ( + + + + ); }; export default SceneList; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index 449f7b64c..af6635940 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -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 = ({ filterHook }) => { +export const SceneMarkerList: React.FC = ({ + 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 ( ); } } - const title_template = `${intl.formatMessage({ - id: "markers", - })} ${TITLE_SUFFIX}`; return ( - <> - - - {listData.template} - + ); }; diff --git a/ui/v2.5/src/components/Scenes/Scenes.tsx b/ui/v2.5/src/components/Scenes/Scenes.tsx index b6f465857..ea966e74a 100644 --- a/ui/v2.5/src/components/Scenes/Scenes.tsx +++ b/ui/v2.5/src/components/Scenes/Scenes.tsx @@ -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 ( <> { )} /> - + ( + <> + + + + )} + /> diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 4038b8924..2168f36f5 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -237,7 +237,10 @@ const StudioPage: React.FC = ({ studio }) => { } > - + = ({ studio }) => { } > - + = ({ studio }) => { } > - + = ({ studio }) => { } > - + = ({ studio }) => { } > - + = ({ studio }) => { } > - + diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx index cd4eddec0..bd5a21a36 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx @@ -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 = ({ + active, studio, }) => { function filterHook(filter: ListFilterModel) { @@ -43,5 +45,5 @@ export const StudioChildrenPanel: React.FC = ({ return filter; } - return ; + return ; }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx index 809ac6d6b..42aca05dc 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx @@ -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 = ({ + active, studio, }) => { const filterHook = useStudioFilterHook(studio); - return ; + return ; }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx index 13549015c..c2ecb4e0f 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx @@ -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 = ({ studio }) => { +export const StudioImagesPanel: React.FC = ({ + active, + studio, +}) => { const filterHook = useStudioFilterHook(studio); - return ; + return ; }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx index 1d238cc0a..93a820242 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx @@ -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 = ({ studio }) => { +export const StudioMoviesPanel: React.FC = ({ + active, + studio, +}) => { const filterHook = useStudioFilterHook(studio); - return ; + return ; }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx index 8394033c6..396b3e790 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx @@ -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 = ({ + active, studio, }) => { const studioCriterion = new StudiosCriterion(); @@ -27,6 +29,10 @@ export const StudioPerformersPanel: React.FC = ({ const filterHook = useStudioFilterHook(studio); return ( - + ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioScenesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioScenesPanel.tsx index 741d871d5..84ba8751d 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioScenesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioScenesPanel.tsx @@ -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 = ({ studio }) => { +export const StudioScenesPanel: React.FC = ({ + active, + studio, +}) => { const filterHook = useStudioFilterHook(studio); - return ; + return ; }; diff --git a/ui/v2.5/src/components/Studios/StudioList.tsx b/ui/v2.5/src/components/Studios/StudioList.tsx index b3eef0113..6a916bdca 100644 --- a/ui/v2.5/src/components/Studios/StudioList.tsx +++ b/ui/v2.5/src/components/Studios/StudioList.tsx @@ -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 = ({ fromParent, filterHook, + alterQuery, }) => { const intl = useIntl(); const history = useHistory(); @@ -49,10 +63,10 @@ export const StudioList: React.FC = ({ }, ]; - 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 = ({ 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 = ({ 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 = ({ setIsExportDialogOpen(true); } - function maybeRenderExportDialog(selectedIds: Set) { - if (isExportDialogOpen) { - return ( - <> + function renderContent( + result: GQL.FindStudiosQueryResult, + filter: ListFilterModel, + selectedIds: Set, + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void + ) { + function maybeRenderExportDialog() { + if (isExportDialogOpen) { + return ( = ({ all: isExportAll, }, }} - onClose={() => { - setIsExportDialogOpen(false); - }} + onClose={() => setIsExportDialogOpen(false)} /> - - ); + ); + } } - } - const renderDeleteDialog = ( - selectedStudios: SlimStudioDataFragment[], - onClose: (confirmed: boolean) => void - ) => ( - - ); + 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 - ) { - if (!result.data?.findStudios) return; - - if (filter.displayMode === DisplayMode.Grid) { - return ( -
- {result.data.findStudios.studios.map((studio) => ( - 0} - selected={selectedIds.has(studio.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - listData.onSelectChange(studio.id, selected, shiftKey) - } - /> - ))} -
- ); + if (filter.displayMode === DisplayMode.Grid) { + return ( +
+ {result.data.findStudios.studios.map((studio) => ( + 0} + selected={selectedIds.has(studio.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(studio.id, selected, shiftKey) + } + /> + ))} +
+ ); + } + if (filter.displayMode === DisplayMode.List) { + return

TODO

; + } + if (filter.displayMode === DisplayMode.Wall) { + return

TODO

; + } } - if (filter.displayMode === DisplayMode.List) { - return

TODO

; - } - if (filter.displayMode === DisplayMode.Wall) { - return

TODO

; - } - } - function renderContent( - result: FindStudiosQueryResult, - filter: ListFilterModel, - selectedIds: Set - ) { return ( <> - {maybeRenderExportDialog(selectedIds)} - {renderStudios(result, filter, selectedIds)} + {maybeRenderExportDialog()} + {renderStudios()} ); } - return listData.template; + function renderDeleteDialog( + selectedStudios: GQL.SlimStudioDataFragment[], + onClose: (confirmed: boolean) => void + ) { + return ( + + ); + } + + return ( + + ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 6bfce3ac2..5b58b7111 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -310,6 +310,7 @@ const TagPage: React.FC = ({ tag }) => { @@ -325,7 +326,7 @@ const TagPage: React.FC = ({ tag }) => { } > - + = ({ tag }) => { } > - + = ({ tag }) => { } > - + = ({ tag }) => { } > - + = ({ tag }) => { } > - + diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx index dd6ff61c3..203715bfb 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx @@ -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 = ({ tag }) => { +export const TagGalleriesPanel: React.FC = ({ + active, + tag, +}) => { const filterHook = useTagFilterHook(tag); - return ; + return ; }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx index c037cdb4b..1c6ea2ec9 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx @@ -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 = ({ tag }) => { +export const TagImagesPanel: React.FC = ({ active, tag }) => { const filterHook = useTagFilterHook(tag); - return ; + return ; }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx index 575729eb2..37d33ea2c 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx @@ -8,10 +8,14 @@ import { import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList"; interface ITagMarkersPanel { + active: boolean; tag: GQL.TagDataFragment; } -export const TagMarkersPanel: React.FC = ({ tag }) => { +export const TagMarkersPanel: React.FC = ({ + 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 = ({ tag }) => { return filter; } - return ; + return ; }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx index 43a447ed3..255acaf64 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx @@ -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 = ({ tag }) => { +export const TagPerformersPanel: React.FC = ({ + active, + tag, +}) => { const filterHook = useTagFilterHook(tag); - return ; + return ; }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx index 972c19d16..5de9ed916 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx @@ -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 = ({ tag }) => { +export const TagScenesPanel: React.FC = ({ active, tag }) => { const filterHook = useTagFilterHook(tag); - return ; + return ; }; diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index 245f2ec9b..98035f12e 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -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 = ({ 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 = ({ filterHook, alterQuery }) => { const Toast = useToast(); const [deletingTag, setDeletingTag] = useState | null>(null); - const [deleteTag] = useTagDestroy(getDeleteTagInput() as GQL.TagDestroyInput); + function getDeleteTagInput() { + const tagInput: Partial = {}; + 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 = ({ 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 = ({ 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 = ({ 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 = ({ filterHook }) => { setIsExportDialogOpen(true); } - function maybeRenderExportDialog(selectedIds: Set) { - if (isExportDialogOpen) { - return ( - <> - { - setIsExportDialogOpen(false); - }} - /> - - ); - } - } - - const renderDeleteDialog = ( - selectedTags: GQL.TagDataFragment[], - onClose: (confirmed: boolean) => void - ) => ( - { - 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 = {}; - 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 = ({ filterHook }) => { } } - function renderTags( - result: FindTagsQueryResult, + function renderContent( + result: GQL.FindTagsQueryResult, filter: ListFilterModel, - selectedIds: Set + selectedIds: Set, + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void ) { - if (!result.data?.findTags) return; - - if (filter.displayMode === DisplayMode.Grid) { - return ( -
- {result.data.findTags.tags.map((tag) => ( - 0} - selected={selectedIds.has(tag.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - listData.onSelectChange(tag.id, selected, shiftKey) - } - /> - ))} -
- ); - } - if (filter.displayMode === DisplayMode.List) { - const deleteAlert = ( - {}} - show={!!deletingTag} - icon={faTrashAlt} - accept={{ - onClick: onDelete, - variant: "danger", - text: intl.formatMessage({ id: "actions.delete" }), - }} - cancel={{ onClick: () => setDeletingTag(null) }} - > - - - - - ); - - const tagElements = result.data.findTags.tags.map((tag) => { + function maybeRenderExportDialog() { + if (isExportDialogOpen) { return ( -
- {tag.name} + setIsExportDialogOpen(false)} + /> + ); + } + } -
- - - - - - - :{" "} - - - -
+ function renderTags() { + if (!result.data?.findTags) return; + + if (filter.displayMode === DisplayMode.Grid) { + return ( +
+ {result.data.findTags.tags.map((tag) => ( + 0} + selected={selectedIds.has(tag.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(tag.id, selected, shiftKey) + } + /> + ))}
); - }); + } + if (filter.displayMode === DisplayMode.List) { + const deleteAlert = ( + {}} + show={!!deletingTag} + icon={faTrashAlt} + accept={{ + onClick: onDelete, + variant: "danger", + text: intl.formatMessage({ id: "actions.delete" }), + }} + cancel={{ onClick: () => setDeletingTag(null) }} + > + + + + + ); - return ( -
- {tagElements} - {deleteAlert} -
- ); - } - if (filter.displayMode === DisplayMode.Wall) { - return

TODO

; - } - } + const tagElements = result.data.findTags.tags.map((tag) => { + return ( +
+ {tag.name} - function renderContent( - result: FindTagsQueryResult, - filter: ListFilterModel, - selectedIds: Set - ) { +
+ + + + + + + :{" "} + + + +
+
+ ); + }); + + return ( +
+ {tagElements} + {deleteAlert} +
+ ); + } + if (filter.displayMode === DisplayMode.Wall) { + return

TODO

; + } + } return ( <> - {maybeRenderExportDialog(selectedIds)} - {renderTags(result, filter, selectedIds)} + {maybeRenderExportDialog()} + {renderTags()} ); } - return listData.template; + function renderDeleteDialog( + selectedTags: GQL.TagDataFragment[], + onClose: (confirmed: boolean) => void + ) { + return ( + { + selectedTags.forEach((t) => + tagRelationHook( + t, + { parents: t.parents ?? [], children: t.children ?? [] }, + { parents: [], children: [] } + ) + ); + }} + /> + ); + } + + return ( + + ); }; diff --git a/ui/v2.5/src/hooks/ListHook.tsx b/ui/v2.5/src/hooks/ListHook.tsx deleted file mode 100644 index 620d3fbfa..000000000 --- a/ui/v2.5/src/hooks/ListHook.tsx +++ /dev/null @@ -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 = ( - result: I[], - selectedIds: Set -) => { - // 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 { - text: string; - onClick: ( - result: T, - filter: ListFilterModel, - selectedIds: Set - ) => Promise; - isDisplayed?: ( - result: T, - filter: ListFilterModel, - selectedIds: Set - ) => 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 { - persistState?: PersistanceLevel; - persistanceKey?: string; - defaultSort?: string; - filterHook?: (filter: ListFilterModel) => ListFilterModel; - filterDialog?: ( - criteria: Criterion[], - setCriteria: (v: Criterion[]) => void - ) => React.ReactNode; - zoomable?: boolean; - selectable?: boolean; - defaultZoomIndex?: number; - otherOperations?: IListHookOperation[]; - renderContent: ( - result: T, - filter: ListFilterModel, - selectedIds: Set, - 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 - ) => () => void; -} - -interface IDataItem { - id: string; -} -interface IQueryResult { - error?: ApolloError; - loading: boolean; - refetch: () => void; -} - -interface IQuery { - 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 & - IQuery & - IRenderListProps) => { - const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [lastClickedId, setLastClickedId] = useState(); - - const [editingCriterion, setEditingCriterion] = - useState>(); - 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 = 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 = new Set(); - items.forEach((item) => { - newSelectedIds.add(item.id); - }); - - setSelectedIds(newSelectedIds); - setLastClickedId(undefined); - } - - const onSelectNone = () => { - const newSelectedIds: Set = new Set(); - setSelectedIds(newSelectedIds); - setLastClickedId(undefined); - }; - - const onChangeZoom = (newZoomIndex: number) => { - const newFilter = cloneDeep(filter); - newFilter.zoomIndex = newZoomIndex; - updateFilter(newFilter); - }; - - const onOperationClicked = async (o: IListHookOperation) => { - 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 = () => ( - - ); - - const maybeRenderContent = () => { - if (result.loading || result.error) { - return; - } - - const pages = Math.ceil(totalCount / filter.itemsPerPage); - return ( - <> - {renderPagination()} - - {renderContent(result, filter, selectedIds, onChangePage, pages)} - - {renderPagination()} - - ); - }; - - const onChangeDisplayMode = (displayMode: DisplayMode) => { - const newFilter = cloneDeep(filter); - newFilter.displayMode = displayMode; - updateFilter(newFilter); - }; - - const onAddCriterion = ( - criterion: Criterion, - 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) => { - const newFilter = cloneDeep(filter); - newFilter.criteria = newFilter.criteria.filter( - (criterion) => criterion.getId() !== removedCriterion.getId() - ); - newFilter.currentPage = 1; - updateFilter(newFilter); - }; - - const updateCriteria = (c: Criterion[]) => { - const newFilter = cloneDeep(filter); - newFilter.criteria = c.slice(); - setNewCriterion(false); - }; - - function onCancelAddCriterion() { - setEditingCriterion(undefined); - setNewCriterion(false); - } - - const content = ( -
- - setNewCriterion(true)} - filterDialogOpen={newCriterion ?? editingCriterion} - persistState={persistState} - /> - 0} - onEdit={renderEditDialog ? onEdit : undefined} - onDelete={renderDeleteDialog ? onDelete : undefined} - /> - - - setEditingCriterion(c)} - onRemoveCriterion={onRemoveCriterion} - /> - {(newCriterion || editingCriterion) && !filterDialog && ( - - )} - {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 ? : undefined} - {result.error ?

{result.error.message}

: undefined} - {maybeRenderContent()} -
- ); - - return { contentTemplate: content, onSelectChange }; -}; - -const useList = ( - options: IListHookOptions & - IQuery -): 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(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 - ) : ( - - ); - - 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 -) => - useList({ - ...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 ( - -  ( - {duration ? ( - - {TextUtils.secondsAsTimeString(duration, 3)} - - ) : undefined} - {separator} - {size && filesize ? ( - - - {` ${TextUtils.formatFileSizeUnit(filesize.unit)}`} - - ) : undefined} - ) - - ); - }, - }); - -export const useSceneMarkersList = ( - props: IListHookOptions -) => - useList({ - ...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 -) => - useList({ - ...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 ( - -  ( - {megapixels ? ( - - Megapixels - - ) : undefined} - {separator} - {size && filesize ? ( - - - {` ${TextUtils.formatFileSizeUnit(filesize.unit)}`} - - ) : undefined} - ) - - ); - }, - }); - -export const useGalleriesList = ( - props: IListHookOptions -) => - useList({ - ...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 -) => - useList({ - ...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 -) => - useList({ - ...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 -) => - useList({ - ...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 -) => - useList({ - ...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 = ( - _result: T, - _filter: ListFilterModel, - selectedIds: Set -) => { - return selectedIds.size > 0; -}; diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index aadd6b2ef..a00f2716a 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -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, };