mirror of
https://github.com/stashapp/stash.git
synced 2026-04-12 18:10:59 +02:00
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:
parent
fc31823fd2
commit
bede849fa6
23 changed files with 708 additions and 376 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue