diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index a7fecca20..d1fd77006 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -483,6 +483,8 @@ input StudioFilterType { image_count: IntCriterionInput "Filter by gallery count" gallery_count: IntCriterionInput + "Filter by group count" + group_count: IntCriterionInput "Filter by tag count" tag_count: IntCriterionInput "Filter by url" @@ -499,6 +501,8 @@ input StudioFilterType { images_filter: ImageFilterType "Filter by related galleries that meet this criteria" galleries_filter: GalleryFilterType + "Filter by related groups that meet this criteria" + groups_filter: GroupFilterType "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" @@ -658,6 +662,8 @@ input TagFilterType { images_filter: ImageFilterType "Filter by related galleries that meet this criteria" galleries_filter: GalleryFilterType + "Filter by related groups that meet this criteria" + groups_filter: GroupFilterType "Filter by related performers that meet this criteria" performers_filter: PerformerFilterType "Filter by related studios that meet this criteria" diff --git a/pkg/models/studio.go b/pkg/models/studio.go index be5d54445..5d1def1bc 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -28,6 +28,8 @@ type StudioFilterType struct { ImageCount *IntCriterionInput `json:"image_count"` // Filter by gallery count GalleryCount *IntCriterionInput `json:"gallery_count"` + // Filter by group count + GroupCount *IntCriterionInput `json:"group_count"` // Filter by url URL *StringCriterionInput `json:"url"` // Filter by studio aliases @@ -42,6 +44,8 @@ type StudioFilterType struct { ImagesFilter *ImageFilterType `json:"images_filter"` // Filter by related galleries that meet this criteria GalleriesFilter *GalleryFilterType `json:"galleries_filter"` + // Filter by related groups that meet this criteria + GroupsFilter *GroupFilterType `json:"groups_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/tag.go b/pkg/models/tag.go index bfb3f1ad3..3a133dcad 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -50,6 +50,8 @@ type TagFilterType struct { ImagesFilter *ImageFilterType `json:"images_filter"` // Filter by related galleries that meet this criteria GalleriesFilter *GalleryFilterType `json:"galleries_filter"` + // Filter by related groups that meet this criteria + GroupsFilter *GroupFilterType `json:"groups_filter"` // Filter by related performers that meet this criteria PerformersFilter *PerformerFilterType `json:"performers_filter"` // Filter by related studios that meet this criteria diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index d0c5c220c..949929c8d 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -101,6 +101,7 @@ type studioRepositoryType struct { scenes repository images repository galleries repository + groups repository } var ( @@ -127,6 +128,10 @@ var ( tableName: galleryTable, idColumn: studioIDColumn, }, + groups: repository{ + tableName: groupTable, + idColumn: studioIDColumn, + }, tags: joinRepository{ repository: repository{ tableName: studiosTagsTable, diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index cd7fc4440..889bd4c74 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -84,6 +84,7 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { qb.sceneCountCriterionHandler(studioFilter.SceneCount), qb.imageCountCriterionHandler(studioFilter.ImageCount), qb.galleryCountCriterionHandler(studioFilter.GalleryCount), + qb.groupCountCriterionHandler(studioFilter.GroupCount), qb.parentCriterionHandler(studioFilter.Parents), qb.aliasCriterionHandler(studioFilter.Aliases), qb.tagsCriterionHandler(studioFilter.Tags), @@ -118,6 +119,15 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { }, }, + &relatedFilterHandler{ + relatedIDCol: "groups.id", + relatedRepo: groupRepository.repository, + relatedHandler: &groupFilterHandler{studioFilter.GroupsFilter}, + joinFn: func(f *filterBuilder) { + studioRepository.groups.innerJoin(f, "", "studios.id") + }, + }, + &customFieldsFilterHandler{ table: studiosCustomFieldsTable.GetTable(), fkCol: studioIDColumn, @@ -179,6 +189,17 @@ func (qb *studioFilterHandler) galleryCountCriterionHandler(galleryCount *models } } +func (qb *studioFilterHandler) groupCountCriterionHandler(groupCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if groupCount != nil { + f.addLeftJoin("groups", "", "groups.studio_id = studios.id") + clause, args := getIntCriterionWhereClause("count(distinct groups.id)", *groupCount) + + f.addHaving(clause, args...) + } + } +} + func (qb *studioFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: studioTable, diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 8a0561b0f..a926dd56e 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -107,6 +107,7 @@ type tagRepositoryType struct { scenes joinRepository images joinRepository galleries joinRepository + groups joinRepository performers joinRepository studios joinRepository } @@ -154,6 +155,14 @@ var ( fkColumn: galleryIDColumn, foreignTable: galleryTable, }, + groups: joinRepository{ + repository: repository{ + tableName: groupsTagsTable, + idColumn: tagIDColumn, + }, + fkColumn: groupIDColumn, + foreignTable: groupTable, + }, performers: joinRepository{ repository: repository{ tableName: performersTagsTable, diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 92da1237c..b3a7c1756 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -135,6 +135,15 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { }, }, + &relatedFilterHandler{ + relatedIDCol: "groups_tags.group_id", + relatedRepo: groupRepository.repository, + relatedHandler: &groupFilterHandler{tagFilter.GroupsFilter}, + joinFn: func(f *filterBuilder) { + tagRepository.groups.innerJoin(f, "", "tags.id") + }, + }, + &relatedFilterHandler{ relatedIDCol: "performers_tags.performer_id", relatedRepo: performerRepository.repository, diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index de0d23c19..d06aaf3a4 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; -import { useHistory, useLocation } from "react-router-dom"; +import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { useFilteredItemList } from "../List/ItemList"; @@ -40,7 +40,10 @@ 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 { + IListFilterOperation, + ListOperations, +} from "../List/ListOperationButtons"; import { FilteredListToolbar, IItemListOperation, @@ -227,12 +230,10 @@ export const FilteredGalleryList = PatchComponent( "FilteredGalleryList", (props: IGalleryList) => { const intl = useIntl(); - const history = useHistory(); - const location = useLocation(); const searchFocus = useFocus(); - const { filterHook, view, alterQuery } = props; + const { filterHook, view, alterQuery, extraOperations = [] } = props; // States const { @@ -312,15 +313,6 @@ export const FilteredGalleryList = PatchComponent( 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) { @@ -365,7 +357,19 @@ export const FilteredGalleryList = PatchComponent( ); } + const convertedExtraOperations: IListFilterOperation[] = + extraOperations.map((o) => ({ + ...o, + isDisplayed: o.isDisplayed + ? () => o.isDisplayed!(result, filter, selectedIds) + : undefined, + onClick: () => { + o.onClick(result, filter, selectedIds); + }, + })); + const otherOperations = [ + ...convertedExtraOperations, { text: intl.formatMessage({ id: "actions.select_all" }), onClick: () => onSelectAll(), @@ -411,8 +415,6 @@ export const FilteredGalleryList = PatchComponent( operations={otherOperations} onEdit={onEdit} onDelete={onDelete} - onCreateNew={onCreateNew} - entityType={intl.formatMessage({ id: "gallery" })} operationsMenuClassName="gallery-list-operations-dropdown" /> ); diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx index 32836ab24..6a11f7004 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GroupList } from "../GroupList"; +import { FilteredGroupList } from "../GroupList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { ContainingGroupsCriterionOption, @@ -10,18 +10,7 @@ import { useRemoveSubGroups, useReorderSubGroupsMutation, } from "src/core/StashService"; -import { ButtonToolbar } from "react-bootstrap"; -import { ListOperationButtons } from "src/components/List/ListOperationButtons"; -import { useListContext } from "src/components/List/ListProvider"; -import { - PageSizeSelector, - SearchTermInput, -} from "src/components/List/ListFilter"; -import { useFilter } from "src/components/List/FilterProvider"; -import { - IFilteredListToolbar, - IItemListOperation, -} from "src/components/List/FilteredListToolbar"; +import { IItemListOperation } from "src/components/List/FilteredListToolbar"; import { showWhenNoneSelected, showWhenSelected, @@ -32,6 +21,7 @@ import { useToast } from "src/hooks/Toast"; import { useModal } from "src/hooks/modal"; import { AddSubGroupsDialog } from "./AddGroupsDialog"; import { PatchComponent } from "src/patch"; +import { View } from "src/components/List/views"; const useContainingGroupFilterHook = ( group: Pick, @@ -71,37 +61,6 @@ const useContainingGroupFilterHook = ( }; }; -const Toolbar: React.FC = ({ - onEdit, - onDelete, - operations, -}) => { - const { getSelected, onSelectAll, onSelectNone, onInvertSelection } = - useListContext(); - const { filter, setFilter } = useFilter(); - - return ( - -
- -
- setFilter(filter.setPageSize(size))} - /> - 0} - otherOperations={operations} - onEdit={onEdit} - onDelete={onDelete} - /> -
- ); -}; - interface IGroupSubGroupsPanel { active: boolean; group: GQL.GroupDataFragment; @@ -203,14 +162,14 @@ export const GroupSubGroupsPanel: React.FC = return ( <> {modal} - } + view={View.GroupSubGroups} /> ); diff --git a/ui/v2.5/src/components/Groups/GroupList.tsx b/ui/v2.5/src/components/Groups/GroupList.tsx index a08610569..6ce00831c 100644 --- a/ui/v2.5/src/components/Groups/GroupList.tsx +++ b/ui/v2.5/src/components/Groups/GroupList.tsx @@ -1,5 +1,5 @@ -import React, { PropsWithChildren, 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 Mousetrap from "mousetrap"; import { useHistory } from "react-router-dom"; @@ -11,208 +11,321 @@ import { useFindGroups, useGroupsDestroy, } from "src/core/StashService"; -import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; +import { useFilteredItemList } from "../List/ItemList"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { GroupCardGrid } from "./GroupCardGrid"; import { EditGroupsDialog } from "./EditGroupsDialog"; import { View } from "../List/views"; import { - IFilteredListToolbar, + FilteredListToolbar, IItemListOperation, } from "../List/FilteredListToolbar"; -import { PatchComponent } from "src/patch"; +import { PatchComponent, PatchContainerComponent } from "src/patch"; +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 { + IListFilterOperation, + ListOperations, +} from "../List/ListOperationButtons"; +import cx from "classnames"; +import { FilterTags } from "../List/FilterTags"; +import { Pagination, PaginationIndex } from "../List/Pagination"; +import { LoadedContent } from "../List/PagedList"; +import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; +import { SidebarTagsFilter } from "../List/Filters/TagsFilter"; +import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; +import { Button } from "react-bootstrap"; -const GroupExportDialog: React.FC<{ - open?: boolean; +const GroupList: React.FC<{ + groups: GQL.ListGroupDataFragment[]; + filter: ListFilterModel; selectedIds: Set; - isExportAll?: boolean; - onClose: () => void; -}> = ({ open = false, selectedIds, isExportAll = false, onClose }) => { - if (!open) { + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + fromGroupId?: string; + onMove?: (srcIds: string[], targetId: string, after: boolean) => void; +}> = PatchComponent( + "GroupList", + ({ groups, filter, selectedIds, onSelectChange, fromGroupId, onMove }) => { + if (groups.length === 0) { + return null; + } + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + return null; } +); + +const GroupFilterSidebarSections = PatchContainerComponent( + "FilteredGroupList.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 && ( + + )} + + + + +
+ +
+ ); }; -const filterMode = GQL.FilterMode.Groups; - -function getItems(result: GQL.FindGroupsQueryResult) { - return result?.data?.findGroups?.groups ?? []; -} - -function getCount(result: GQL.FindGroupsQueryResult) { - return result?.data?.findGroups?.count ?? 0; -} - interface IGroupListContext { filterHook?: (filter: ListFilterModel) => ListFilterModel; defaultFilter?: ListFilterModel; view?: View; alterQuery?: boolean; - selectable?: boolean; } -export const GroupListContext: React.FC< - PropsWithChildren -> = ({ alterQuery, filterHook, defaultFilter, view, selectable, children }) => { - return ( - - {children} - - ); -}; - interface IGroupList extends IGroupListContext { fromGroupId?: string; onMove?: (srcIds: string[], targetId: string, after: boolean) => void; - renderToolbar?: (props: IFilteredListToolbar) => React.ReactNode; otherOperations?: IItemListOperation[]; } -export const GroupList: React.FC = PatchComponent( - "GroupList", - ({ - filterHook, - alterQuery, - defaultFilter, - view, - fromGroupId, - onMove, - selectable, - renderToolbar, - otherOperations: providedOperations = [], - }) => { +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 queryFindGroups(filterCopy); + if (singleResult.data.findGroups.groups.length === 1) { + const { id } = singleResult.data.findGroups.groups[0]; + // navigate to the image player page + history.push(`/groups/${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 FilteredGroupList = PatchComponent( + "FilteredGroupList", + (props: IGroupList) => { const intl = useIntl(); - const history = useHistory(); - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - const [isExportAll, setIsExportAll] = useState(false); - const otherOperations = [ - { - text: intl.formatMessage({ id: "actions.view_random" }), - onClick: viewRandom, - }, - { - text: intl.formatMessage({ id: "actions.export" }), - onClick: onExport, - isDisplayed: showWhenSelected, - }, - { - text: intl.formatMessage({ id: "actions.export_all" }), - onClick: onExportAll, - }, - ...providedOperations, - ]; + const searchFocus = useFocus(); - function addKeybinds( - result: GQL.FindGroupsQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - viewRandom(result, filter); + const { + filterHook, + view, + alterQuery, + onMove, + fromGroupId, + otherOperations: providedOperations = [], + defaultFilter, + } = props; + + const withSidebar = view !== View.GroupSubGroups; + const filterable = view !== View.GroupSubGroups; + const sortable = view !== View.GroupSubGroups; + + // States + const { + showSidebar, + setShowSidebar, + sectionOpen, + setSectionOpen, + loading: sidebarStateLoading, + } = useSidebarState(view); + + const { filterState, queryResult, modalState, listSelect, showEditFilter } = + useFilteredItemList({ + filterStateProps: { + filterMode: GQL.FilterMode.Groups, + defaultFilter, + view, + useURL: alterQuery, + }, + queryResultProps: { + useResult: useFindGroups, + getCount: (r) => r.data?.findGroups.count ?? 0, + getItems: (r) => r.data?.findGroups.groups ?? [], + 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("p r"); + Mousetrap.unbind("e"); + Mousetrap.unbind("d d"); }; - } + }); - async function viewRandom( - result: GQL.FindGroupsQueryResult, - filter: ListFilterModel - ) { - // query for a random image - if (result.data?.findGroups) { - const { count } = result.data.findGroups; + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindGroups(filterCopy); - if (singleResult.data.findGroups.groups.length === 1) { - const { id } = singleResult.data.findGroups.groups[0]; - // navigate to the group page - history.push(`/groups/${id}`); - } - } - } + const viewRandom = useViewRandom(filter, totalCount); - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } - - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } - - function renderContent( - result: GQL.FindGroupsQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - return ( - <> - setIsExportDialogOpen(false)} - /> - {filter.displayMode === DisplayMode.Grid && ( - - )} - + function onExport(all: boolean) { + showModal( + closeModal()} + /> ); } - function renderEditDialog( - selectedGroups: GQL.ListGroupDataFragment[], - onClose: (applied: boolean) => void - ) { - return ; + function onEdit() { + showModal( + + ); } - function renderDeleteDialog( - selectedGroups: GQL.SlimGroupDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( + function onDelete() { + showModal( = PatchComponent( ); } - return ( - - ({ + ...o, + isDisplayed: o.isDisplayed + ? () => o.isDisplayed!(result, filter, selectedIds) + : undefined, + onClick: () => { + o.onClick(result, filter, selectedIds); + }, + })); + + const otherOperations = [ + ...convertedExtraOperations, + { + text: intl.formatMessage({ id: "actions.select_all" }), + onClick: () => onSelectAll(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.select_none" }), + onClick: () => onSelectNone(), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, + { + text: intl.formatMessage({ id: "actions.view_random" }), + onClick: viewRandom, + }, + { + text: intl.formatMessage({ id: "actions.export" }), + onClick: () => onExport(false), + isDisplayed: () => hasSelection, + }, + { + text: intl.formatMessage({ id: "actions.export_all" }), + onClick: () => onExport(true), + }, + ]; + + // render + if (sidebarStateLoading) return null; + + const operations = ( + + ); + + const content = ( + <> + - + + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={removeCriterion} + onRemoveAll={clearAllCriteria} + /> + +
+ setFilter(filter.changePage(page))} + /> + +
+ + + + + + {totalCount > filter.itemsPerPage && ( +
+
+ +
+
+ )} + + ); + + if (!withSidebar) { + return content; + } + + return ( +
+ {modal} + + + + setShowSidebar(false)}> + setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} + /> + + setShowSidebar(!showSidebar)} + > + {content} + + + +
); } ); diff --git a/ui/v2.5/src/components/Groups/Groups.tsx b/ui/v2.5/src/components/Groups/Groups.tsx index 5ec7b4eaf..1a89444b0 100644 --- a/ui/v2.5/src/components/Groups/Groups.tsx +++ b/ui/v2.5/src/components/Groups/Groups.tsx @@ -4,11 +4,11 @@ import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Group from "./GroupDetails/Group"; import GroupCreate from "./GroupDetails/GroupCreate"; -import { GroupList } from "./GroupList"; +import { FilteredGroupList } from "./GroupList"; import { View } from "../List/views"; const Groups: React.FC = () => { - return ; + return ; }; const GroupRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/List/FilteredListToolbar.tsx b/ui/v2.5/src/components/List/FilteredListToolbar.tsx index 162b30ff3..a6a983dc4 100644 --- a/ui/v2.5/src/components/List/FilteredListToolbar.tsx +++ b/ui/v2.5/src/components/List/FilteredListToolbar.tsx @@ -80,6 +80,8 @@ export interface IFilteredListToolbar { operations?: IListFilterOperation[]; operationComponent?: React.ReactNode; zoomable?: boolean; + filterable?: boolean; + sortable?: boolean; } export const FilteredListToolbar: React.FC = ({ @@ -93,6 +95,8 @@ export const FilteredListToolbar: React.FC = ({ operations, operationComponent, zoomable = false, + filterable = true, + sortable = true, }) => { const filterOptions = filter.options; const { setDisplayMode, setZoom } = useFilterOperations({ @@ -128,32 +132,40 @@ export const FilteredListToolbar: React.FC = ({ /> ) : ( <> - + {filterable && ( + + )} - - - showEditFilter()} - count={filter.count()} - /> - + {filterable && ( + + + showEditFilter()} + count={filter.count()} + /> + + )} - setFilter(filter.setSortBy(e ?? undefined))} - onChangeSortDirection={() => - setFilter(filter.toggleSortDirection()) - } - onReshuffleRandomSort={() => - setFilter(filter.reshuffleRandomSort()) - } - /> + {sortable && ( + + setFilter(filter.setSortBy(e ?? undefined)) + } + onChangeSortDirection={() => + setFilter(filter.toggleSortDirection()) + } + onReshuffleRandomSort={() => + setFilter(filter.reshuffleRandomSort()) + } + /> + )} ; galleries_filter?: InputMaybe; gallery_count?: InputMaybe; + groups_filter?: InputMaybe; + group_count?: InputMaybe; studios_filter?: InputMaybe; studio_count?: InputMaybe; } @@ -533,6 +536,7 @@ export function setObjectFilter( | SceneFilterType | PerformerFilterType | GalleryFilterType + | GroupFilterType | StudioFilterType ) { const empty = Object.keys(relatedFilterOutput).length === 0; @@ -568,6 +572,16 @@ export function setObjectFilter( } out.galleries_filter = relatedFilterOutput as GalleryFilterType; break; + case FilterMode.Groups: + // if empty, only get objects with groups + if (empty) { + out.group_count = { + modifier: CriterionModifier.GreaterThan, + value: 0, + }; + } + out.groups_filter = relatedFilterOutput as GroupFilterType; + break; case FilterMode.Studios: // if empty, only get objects with studios if (empty) { diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index c214a947a..2a4232fb3 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -9,7 +9,6 @@ import { faPencil, faPencilAlt, faPlay, - faPlus, faTrash, } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; @@ -61,6 +60,7 @@ export interface IListFilterOperation { isDisplayed?: () => boolean; icon?: IconDefinition; buttonVariant?: string; + className?: string; } interface IListOperationButtonsProps { @@ -268,22 +268,13 @@ export const ListOperationButtons: React.FC = ({ ); }; -export interface IListOperations { - text: string; - onClick: () => void; - isDisplayed?: () => boolean; - className?: string; -} - export const ListOperations: React.FC<{ items: number; hasSelection?: boolean; - operations?: IListOperations[]; + operations?: IListFilterOperation[]; onEdit?: () => void; onDelete?: () => void; onPlay?: () => void; - onCreateNew?: () => void; - entityType?: string; operationsClassName?: string; operationsMenuClassName?: string; }> = ({ @@ -293,79 +284,128 @@ export const ListOperations: React.FC<{ onEdit, onDelete, onPlay, - onCreateNew, - entityType, operationsClassName = "list-operations", operationsMenuClassName, }) => { const intl = useIntl(); + const dropdownOperations = useMemo(() => { + return operations.filter((o) => { + if (o.icon) { + return false; + } + + if (!o.isDisplayed) { + return true; + } + + return o.isDisplayed(); + }); + }, [operations]); + + const buttons = useMemo(() => { + const otherButtons = (operations ?? []).filter((o) => { + if (!o.icon) { + return false; + } + + if (!o.isDisplayed) { + return true; + } + + return o.isDisplayed(); + }); + + const ret: React.ReactNode[] = []; + + function addButton(b: React.ReactNode | null) { + if (b) { + ret.push(b); + } + } + + const playButton = + !!items && onPlay ? ( + + ) : null; + + const editButton = + hasSelection && onEdit ? ( + + ) : null; + + const deleteButton = + hasSelection && onDelete ? ( + + ) : null; + + addButton(playButton); + addButton(editButton); + addButton(deleteButton); + + otherButtons.forEach((button) => { + addButton( + + ); + }); + + if (ret.length === 0) { + return null; + } + + return ret; + }, [operations, hasSelection, onDelete, onEdit, onPlay, items, intl]); + + if (dropdownOperations.length === 0 && !buttons) { + return null; + } + return (
- {!!items && onPlay && ( - - )} - {!hasSelection && onCreateNew && ( - - )} + {buttons} - {hasSelection && (onEdit || onDelete) && ( - <> - {onEdit && ( - - )} - {onDelete && ( - - )} - - )} - - {operations.length > 0 && ( + {dropdownOperations.length > 0 && ( - {operations.map((o) => { - if (o.isDisplayed && !o.isDisplayed()) { - return null; - } - - return ( - - ); - })} + {dropdownOperations.map((o) => ( + + ))} )} diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 707346848..d870c631f 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -139,6 +139,7 @@ function useEmptyFilter(props: { export interface IFilterStateHook { filterMode: GQL.FilterMode; + defaultFilter?: ListFilterModel; defaultSort?: string; view?: View; useURL?: boolean; @@ -149,7 +150,14 @@ export function useFilterState( config?: GQL.ConfigDataFragment; } ) { - const { filterMode, defaultSort, config, view, useURL } = props; + const { + filterMode, + defaultSort, + config, + view, + useURL, + defaultFilter: propDefaultFilter, + } = props; const [filter, setFilterState] = useState( () => @@ -158,10 +166,13 @@ export function useFilterState( const emptyFilter = useEmptyFilter({ filterMode, defaultSort, config }); - const { defaultFilter } = useDefaultFilter(emptyFilter, view); + const { defaultFilter: defaultFilterFromConfig } = useDefaultFilter( + emptyFilter, + view + ); const { setFilter } = useFilterURL(filter, setFilterState, { - defaultFilter, + defaultFilter: propDefaultFilter ?? defaultFilterFromConfig, active: useURL, }); diff --git a/ui/v2.5/src/components/List/views.ts b/ui/v2.5/src/components/List/views.ts index 5b9f9798f..4ea4e46d8 100644 --- a/ui/v2.5/src/components/List/views.ts +++ b/ui/v2.5/src/components/List/views.ts @@ -13,6 +13,7 @@ export enum View { TagScenes = "tag_scenes", TagImages = "tag_images", TagPerformers = "tag_performers", + TagGroups = "tag_groups", PerformerScenes = "performer_scenes", PerformerGalleries = "performer_galleries", diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index a73a3078b..c70994476 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -103,6 +103,7 @@ const allMenuItems: IMenuItem[] = [ href: "/scenes", icon: faPlayCircle, hotkey: "g s", + userCreatable: true, }, { name: "images", @@ -132,6 +133,7 @@ const allMenuItems: IMenuItem[] = [ href: "/galleries", icon: faImages, hotkey: "g l", + userCreatable: true, }, { name: "performers", @@ -139,6 +141,7 @@ const allMenuItems: IMenuItem[] = [ href: "/performers", icon: faUser, hotkey: "g p", + userCreatable: true, }, { name: "studios", diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx index 5475c1484..ce61edc42 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GroupList } from "src/components/Groups/GroupList"; +import { FilteredGroupList } from "src/components/Groups/GroupList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; import { PatchComponent } from "src/patch"; @@ -14,7 +14,7 @@ export const PerformerGroupsPanel: React.FC = PatchComponent("PerformerGroupsPanel", ({ active, performer }) => { const filterHook = usePerformerFilterHook(performer); return ( - { const intl = useIntl(); const history = useHistory(); - const location = useLocation(); const searchFocus = useFocus(); @@ -444,15 +446,6 @@ export const FilteredPerformerList = PatchComponent( result, }); - function onCreateNew() { - let queryParam = new URLSearchParams(location.search).get("q"); - let newPath = "/performers/new"; - if (queryParam) { - newPath += "?q=" + encodeURIComponent(queryParam); - } - history.push(newPath); - } - const viewRandom = useViewRandom(filter, totalCount); function onExport(all: boolean) { @@ -505,8 +498,8 @@ export const FilteredPerformerList = PatchComponent( ); } - const convertedExtraOperations: IListOperations[] = extraOperations.map( - (o) => ({ + const convertedExtraOperations: IListFilterOperation[] = + extraOperations.map((o) => ({ ...o, isDisplayed: o.isDisplayed ? () => o.isDisplayed!(result, filter, selectedIds) @@ -514,10 +507,9 @@ export const FilteredPerformerList = PatchComponent( onClick: () => { o.onClick(result, filter, selectedIds); }, - }) - ); + })); - const otherOperations: IListOperations[] = [ + const otherOperations: IListFilterOperation[] = [ ...convertedExtraOperations, { text: intl.formatMessage({ id: "actions.select_all" }), @@ -564,8 +556,6 @@ export const FilteredPerformerList = PatchComponent( operations={otherOperations} onEdit={onEdit} onDelete={onDelete} - onCreateNew={onCreateNew} - entityType={intl.formatMessage({ id: "gallery" })} operationsMenuClassName="gallery-list-operations-dropdown" /> ); diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 79f470de8..a0458c5ac 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -627,8 +627,6 @@ export const FilteredSceneList = PatchComponent( onEdit={onEdit} onDelete={onDelete} onPlay={onPlay} - onCreateNew={onCreateNew} - entityType={intl.formatMessage({ id: "scene" })} operationsMenuClassName="scene-list-operations-dropdown" /> ); diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioGroupsPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioGroupsPanel.tsx index ba3a7cc02..75d001c21 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioGroupsPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioGroupsPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GroupList } from "src/components/Groups/GroupList"; +import { FilteredGroupList } from "src/components/Groups/GroupList"; import { useStudioFilterHook } from "src/core/studios"; import { View } from "src/components/List/views"; @@ -17,7 +17,7 @@ export const StudioGroupsPanel: React.FC = ({ }) => { const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( - { const intl = useIntl(); - const history = useHistory(); - const location = useLocation(); const searchFocus = useFocus(); @@ -284,15 +282,6 @@ export const FilteredStudioList = PatchComponent( result, }); - function onCreateNew() { - let queryParam = new URLSearchParams(location.search).get("q"); - let newPath = "/studios/new"; - if (queryParam) { - newPath += "?q=" + encodeURIComponent(queryParam); - } - history.push(newPath); - } - const viewRandom = useViewRandom(filter, totalCount); function onExport(all: boolean) { @@ -378,8 +367,6 @@ export const FilteredStudioList = PatchComponent( operations={otherOperations} onEdit={onEdit} onDelete={onDelete} - onCreateNew={onCreateNew} - entityType={intl.formatMessage({ id: "studio" })} operationsMenuClassName="studio-list-operations-dropdown" /> ); diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagGroupsPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagGroupsPanel.tsx index 363efadde..89636f5d3 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagGroupsPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagGroupsPanel.tsx @@ -1,7 +1,8 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; -import { GroupList } from "src/components/Groups/GroupList"; +import { FilteredGroupList } from "src/components/Groups/GroupList"; +import { View } from "src/components/List/views"; export const TagGroupsPanel: React.FC<{ active: boolean; @@ -9,5 +10,11 @@ export const TagGroupsPanel: React.FC<{ showSubTagContent?: boolean; }> = ({ active, tag, showSubTagContent }) => { const filterHook = useTagFilterHook(tag, showSubTagContent); - return ; + return ( + + ); };