Add sidebar to group list (#6573)

* Add group filter criteria to tag and studio
* Add sidebar to groups list
* Refactor ListOperations to accept buttons
* Move create new button back to navbar

Having the create new button with a plus icon conflicted with the add sub-group button in the sub-groups view.

* Simplify group-sub-groups view
This commit is contained in:
WithoutPants 2026-02-16 17:28:41 +11:00 committed by GitHub
parent fc31823fd2
commit bede849fa6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 708 additions and 376 deletions

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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"
/>
);

View file

@ -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<GQL.StudioDataFragment, "id" | "name">,
@ -71,37 +61,6 @@ const useContainingGroupFilterHook = (
};
};
const Toolbar: React.FC<IFilteredListToolbar> = ({
onEdit,
onDelete,
operations,
}) => {
const { getSelected, onSelectAll, onSelectNone, onInvertSelection } =
useListContext();
const { filter, setFilter } = useFilter();
return (
<ButtonToolbar className="filtered-list-toolbar">
<div>
<SearchTermInput filter={filter} onFilterUpdate={setFilter} />
</div>
<PageSizeSelector
pageSize={filter.itemsPerPage}
setPageSize={(size) => setFilter(filter.setPageSize(size))}
/>
<ListOperationButtons
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
onInvertSelection={onInvertSelection}
itemsSelected={getSelected().length > 0}
otherOperations={operations}
onEdit={onEdit}
onDelete={onDelete}
/>
</ButtonToolbar>
);
};
interface IGroupSubGroupsPanel {
active: boolean;
group: GQL.GroupDataFragment;
@ -203,14 +162,14 @@ export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> =
return (
<>
{modal}
<GroupList
<FilteredGroupList
defaultFilter={defaultFilter}
filterHook={filterHook}
alterQuery={active}
fromGroupId={group.id}
otherOperations={otherOperations}
onMove={onMove}
renderToolbar={(props) => <Toolbar {...props} />}
view={View.GroupSubGroups}
/>
</>
);

View file

@ -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<string>;
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 (
<GroupCardGrid
groups={groups ?? []}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
fromGroupId={fromGroupId}
onMove={onMove}
/>
);
}
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<typeof useFocus>;
}> = ({
filter,
setFilter,
filterHook,
view,
showEditFilter,
sidebarOpen,
onClose,
count,
focus,
}) => {
const showResultsId =
count !== undefined ? "actions.show_count_results" : "actions.show_results";
const hideStudios = view === View.StudioScenes;
return (
<ExportDialog
exportInput={{
groups: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={onClose}
/>
<>
<FilteredSidebarHeader
sidebarOpen={sidebarOpen}
showEditFilter={showEditFilter}
filter={filter}
setFilter={setFilter}
view={view}
focus={focus}
/>
<GroupFilterSidebarSections>
{!hideStudios && (
<SidebarStudiosFilter
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
/>
)}
<SidebarTagsFilter
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
/>
<SidebarRatingFilter filter={filter} setFilter={setFilter} />
</GroupFilterSidebarSections>
<div className="sidebar-footer">
<Button className="sidebar-close-button" onClick={onClose}>
<FormattedMessage id={showResultsId} values={{ count }} />
</Button>
</div>
</>
);
};
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<IGroupListContext>
> = ({ alterQuery, filterHook, defaultFilter, view, selectable, children }) => {
return (
<ItemListContext
filterMode={filterMode}
defaultFilter={defaultFilter}
useResult={useFindGroups}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
selectable={selectable}
>
{children}
</ItemListContext>
);
};
interface IGroupList extends IGroupListContext {
fromGroupId?: string;
onMove?: (srcIds: string[], targetId: string, after: boolean) => void;
renderToolbar?: (props: IFilteredListToolbar) => React.ReactNode;
otherOperations?: IItemListOperation<GQL.FindGroupsQueryResult>[];
}
export const GroupList: React.FC<IGroupList> = 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<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
return (
<>
<GroupExportDialog
open={isExportDialogOpen}
selectedIds={selectedIds}
isExportAll={isExportAll}
onClose={() => setIsExportDialogOpen(false)}
/>
{filter.displayMode === DisplayMode.Grid && (
<GroupCardGrid
groups={result.data?.findGroups.groups ?? []}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
fromGroupId={fromGroupId}
onMove={onMove}
/>
)}
</>
function onExport(all: boolean) {
showModal(
<ExportDialog
exportInput={{
groups: {
ids: Array.from(selectedIds.values()),
all: all,
},
}}
onClose={() => closeModal()}
/>
);
}
function renderEditDialog(
selectedGroups: GQL.ListGroupDataFragment[],
onClose: (applied: boolean) => void
) {
return <EditGroupsDialog selected={selectedGroups} onClose={onClose} />;
function onEdit() {
showModal(
<EditGroupsDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}
function renderDeleteDialog(
selectedGroups: GQL.SlimGroupDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
function onDelete() {
showModal(
<DeleteEntityDialog
selected={selectedGroups}
onClose={onClose}
selected={selectedItems}
onClose={onCloseEditDelete}
singularEntity={intl.formatMessage({ id: "group" })}
pluralEntity={intl.formatMessage({ id: "groups" })}
destroyMutation={useGroupsDestroy}
@ -220,24 +333,163 @@ export const GroupList: React.FC<IGroupList> = PatchComponent(
);
}
return (
<GroupListContext
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
defaultFilter={defaultFilter}
selectable={selectable}
>
<ItemList
const convertedExtraOperations: IListFilterOperation[] =
providedOperations.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(),
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 = (
<ListOperations
items={items.length}
hasSelection={hasSelection}
operations={otherOperations}
onEdit={onEdit}
onDelete={onDelete}
operationsMenuClassName="group-list-operations-dropdown"
/>
);
const content = (
<>
<FilteredListToolbar
filter={filter}
listSelect={listSelect}
setFilter={setFilter}
showEditFilter={showEditFilter}
onDelete={onDelete}
onEdit={onEdit}
operationComponent={operations}
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
renderToolbar={renderToolbar}
zoomable
filterable={filterable}
sortable={sortable}
/>
</GroupListContext>
<FilterTags
criteria={filter.criteria}
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
onRemoveCriterion={removeCriterion}
onRemoveAll={clearAllCriteria}
/>
<div className="pagination-index-container">
<Pagination
currentPage={filter.currentPage}
itemsPerPage={filter.itemsPerPage}
totalItems={totalCount}
onChangePage={(page) => setFilter(filter.changePage(page))}
/>
<PaginationIndex
loading={cachedResult.loading}
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
/>
</div>
<LoadedContent loading={result.loading} error={result.error}>
<GroupList
filter={effectiveFilter}
groups={items}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
fromGroupId={fromGroupId}
onMove={onMove}
/>
</LoadedContent>
{totalCount > filter.itemsPerPage && (
<div className="pagination-footer-container">
<div className="pagination-footer">
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
onChangePage={setPage}
pagePopupPlacement="top"
/>
</div>
</div>
)}
</>
);
if (!withSidebar) {
return content;
}
return (
<div
className={cx("item-list-container group-list", {
"hide-sidebar": !showSidebar,
})}
>
{modal}
<SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>
<SidebarPane hideSidebar={!showSidebar}>
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
<SidebarContent
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
showEditFilter={showEditFilter}
view={view}
sidebarOpen={showSidebar}
onClose={() => setShowSidebar(false)}
count={cachedResult.loading ? undefined : totalCount}
focus={searchFocus}
/>
</Sidebar>
<SidebarPaneContent
onSidebarToggle={() => setShowSidebar(!showSidebar)}
>
{content}
</SidebarPaneContent>
</SidebarPane>
</SidebarStateContext.Provider>
</div>
);
}
);

View file

@ -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 <GroupList view={View.Groups} />;
return <FilteredGroupList view={View.Groups} />;
};
const GroupRoutes: React.FC = () => {

View file

@ -80,6 +80,8 @@ export interface IFilteredListToolbar {
operations?: IListFilterOperation[];
operationComponent?: React.ReactNode;
zoomable?: boolean;
filterable?: boolean;
sortable?: boolean;
}
export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
@ -93,6 +95,8 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
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<IFilteredListToolbar> = ({
/>
) : (
<>
<SearchTermInput filter={filter} onFilterUpdate={setFilter} />
{filterable && (
<SearchTermInput filter={filter} onFilterUpdate={setFilter} />
)}
<ButtonGroup>
<SavedFilterDropdown
filter={filter}
onSetFilter={setFilter}
view={view}
/>
<FilterButton
onClick={() => showEditFilter()}
count={filter.count()}
/>
</ButtonGroup>
{filterable && (
<ButtonGroup>
<SavedFilterDropdown
filter={filter}
onSetFilter={setFilter}
view={view}
/>
<FilterButton
onClick={() => showEditFilter()}
count={filter.count()}
/>
</ButtonGroup>
)}
<SortBySelect
sortBy={filter.sortBy}
sortDirection={filter.sortDirection}
options={filterOptions.sortByOptions}
onChangeSortBy={(e) => setFilter(filter.setSortBy(e ?? undefined))}
onChangeSortDirection={() =>
setFilter(filter.toggleSortDirection())
}
onReshuffleRandomSort={() =>
setFilter(filter.reshuffleRandomSort())
}
/>
{sortable && (
<SortBySelect
sortBy={filter.sortBy}
sortDirection={filter.sortDirection}
options={filterOptions.sortByOptions}
onChangeSortBy={(e) =>
setFilter(filter.setSortBy(e ?? undefined))
}
onChangeSortDirection={() =>
setFilter(filter.toggleSortDirection())
}
onReshuffleRandomSort={() =>
setFilter(filter.reshuffleRandomSort())
}
/>
)}
<PageSizeSelector
pageSize={filter.itemsPerPage}

View file

@ -19,6 +19,7 @@ import {
CriterionModifier,
FilterMode,
GalleryFilterType,
GroupFilterType,
InputMaybe,
IntCriterionInput,
PerformerFilterType,
@ -522,6 +523,8 @@ interface IFilterType {
performer_count?: InputMaybe<IntCriterionInput>;
galleries_filter?: InputMaybe<GalleryFilterType>;
gallery_count?: InputMaybe<IntCriterionInput>;
groups_filter?: InputMaybe<GroupFilterType>;
group_count?: InputMaybe<IntCriterionInput>;
studios_filter?: InputMaybe<StudioFilterType>;
studio_count?: InputMaybe<IntCriterionInput>;
}
@ -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) {

View file

@ -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<IListOperationButtonsProps> = ({
);
};
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 ? (
<Button
className="play-button"
variant="secondary"
onClick={() => onPlay()}
title={intl.formatMessage({ id: "actions.play" })}
>
<Icon icon={faPlay} />
</Button>
) : null;
const editButton =
hasSelection && onEdit ? (
<Button
className="edit-existing-button"
variant="secondary"
onClick={() => onEdit()}
>
<Icon icon={faPencil} />
</Button>
) : null;
const deleteButton =
hasSelection && onDelete ? (
<Button
variant="danger"
className="delete-button btn-danger-minimal"
onClick={() => onDelete()}
>
<Icon icon={faTrash} />
</Button>
) : null;
addButton(playButton);
addButton(editButton);
addButton(deleteButton);
otherButtons.forEach((button) => {
addButton(
<Button
key={button.text}
variant={button.buttonVariant ?? "secondary"}
onClick={button.onClick}
title={button.text}
className={button.className}
>
<Icon icon={button.icon!} />
</Button>
);
});
if (ret.length === 0) {
return null;
}
return ret;
}, [operations, hasSelection, onDelete, onEdit, onPlay, items, intl]);
if (dropdownOperations.length === 0 && !buttons) {
return null;
}
return (
<div className="list-operations">
<ButtonGroup>
{!!items && onPlay && (
<Button
className="play-button"
variant="secondary"
onClick={() => onPlay()}
title={intl.formatMessage({ id: "actions.play" })}
>
<Icon icon={faPlay} />
</Button>
)}
{!hasSelection && onCreateNew && (
<Button
className="create-new-button"
variant="secondary"
onClick={() => onCreateNew()}
title={intl.formatMessage(
{ id: "actions.create_entity" },
{ entityType }
)}
>
<Icon icon={faPlus} />
</Button>
)}
{buttons}
{hasSelection && (onEdit || onDelete) && (
<>
{onEdit && (
<Button variant="secondary" onClick={() => onEdit()}>
<Icon icon={faPencil} />
</Button>
)}
{onDelete && (
<Button
variant="danger"
className="btn-danger-minimal"
onClick={() => onDelete()}
>
<Icon icon={faTrash} />
</Button>
)}
</>
)}
{operations.length > 0 && (
{dropdownOperations.length > 0 && (
<OperationDropdown
className={operationsClassName}
menuClassName={operationsMenuClassName}
menuPortalTarget={document.body}
>
{operations.map((o) => {
if (o.isDisplayed && !o.isDisplayed()) {
return null;
}
return (
<OperationDropdownItem
key={o.text}
onClick={o.onClick}
text={o.text}
className={o.className}
/>
);
})}
{dropdownOperations.map((o) => (
<OperationDropdownItem
key={o.text}
onClick={o.onClick}
text={o.text}
className={o.className}
/>
))}
</OperationDropdown>
)}
</ButtonGroup>

View file

@ -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<ListFilterModel>(
() =>
@ -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,
});

View file

@ -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",

View file

@ -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",

View file

@ -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<IPerformerDetailsProps> =
PatchComponent("PerformerGroupsPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
<GroupList
<FilteredGroupList
filterHook={filterHook}
alterQuery={active}
view={View.PerformerGroups}

View file

@ -1,7 +1,7 @@
import cloneDeep from "lodash-es/cloneDeep";
import React, { useCallback, useEffect } from "react";
import { FormattedMessage, useIntl } from "react-intl";
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 {
@ -41,7 +41,10 @@ import {
FilteredSidebarHeader,
useFilteredSidebarKeybinds,
} from "../List/Filters/FilterSidebar";
import { IListOperations, ListOperations } from "../List/ListOperationButtons";
import {
IListFilterOperation,
ListOperations,
} from "../List/ListOperationButtons";
import { FilterTags } from "../List/FilterTags";
import { Pagination, PaginationIndex } from "../List/Pagination";
import { LoadedContent } from "../List/PagedList";
@ -354,7 +357,6 @@ export const FilteredPerformerList = PatchComponent(
(props: IPerformerList) => {
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"
/>
);

View file

@ -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"
/>
);

View file

@ -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<IStudioGroupsPanel> = ({
}) => {
const filterHook = useStudioFilterHook(studio, showChildStudioContent);
return (
<GroupList
<FilteredGroupList
filterHook={filterHook}
alterQuery={active}
view={View.StudioGroups}

View file

@ -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 {
@ -199,8 +199,6 @@ export const FilteredStudioList = PatchComponent(
"FilteredStudioList",
(props: IStudioList) => {
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"
/>
);

View file

@ -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 <GroupList filterHook={filterHook} alterQuery={active} />;
return (
<FilteredGroupList
filterHook={filterHook}
alterQuery={active}
view={View.TagGroups}
/>
);
};