From b5de30a295ffb855000940e69e8d2047416f869a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:45:59 +1100 Subject: [PATCH] Revamp gallery list with sidebar (#6157) * Make list operation utility component * Add defaults for sidebar filters * Refactor gallery list for sidebar * Fix gallery styling * Fix sidebar state issues * Auto-populate query string into name on create * Remove new gallery button from navbar * Make components patchable --- .../src/components/Galleries/Galleries.tsx | 4 +- .../src/components/Galleries/GalleryList.tsx | 648 ++++++++---- ui/v2.5/src/components/Galleries/styles.scss | 1 - .../List/Filters/LabeledIdFilter.tsx | 17 +- .../List/Filters/PerformersFilter.tsx | 26 +- .../components/List/Filters/RatingFilter.tsx | 14 +- .../List/Filters/SidebarDurationFilter.tsx | 10 +- .../components/List/Filters/StudiosFilter.tsx | 26 +- .../components/List/Filters/TagsFilter.tsx | 26 +- .../components/List/ListOperationButtons.tsx | 108 ++ ui/v2.5/src/components/List/styles.scss | 3 +- ui/v2.5/src/components/MainNavbar.tsx | 1 - .../PerformerGalleriesPanel.tsx | 4 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 925 ++++++++---------- .../StudioDetails/StudioGalleriesPanel.tsx | 4 +- .../Tags/TagDetails/TagGalleriesPanel.tsx | 4 +- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 4 + ui/v2.5/src/index.scss | 2 - ui/v2.5/src/pluginApi.d.ts | 2 + 19 files changed, 1084 insertions(+), 745 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/Galleries.tsx b/ui/v2.5/src/components/Galleries/Galleries.tsx index c845a153c..388ce6720 100644 --- a/ui/v2.5/src/components/Galleries/Galleries.tsx +++ b/ui/v2.5/src/components/Galleries/Galleries.tsx @@ -4,7 +4,7 @@ import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Gallery from "./GalleryDetails/Gallery"; import GalleryCreate from "./GalleryDetails/GalleryCreate"; -import { GalleryList } from "./GalleryList"; +import { FilteredGalleryList } from "./GalleryList"; import { View } from "../List/views"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { ErrorMessage } from "../Shared/ErrorMessage"; @@ -40,7 +40,7 @@ const GalleryImage: React.FC> = ({ }; const Galleries: React.FC = () => { - return ; + return ; }; const GalleryRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index 4afbab620..de0d23c19 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -1,10 +1,10 @@ -import React, { useState } from "react"; -import { useIntl } from "react-intl"; +import React, { useCallback, useEffect } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; -import { useHistory } from "react-router-dom"; +import { useHistory, useLocation } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; -import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; +import { useFilteredItemList } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { queryFindGalleries, useFindGalleries } from "src/core/StashService"; @@ -16,17 +16,167 @@ import { GenerateDialog } from "../Dialogs/GenerateDialog"; import { GalleryListTable } from "./GalleryListTable"; import { GalleryCardGrid } from "./GalleryCardGrid"; import { View } from "../List/views"; -import { PatchComponent } from "src/patch"; -import { IItemListOperation } from "../List/FilteredListToolbar"; -import { useModal } from "src/hooks/modal"; +import useFocus from "src/utils/focus"; +import { + Sidebar, + SidebarPane, + SidebarPaneContent, + SidebarStateContext, + useSidebarState, +} from "../Shared/Sidebar"; +import { useCloseEditDelete, useFilterOperations } from "../List/util"; +import { + FilteredSidebarHeader, + useFilteredSidebarKeybinds, +} from "../List/Filters/FilterSidebar"; +import cx from "classnames"; +import { LoadedContent } from "../List/PagedList"; +import { Pagination, PaginationIndex } from "../List/Pagination"; +import { PatchComponent, PatchContainerComponent } from "src/patch"; +import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; +import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; +import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; +import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; +import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; +import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized"; +import { Button } from "react-bootstrap"; +import { ListOperations } from "../List/ListOperationButtons"; +import { + FilteredListToolbar, + IItemListOperation, +} from "../List/FilteredListToolbar"; +import { FilterTags } from "../List/FilterTags"; -function getItems(result: GQL.FindGalleriesQueryResult) { - return result?.data?.findGalleries?.galleries ?? []; -} +const GalleryList: React.FC<{ + galleries: GQL.SlimGalleryDataFragment[]; + filter: ListFilterModel; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +}> = PatchComponent( + "GalleryList", + ({ galleries, filter, selectedIds, onSelectChange }) => { + if (galleries.length === 0) { + return null; + } -function getCount(result: GQL.FindGalleriesQueryResult) { - return result?.data?.findGalleries?.count ?? 0; -} + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.List) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.Wall) { + return ( +
+ {galleries.map((gallery) => ( + + onSelectChange(gallery.id, selected, shiftKey) + } + selecting={selectedIds.size > 0} + /> + ))} +
+ ); + } + + return null; + } +); + +const GalleryFilterSidebarSections = PatchContainerComponent( + "FilteredGalleryList.SidebarSections" +); + +const SidebarContent: React.FC<{ + filter: ListFilterModel; + setFilter: (filter: ListFilterModel) => void; + filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; + sidebarOpen: boolean; + onClose?: () => void; + showEditFilter: (editingCriterion?: string) => void; + count?: number; + focus?: ReturnType; +}> = ({ + filter, + setFilter, + filterHook, + view, + showEditFilter, + sidebarOpen, + onClose, + count, + focus, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + + const hideStudios = view === View.StudioScenes; + + return ( + <> + + + + {!hideStudios && ( + + )} + + + + } + data-type={OrganizedCriterionOption.type} + option={OrganizedCriterionOption} + filter={filter} + setFilter={setFilter} + /> + + +
+ +
+ + ); +}; interface IGalleryList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -35,208 +185,324 @@ interface IGalleryList { extraOperations?: IItemListOperation[]; } -export const GalleryList: React.FC = PatchComponent( - "GalleryList", - ({ filterHook, view, alterQuery, extraOperations = [] }) => { +function useViewRandom(filter: ListFilterModel, count: number) { + const history = useHistory(); + + const viewRandom = useCallback(async () => { + // query for a random scene + if (count === 0) { + return; + } + + const index = Math.floor(Math.random() * count); + const filterCopy = cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindGalleries(filterCopy); + if (singleResult.data.findGalleries.galleries.length === 1) { + const { id } = singleResult.data.findGalleries.galleries[0]; + // navigate to the image player page + history.push(`/galleries/${id}`); + } + }, [history, filter, count]); + + return viewRandom; +} + +function useAddKeybinds(filter: ListFilterModel, count: number) { + const viewRandom = useViewRandom(filter, count); + + useEffect(() => { + Mousetrap.bind("p r", () => { + viewRandom(); + }); + + return () => { + Mousetrap.unbind("p r"); + }; + }, [viewRandom]); +} + +export const FilteredGalleryList = PatchComponent( + "FilteredGalleryList", + (props: IGalleryList) => { const intl = useIntl(); const history = useHistory(); - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - const [isExportAll, setIsExportAll] = useState(false); - const { modal, showModal, closeModal } = useModal(); + const location = useLocation(); - const filterMode = GQL.FilterMode.Galleries; + const searchFocus = useFocus(); + + const { filterHook, view, alterQuery } = props; + + // States + const { + showSidebar, + setShowSidebar, + sectionOpen, + setSectionOpen, + loading: sidebarStateLoading, + } = useSidebarState(view); + + const { filterState, queryResult, modalState, listSelect, showEditFilter } = + useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.Galleries, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindGalleries, + getCount: (r) => r.data?.findGalleries.count ?? 0, + getItems: (r) => r.data?.findGalleries.galleries ?? [], + filterHook, + }, + }); + + const { filter, setFilter } = filterState; + + const { effectiveFilter, result, cachedResult, items, totalCount } = + queryResult; + + const { + selectedIds, + selectedItems, + onSelectChange, + onSelectAll, + onSelectNone, + onInvertSelection, + hasSelection, + } = listSelect; + + const { modal, showModal, closeModal } = modalState; + + // Utility hooks + const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ + filter, + setFilter, + }); + + useAddKeybinds(filter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); + + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); + + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } + }); + + return () => { + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); + }; + }); + + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); + + function onCreateNew() { + let queryParam = new URLSearchParams(location.search).get("q"); + let newPath = "/galleries/new"; + if (queryParam) { + newPath += "?q=" + encodeURIComponent(queryParam); + } + history.push(newPath); + } + + const viewRandom = useViewRandom(filter, totalCount); + + function onExport(all: boolean) { + showModal( + closeModal()} + /> + ); + } + + function onEdit() { + showModal( + + ); + } + + function onDelete() { + showModal( + + ); + } + + function onGenerate() { + showModal( + closeModal()} + /> + ); + } const otherOperations = [ - ...extraOperations, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, { text: intl.formatMessage({ id: "actions.view_random" }), onClick: viewRandom, }, { text: `${intl.formatMessage({ id: "actions.generate" })}…`, - onClick: ( - _result: GQL.FindGalleriesQueryResult, - _filter: ListFilterModel, - selectedIds: Set - ) => { - showModal( - closeModal()} - /> - ); - return Promise.resolve(); - }, - isDisplayed: showWhenSelected, + onClick: onGenerate, + isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export" }), - onClick: onExport, - isDisplayed: showWhenSelected, + onClick: () => onExport(false), + isDisplayed: () => hasSelection, }, { text: intl.formatMessage({ id: "actions.export_all" }), - onClick: onExportAll, + onClick: () => onExport(true), }, ]; - function addKeybinds( - result: GQL.FindGalleriesQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - viewRandom(result, filter); - }); + // render + if (sidebarStateLoading) return null; - return () => { - Mousetrap.unbind("p r"); - }; - } - - async function viewRandom( - result: GQL.FindGalleriesQueryResult, - filter: ListFilterModel - ) { - // query for a random image - if (result.data?.findGalleries) { - const { count } = result.data.findGalleries; - - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindGalleries(filterCopy); - if (singleResult.data.findGalleries.galleries.length === 1) { - const { id } = singleResult.data.findGalleries.galleries[0]; - // navigate to the image player page - history.push(`/galleries/${id}`); - } - } - } - - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } - - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } - - function renderContent( - result: GQL.FindGalleriesQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - function maybeRenderGalleryExportDialog() { - if (isExportDialogOpen) { - return ( - setIsExportDialogOpen(false)} - /> - ); - } - } - - function renderGalleries() { - if (!result.data?.findGalleries) return; - - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.List) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.Wall) { - return ( -
-
- {result.data.findGalleries.galleries.map((gallery) => ( - - onSelectChange(gallery.id, selected, shiftKey) - } - selecting={selectedIds.size > 0} - /> - ))} -
-
- ); - } - } - - return ( - <> - {maybeRenderGalleryExportDialog()} - {modal} - {renderGalleries()} - - ); - } - - function renderEditDialog( - selectedImages: GQL.SlimGalleryDataFragment[], - onClose: (applied: boolean) => void - ) { - return ( - - ); - } - - function renderDeleteDialog( - selectedImages: GQL.SlimGalleryDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( - - ); - } + const operations = ( + + ); return ( - - - + {modal} + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + + + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> + +
+ setFilter(filter.changePage(page))} + /> + +
+ + + + + + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} +
+
+
+ ); } ); diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index 9890e887b..c53175313 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -229,7 +229,6 @@ div.GalleryWall { display: flex; flex-wrap: wrap; margin: 0 auto; - width: 96vw; /* Prevents last row from consuming all space and stretching images to oblivion */ &::after { diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index 200c16917..2e63cd465 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -18,6 +18,7 @@ import { Option } from "./SidebarListFilter"; import { CriterionModifier, FilterMode, + GalleryFilterType, InputMaybe, IntCriterionInput, SceneFilterType, @@ -515,12 +516,14 @@ export function makeQueryVariables(query: string, extraProps: {}) { interface IFilterType { scenes_filter?: InputMaybe; scene_count?: InputMaybe; + galleries_filter?: InputMaybe; + gallery_count?: InputMaybe; } export function setObjectFilter( out: IFilterType, mode: FilterMode, - relatedFilterOutput: SceneFilterType + relatedFilterOutput: SceneFilterType | GalleryFilterType ) { const empty = Object.keys(relatedFilterOutput).length === 0; @@ -535,5 +538,17 @@ export function setObjectFilter( } out.scenes_filter = relatedFilterOutput; break; + case FilterMode.Galleries: + // if empty, only get objects with galleries + if (empty) { + out.gallery_count = { + modifier: CriterionModifier.GreaterThan, + value: 0, + }; + } + out.galleries_filter = relatedFilterOutput; + break; + default: + throw new Error("Invalid filter mode"); } } diff --git a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx index 3df19593f..7e0dee855 100644 --- a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx @@ -1,5 +1,8 @@ import React, { ReactNode, useMemo } from "react"; -import { PerformersCriterion } from "src/models/list-filter/criteria/performers"; +import { + PerformersCriterion, + PerformersCriterionOption, +} from "src/models/list-filter/criteria/performers"; import { CriterionModifier, FindPerformersForSelectQueryVariables, @@ -18,6 +21,7 @@ import { useLabeledIdFilterState, } from "./LabeledIdFilter"; import { SidebarListFilter } from "./SidebarListFilter"; +import { FormattedMessage } from "react-intl"; interface IPerformersFilter { criterion: PerformersCriterion; @@ -106,12 +110,19 @@ const PerformersFilter: React.FC = ({ export const SidebarPerformersFilter: React.FC<{ title?: ReactNode; - option: CriterionOption; + option?: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; filterHook?: (f: ListFilterModel) => ListFilterModel; sectionID?: string; -}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => { +}> = ({ + title = , + option = PerformersCriterionOption, + filter, + setFilter, + filterHook, + sectionID = "performers", +}) => { const state = useLabeledIdFilterState({ filter, setFilter, @@ -120,7 +131,14 @@ export const SidebarPerformersFilter: React.FC<{ useQuery: usePerformerQueryFilter, }); - return ; + return ( + + ); }; export default PerformersFilter; diff --git a/ui/v2.5/src/components/List/Filters/RatingFilter.tsx b/ui/v2.5/src/components/List/Filters/RatingFilter.tsx index 9f5c8f8c9..8a07d54f9 100644 --- a/ui/v2.5/src/components/List/Filters/RatingFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/RatingFilter.tsx @@ -13,7 +13,10 @@ import { defaultRatingSystemOptions, } from "src/utils/rating"; import { useConfigurationContext } from "src/hooks/Config"; -import { RatingCriterion } from "src/models/list-filter/criteria/rating"; +import { + RatingCriterion, + RatingCriterionOption, +} from "src/models/list-filter/criteria/rating"; import { ListFilterModel } from "src/models/list-filter/filter"; import { Option, SidebarListFilter } from "./SidebarListFilter"; @@ -74,7 +77,7 @@ export const RatingFilter: React.FC = ({ interface ISidebarFilter { title?: React.ReactNode; - option: CriterionOption; + option?: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; sectionID?: string; @@ -84,11 +87,11 @@ const any = "any"; const none = "none"; export const SidebarRatingFilter: React.FC = ({ - title, - option, + title = , + option = RatingCriterionOption, filter, setFilter, - sectionID, + sectionID = "rating", }) => { const intl = useIntl(); @@ -193,6 +196,7 @@ export const SidebarRatingFilter: React.FC = ({ return ( <> void; sectionID?: string; @@ -55,11 +57,11 @@ function snapToStep(value: number): number { } export const SidebarDurationFilter: React.FC = ({ - title, - option, + title = , + option = DurationCriterionOption, filter, setFilter, - sectionID, + sectionID = "duration", }) => { const criteria = filter.criteriaFor(option.type) as DurationCriterion[]; const criterion = criteria.length > 0 ? criteria[0] : null; diff --git a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx index e922e688a..3e28bd927 100644 --- a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx @@ -5,7 +5,10 @@ import { useFindStudiosForSelectQuery, } from "src/core/generated-graphql"; import { HierarchicalObjectsFilter } from "./SelectableFilter"; -import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; +import { + StudiosCriterion, + StudiosCriterionOption, +} from "src/models/list-filter/criteria/studios"; import { sortByRelevance } from "src/utils/query"; import { CriterionOption } from "src/models/list-filter/criteria/criterion"; import { ListFilterModel } from "src/models/list-filter/filter"; @@ -16,6 +19,7 @@ import { useLabeledIdFilterState, } from "./LabeledIdFilter"; import { SidebarListFilter } from "./SidebarListFilter"; +import { FormattedMessage } from "react-intl"; interface IStudiosFilter { criterion: StudiosCriterion; @@ -94,12 +98,19 @@ const StudiosFilter: React.FC = ({ export const SidebarStudiosFilter: React.FC<{ title?: ReactNode; - option: CriterionOption; + option?: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; filterHook?: (f: ListFilterModel) => ListFilterModel; sectionID?: string; -}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => { +}> = ({ + title = , + option = StudiosCriterionOption, + filter, + setFilter, + filterHook, + sectionID = "studios", +}) => { const state = useLabeledIdFilterState({ filter, setFilter, @@ -111,7 +122,14 @@ export const SidebarStudiosFilter: React.FC<{ includeSubMessageID: "subsidiary_studios", }); - return ; + return ( + + ); }; export default StudiosFilter; diff --git a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx index f4c618ffa..446a90331 100644 --- a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx @@ -16,7 +16,11 @@ import { useLabeledIdFilterState, } from "./LabeledIdFilter"; import { SidebarListFilter } from "./SidebarListFilter"; -import { TagsCriterion } from "src/models/list-filter/criteria/tags"; +import { + TagsCriterion, + TagsCriterionOption, +} from "src/models/list-filter/criteria/tags"; +import { FormattedMessage } from "react-intl"; interface ITagsFilter { criterion: TagsCriterion; @@ -99,12 +103,19 @@ const TagsFilter: React.FC = ({ criterion, setCriterion }) => { export const SidebarTagsFilter: React.FC<{ title?: ReactNode; - option: CriterionOption; + option?: CriterionOption; filter: ListFilterModel; setFilter: (f: ListFilterModel) => void; filterHook?: (f: ListFilterModel) => ListFilterModel; sectionID?: string; -}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => { +}> = ({ + title = , + option = TagsCriterionOption, + filter, + setFilter, + filterHook, + sectionID = "tags", +}) => { const state = useLabeledIdFilterState({ filter, setFilter, @@ -115,7 +126,14 @@ export const SidebarTagsFilter: React.FC<{ includeSubMessageID: "sub_tags", }); - return ; + return ( + + ); }; export default TagsFilter; diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index b377cedba..314c28bf8 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -6,7 +6,10 @@ import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { Icon } from "../Shared/Icon"; import { faEllipsisH, + faPencil, faPencilAlt, + faPlay, + faPlus, faTrash, } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; @@ -264,3 +267,108 @@ export const ListOperationButtons: React.FC = ({ ); }; + +interface IListOperations { + text: string; + onClick: () => void; + isDisplayed?: () => boolean; + className?: string; +} + +export const ListOperations: React.FC<{ + items: number; + hasSelection?: boolean; + operations?: IListOperations[]; + onEdit?: () => void; + onDelete?: () => void; + onPlay?: () => void; + onCreateNew?: () => void; + entityType?: string; + operationsClassName?: string; + operationsMenuClassName?: string; +}> = ({ + items, + hasSelection = false, + operations = [], + onEdit, + onDelete, + onPlay, + onCreateNew, + entityType, + operationsClassName = "list-operations", + operationsMenuClassName, +}) => { + const intl = useIntl(); + + return ( +
+ + {!!items && onPlay && ( + + )} + {!hasSelection && onCreateNew && ( + + )} + + {hasSelection && (onEdit || onDelete) && ( + <> + {onEdit && ( + + )} + {onDelete && ( + + )} + + )} + + {operations.length > 0 && ( + + {operations.map((o) => { + if (o.isDisplayed && !o.isDisplayed()) { + return null; + } + + return ( + + ); + })} + + )} + +
+ ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 5f1b4da2a..8a7fdf8cf 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -1120,7 +1120,8 @@ input[type="range"].zoom-slider { justify-content: flex-end; } -.scene-list-toolbar .selected-items-info { +.scene-list-toolbar .selected-items-info, +.gallery-list-toolbar .selected-items-info { justify-content: flex-start; } diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index caee46f0c..ac1be2c13 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -132,7 +132,6 @@ const allMenuItems: IMenuItem[] = [ href: "/galleries", icon: faImages, hotkey: "g l", - userCreatable: true, }, { name: "performers", diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx index 5a9d0b81d..44b0401e9 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GalleryList } from "src/components/Galleries/GalleryList"; +import { FilteredGalleryList } from "src/components/Galleries/GalleryList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; import { PatchComponent } from "src/patch"; @@ -14,7 +14,7 @@ export const PerformerGalleriesPanel: React.FC = PatchComponent("PerformerGalleriesPanel", ({ active, performer }) => { const filterHook = usePerformerFilterHook(performer); return ( - ; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; fromGroupId?: string; -}> = ({ scenes, filter, selectedIds, onSelectChange, fromGroupId }) => { - const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]); +}> = PatchComponent( + "SceneList", + ({ scenes, filter, selectedIds, onSelectChange, fromGroupId }) => { + const queue = useMemo( + () => SceneQueue.fromListFilterModel(filter), + [filter] + ); + + if (scenes.length === 0 && filter.displayMode !== DisplayMode.Tagger) { + return null; + } + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.List) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.Wall) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.Tagger) { + return ( + + ); + } - if (scenes.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } - - 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 null; -}; +); const ScenesFilterSidebarSections = PatchContainerComponent( "FilteredSceneList.SidebarSections" @@ -298,48 +287,23 @@ const SidebarContent: React.FC<{ {!hideStudios && ( } - data-type={StudiosCriterionOption.type} - option={StudiosCriterionOption} filter={filter} setFilter={setFilter} filterHook={filterHook} - sectionID="studios" /> )} } - data-type={PerformersCriterionOption.type} - option={PerformersCriterionOption} filter={filter} setFilter={setFilter} filterHook={filterHook} - sectionID="performers" /> } - data-type={TagsCriterionOption.type} - option={TagsCriterionOption} filter={filter} setFilter={setFilter} filterHook={filterHook} - sectionID="tags" - /> - } - data-type={RatingCriterionOption.type} - option={RatingCriterionOption} - filter={filter} - setFilter={setFilter} - sectionID="rating" - /> - } - option={DurationCriterionOption} - filter={filter} - setFilter={setFilter} - sectionID="duration" /> + + } data-type={HasMarkersCriterionOption.type} @@ -374,102 +338,6 @@ const SidebarContent: React.FC<{ ); }; -interface IOperations { - text: string; - onClick: () => void; - isDisplayed?: () => boolean; - className?: string; -} - -const SceneListOperations: React.FC<{ - items: number; - hasSelection: boolean; - operations: IOperations[]; - onEdit: () => void; - onDelete: () => void; - onPlay: () => void; - onCreateNew: () => void; -}> = PatchComponent( - "SceneListOperations", - ({ - items, - hasSelection, - operations, - onEdit, - onDelete, - onPlay, - onCreateNew, - }) => { - const intl = useIntl(); - - return ( -
- - {!!items && ( - - )} - {!hasSelection && ( - - )} - - {hasSelection && ( - <> - - - - )} - - - {operations.map((o) => { - if (o.isDisplayed && !o.isDisplayed()) { - return null; - } - - return ( - - ); - })} - - -
- ); - } -); - interface IFilteredScenes { filterHook?: (filter: ListFilterModel) => ListFilterModel; defaultSort?: string; @@ -478,362 +346,381 @@ interface IFilteredScenes { fromGroupId?: string; } -export const FilteredSceneList = (props: IFilteredScenes) => { - const intl = useIntl(); - const history = useHistory(); +export const FilteredSceneList = PatchComponent( + "FilteredSceneList", + (props: IFilteredScenes) => { + const intl = useIntl(); + const history = useHistory(); + const location = useLocation(); - const searchFocus = useFocus(); + const searchFocus = useFocus(); - const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props; + const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props; - // States - const { - showSidebar, - setShowSidebar, - loading: sidebarStateLoading, - sectionOpen, - setSectionOpen, - } = useSidebarState(view); + // States + const { + showSidebar, + setShowSidebar, + loading: sidebarStateLoading, + sectionOpen, + setSectionOpen, + } = useSidebarState(view); - const { filterState, queryResult, modalState, listSelect, showEditFilter } = - useFilteredItemList({ - filterStateProps: { - filterMode: GQL.FilterMode.Scenes, - defaultSort, - view, - useURL: alterQuery, - }, - queryResultProps: { - useResult: useFindScenes, - getCount: (r) => r.data?.findScenes.count ?? 0, - getItems: (r) => r.data?.findScenes.scenes ?? [], - filterHook, - }, + const { filterState, queryResult, modalState, listSelect, showEditFilter } = + useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.Scenes, + defaultSort, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindScenes, + getCount: (r) => r.data?.findScenes.count ?? 0, + getItems: (r) => r.data?.findScenes.scenes ?? [], + filterHook, + }, + }); + + const { filter, setFilter } = filterState; + + const { effectiveFilter, result, cachedResult, items, totalCount } = + queryResult; + + const { + selectedIds, + selectedItems, + onSelectChange, + onSelectAll, + onSelectNone, + onInvertSelection, + hasSelection, + } = listSelect; + + const { modal, showModal, closeModal } = modalState; + + // Utility hooks + const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ + filter, + setFilter, }); - const { filter, setFilter } = filterState; + useAddKeybinds(filter, totalCount); + useFilteredSidebarKeybinds({ + showSidebar, + setShowSidebar, + }); - const { effectiveFilter, result, cachedResult, items, totalCount } = - queryResult; + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); - const { - selectedIds, - selectedItems, - onSelectChange, - onSelectAll, - onSelectNone, - onInvertSelection, - hasSelection, - } = listSelect; + const onEdit = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); - const { modal, showModal, closeModal } = modalState; + const onDelete = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); - // Utility hooks - const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({ - filter, - setFilter, - }); + useEffect(() => { + Mousetrap.bind("e", () => { + if (hasSelection) { + onEdit?.(); + } + }); - useAddKeybinds(filter, totalCount); - useFilteredSidebarKeybinds({ - showSidebar, - setShowSidebar, - }); + Mousetrap.bind("d d", () => { + if (hasSelection) { + onDelete?.(); + } + }); - const onCloseEditDelete = useCloseEditDelete({ - closeModal, - onSelectNone, - result, - }); + return () => { + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); + }; + }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]); + useZoomKeybinds({ + zoomIndex: filter.zoomIndex, + onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)), + }); - const onEdit = useCallback(() => { - showModal( - + const metadataByline = useMemo(() => { + if (cachedResult.loading) return null; + + return renderMetadataByline(cachedResult) ?? null; + }, [cachedResult]); + + const queue = useMemo( + () => SceneQueue.fromListFilterModel(filter), + [filter] ); - }, [showModal, selectedItems, onCloseEditDelete]); - const onDelete = useCallback(() => { - showModal( - - ); - }, [showModal, selectedItems, onCloseEditDelete]); + const playRandom = usePlayRandom(effectiveFilter, totalCount); + const playSelected = usePlaySelected(selectedIds); + const playFirst = usePlayFirst(); - useEffect(() => { - Mousetrap.bind("e", () => { - if (hasSelection) { - onEdit?.(); + function onCreateNew() { + let queryParam = new URLSearchParams(location.search).get("q"); + let newPath = "/scenes/new"; + if (queryParam) { + newPath += "?q=" + encodeURIComponent(queryParam); } - }); - - Mousetrap.bind("d d", () => { - if (hasSelection) { - onDelete?.(); - } - }); - - return () => { - Mousetrap.unbind("e"); - Mousetrap.unbind("d d"); - }; - }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]); - useZoomKeybinds({ - zoomIndex: filter.zoomIndex, - onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)), - }); - - const metadataByline = useMemo(() => { - if (cachedResult.loading) return null; - - return renderMetadataByline(cachedResult) ?? null; - }, [cachedResult]); - - const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]); - - const playRandom = usePlayRandom(effectiveFilter, totalCount); - const playSelected = usePlaySelected(selectedIds); - const playFirst = usePlayFirst(); - - function onCreateNew() { - history.push("/scenes/new"); - } - - function onPlay() { - if (items.length === 0) { - return; + history.push(newPath); } - // if there are selected items, play those - if (hasSelection) { - playSelected(); - return; + function onPlay() { + if (items.length === 0) { + return; + } + + // if there are selected items, play those + if (hasSelection) { + playSelected(); + return; + } + + // otherwise, play the first item in the list + const sceneID = items[0].id; + playFirst(queue, sceneID, 0); } - // otherwise, play the first item in the list - const sceneID = items[0].id; - playFirst(queue, sceneID, 0); - } + function onExport(all: boolean) { + showModal( + closeModal()} + /> + ); + } - function onExport(all: boolean) { - showModal( - closeModal()} + function onMerge() { + const selected = + selectedItems.map((s) => { + return { + id: s.id, + title: objectTitle(s), + }; + }) ?? []; + showModal( + { + closeModal(); + if (mergedID) { + history.push(`/scenes/${mergedID}`); + } + }} + show + /> + ); + } + + const otherOperations = [ + { + text: intl.formatMessage({ id: "actions.play" }), + onClick: () => onPlay(), + isDisplayed: () => items.length > 0, + className: "play-item", + }, + { + text: intl.formatMessage( + { id: "actions.create_entity" }, + { entityType: intl.formatMessage({ id: "scene" }) } + ), + onClick: () => onCreateNew(), + isDisplayed: () => !hasSelection, + className: "create-new-item", + }, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.play_random" }), + onClick: playRandom, + isDisplayed: () => totalCount > 1, + }, + { + text: `${intl.formatMessage({ id: "actions.generate" })}…`, + onClick: () => + showModal( + closeModal()} + /> + ), + isDisplayed: () => hasSelection, + }, + { + text: `${intl.formatMessage({ id: "actions.identify" })}…`, + onClick: () => + showModal( + closeModal()} + /> + ), + isDisplayed: () => hasSelection, + }, + { + text: `${intl.formatMessage({ id: "actions.merge" })}…`, + onClick: () => onMerge(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.export" }), + onClick: () => onExport(false), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.export_all" }), + onClick: () => onExport(true), + }, + ]; + + // render + if (sidebarStateLoading) return null; + + const operations = ( + ); - } - function onMerge() { - const selected = - selectedItems.map((s) => { - return { - id: s.id, - title: objectTitle(s), - }; - }) ?? []; - showModal( - { - closeModal(); - if (mergedID) { - history.push(`/scenes/${mergedID}`); - } - }} - show - /> - ); - } + return ( + +
+ {modal} - const otherOperations = [ - { - text: intl.formatMessage({ id: "actions.play" }), - onClick: () => onPlay(), - isDisplayed: () => items.length > 0, - className: "play-item", - }, - { - text: intl.formatMessage( - { id: "actions.create_entity" }, - { entityType: intl.formatMessage({ id: "scene" }) } - ), - onClick: () => onCreateNew(), - isDisplayed: () => !hasSelection, - className: "create-new-item", - }, - { - text: intl.formatMessage({ id: "actions.select_all" }), - onClick: () => onSelectAll(), - isDisplayed: () => totalCount > 0, - }, - { - text: intl.formatMessage({ id: "actions.select_none" }), - onClick: () => onSelectNone(), - isDisplayed: () => hasSelection, - }, - { - text: intl.formatMessage({ id: "actions.invert_selection" }), - onClick: () => onInvertSelection(), - isDisplayed: () => totalCount > 0, - }, - { - text: intl.formatMessage({ id: "actions.play_random" }), - onClick: playRandom, - isDisplayed: () => totalCount > 1, - }, - { - text: `${intl.formatMessage({ id: "actions.generate" })}…`, - onClick: () => - showModal( - closeModal()} - /> - ), - isDisplayed: () => hasSelection, - }, - { - text: `${intl.formatMessage({ id: "actions.identify" })}…`, - onClick: () => - showModal( - closeModal()} - /> - ), - isDisplayed: () => hasSelection, - }, - { - text: `${intl.formatMessage({ id: "actions.merge" })}…`, - onClick: () => onMerge(), - isDisplayed: () => hasSelection, - }, - { - text: intl.formatMessage({ id: "actions.export" }), - onClick: () => onExport(false), - isDisplayed: () => hasSelection, - }, - { - text: intl.formatMessage({ id: "actions.export_all" }), - onClick: () => onExport(true), - }, - ]; - - // render - if (sidebarStateLoading) return null; - - const operations = ( - - ); - - return ( - -
- {modal} - - - - setShowSidebar(false)}> - setShowSidebar(false)} - count={cachedResult.loading ? undefined : totalCount} - focus={searchFocus} - /> - - setShowSidebar(!showSidebar)} - > - - - showEditFilter(c.criterionOption.type)} - onRemoveCriterion={removeCriterion} - onRemoveAll={clearAllCriteria} - /> - -
- setFilter(filter.changePage(page))} + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} /> - + setShowSidebar(!showSidebar)} + > + -
- - + showEditFilter(c.criterionOption.type) + } + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} /> - - {totalCount > filter.itemsPerPage && ( -
-
- -
+
+ setFilter(filter.changePage(page))} + /> +
- )} - - - -
- - ); -}; + + + + + + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} +
+
+
+
+
+ ); + } +); export default FilteredSceneList; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx index 340586b94..f5a1aba32 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GalleryList } from "src/components/Galleries/GalleryList"; +import { FilteredGalleryList } from "src/components/Galleries/GalleryList"; import { useStudioFilterHook } from "src/core/studios"; import { View } from "src/components/List/views"; @@ -17,7 +17,7 @@ export const StudioGalleriesPanel: React.FC = ({ }) => { const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( - = ({ }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); return ( - ; ExternalLinkButtons: React.FC; ExternalLinksButton: React.FC; + FilteredGalleryList: React.FC; + FilteredSceneList: React.FC; FolderSelect: React.FC; FrontPage: React.FC; GalleryCard: React.FC;