mirror of
https://github.com/stashapp/stash.git
synced 2026-02-28 02:02:57 +01:00
Add sidebar to Tag list (#6610)
* Fix image export dialog * Add sidebar to TagList * Update plugin docs and types * Remove ItemList as it is no longer referenced
This commit is contained in:
parent
660feabced
commit
ead0c7fe07
6 changed files with 528 additions and 727 deletions
|
|
@ -618,7 +618,7 @@ export const FilteredImageList = PatchComponent(
|
|||
showModal(
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
groups: {
|
||||
images: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: all,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,33 +1,11 @@
|
|||
import React, {
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { QueryResult } from "@apollo/client";
|
||||
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import {
|
||||
EditFilterDialog,
|
||||
useShowEditFilter,
|
||||
} from "src/components/List/EditFilterDialog";
|
||||
import { FilterTags } from "./FilterTags";
|
||||
import { View } from "./views";
|
||||
import { useShowEditFilter } from "src/components/List/EditFilterDialog";
|
||||
import { IHasID } from "src/utils/data";
|
||||
import {
|
||||
ListContext,
|
||||
QueryResultContext,
|
||||
useListContext,
|
||||
useQueryResultContext,
|
||||
} from "./ListProvider";
|
||||
import { FilterContext, SetFilterURL, useFilter } from "./FilterProvider";
|
||||
import { useModal } from "src/hooks/modal";
|
||||
import {
|
||||
IFilterStateHook,
|
||||
IQueryResultHook,
|
||||
useDefaultFilter,
|
||||
useEnsureValidPage,
|
||||
useFilterOperations,
|
||||
useFilterState,
|
||||
|
|
@ -36,15 +14,7 @@ import {
|
|||
useQueryResult,
|
||||
useScrollToTopOnPageChange,
|
||||
} from "./util";
|
||||
import {
|
||||
FilteredListToolbar,
|
||||
IFilteredListToolbar,
|
||||
IItemListOperation,
|
||||
} from "./FilteredListToolbar";
|
||||
import { PagedList } from "./PagedList";
|
||||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
import { useZoomKeybinds } from "./ZoomSlider";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
|
||||
interface IFilteredItemList<
|
||||
T extends QueryResult,
|
||||
|
|
@ -119,346 +89,6 @@ export function useFilteredItemList<
|
|||
};
|
||||
}
|
||||
|
||||
interface IItemListProps<T extends QueryResult, E extends IHasID, M = unknown> {
|
||||
view?: View;
|
||||
otherOperations?: IItemListOperation<T>[];
|
||||
renderContent: (
|
||||
result: T,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>,
|
||||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void,
|
||||
onChangePage: (page: number) => void,
|
||||
pageCount: number
|
||||
) => React.ReactNode;
|
||||
renderMetadataByline?: (data: T, metadataInfo?: M) => React.ReactNode;
|
||||
renderEditDialog?: (
|
||||
selected: E[],
|
||||
onClose: (applied: boolean) => void
|
||||
) => React.ReactNode;
|
||||
renderDeleteDialog?: (
|
||||
selected: E[],
|
||||
onClose: (confirmed: boolean) => void
|
||||
) => React.ReactNode;
|
||||
addKeybinds?: (
|
||||
result: T,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>
|
||||
) => () => void;
|
||||
renderToolbar?: (props: IFilteredListToolbar) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const ItemList = <T extends QueryResult, E extends IHasID, M = unknown>(
|
||||
props: IItemListProps<T, E, M>
|
||||
) => {
|
||||
const {
|
||||
view,
|
||||
otherOperations,
|
||||
renderContent,
|
||||
renderEditDialog,
|
||||
renderDeleteDialog,
|
||||
renderMetadataByline,
|
||||
addKeybinds,
|
||||
renderToolbar: providedToolbar,
|
||||
} = props;
|
||||
|
||||
const { filter, setFilter: updateFilter } = useFilter();
|
||||
const { effectiveFilter, result, metadataInfo, cachedResult, totalCount } =
|
||||
useQueryResultContext<T, E, M>();
|
||||
const listSelect = useListContext<E>();
|
||||
const {
|
||||
selectedIds,
|
||||
getSelected,
|
||||
onSelectChange,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
onInvertSelection,
|
||||
} = listSelect;
|
||||
|
||||
// scroll to the top of the page when the page changes
|
||||
useScrollToTopOnPageChange(filter.currentPage, result.loading);
|
||||
|
||||
const { modal, showModal, closeModal } = useModal();
|
||||
|
||||
const metadataByline = useMemo(() => {
|
||||
if (cachedResult.loading) return "";
|
||||
|
||||
return renderMetadataByline?.(cachedResult, metadataInfo) ?? "";
|
||||
}, [renderMetadataByline, cachedResult, metadataInfo]);
|
||||
|
||||
const pages = Math.ceil(totalCount / filter.itemsPerPage);
|
||||
|
||||
const onChangePage = useCallback(
|
||||
(p: number) => {
|
||||
updateFilter(filter.changePage(p));
|
||||
},
|
||||
[filter, updateFilter]
|
||||
);
|
||||
|
||||
useEnsureValidPage(filter, totalCount, updateFilter);
|
||||
|
||||
const showEditFilter = useCallback(
|
||||
(editingCriterion?: string) => {
|
||||
function onApplyEditFilter(f: ListFilterModel) {
|
||||
closeModal();
|
||||
updateFilter(f);
|
||||
}
|
||||
|
||||
showModal(
|
||||
<EditFilterDialog
|
||||
filter={filter}
|
||||
onApply={onApplyEditFilter}
|
||||
onCancel={() => closeModal()}
|
||||
editingCriterion={editingCriterion}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[filter, updateFilter, showModal, closeModal]
|
||||
);
|
||||
|
||||
useListKeyboardShortcuts({
|
||||
currentPage: filter.currentPage,
|
||||
onChangePage,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
onInvertSelection,
|
||||
pages,
|
||||
showEditFilter,
|
||||
});
|
||||
|
||||
const zoomable =
|
||||
filter.displayMode === DisplayMode.Grid ||
|
||||
filter.displayMode === DisplayMode.Wall;
|
||||
|
||||
useZoomKeybinds({
|
||||
zoomIndex: zoomable ? filter.zoomIndex : undefined,
|
||||
onChangeZoom: (zoom) => updateFilter(filter.setZoom(zoom)),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (addKeybinds) {
|
||||
const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds);
|
||||
return () => {
|
||||
unbindExtras();
|
||||
};
|
||||
}
|
||||
}, [addKeybinds, result, effectiveFilter, selectedIds]);
|
||||
|
||||
const operations = useMemo(() => {
|
||||
async function onOperationClicked(o: IItemListOperation<T>) {
|
||||
await o.onClick(result, effectiveFilter, selectedIds);
|
||||
if (o.postRefetch) {
|
||||
result.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
return otherOperations?.map((o) => ({
|
||||
text: o.text,
|
||||
onClick: () => {
|
||||
onOperationClicked(o);
|
||||
},
|
||||
isDisplayed: () => {
|
||||
if (o.isDisplayed) {
|
||||
return o.isDisplayed(result, effectiveFilter, selectedIds);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
icon: o.icon,
|
||||
buttonVariant: o.buttonVariant,
|
||||
}));
|
||||
}, [result, effectiveFilter, selectedIds, otherOperations]);
|
||||
|
||||
function onEdit() {
|
||||
if (!renderEditDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
showModal(
|
||||
renderEditDialog(getSelected(), (applied) => onEditDialogClosed(applied))
|
||||
);
|
||||
}
|
||||
|
||||
function onEditDialogClosed(applied: boolean) {
|
||||
if (applied) {
|
||||
onSelectNone();
|
||||
}
|
||||
closeModal();
|
||||
|
||||
// refetch
|
||||
result.refetch();
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!renderDeleteDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
showModal(
|
||||
renderDeleteDialog(getSelected(), (deleted) =>
|
||||
onDeleteDialogClosed(deleted)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
if (deleted) {
|
||||
onSelectNone();
|
||||
}
|
||||
closeModal();
|
||||
|
||||
// refetch
|
||||
result.refetch();
|
||||
}
|
||||
|
||||
function onRemoveCriterion(removedCriterion: Criterion, valueIndex?: number) {
|
||||
if (valueIndex === undefined) {
|
||||
updateFilter(
|
||||
filter.removeCriterion(removedCriterion.criterionOption.type)
|
||||
);
|
||||
} else {
|
||||
updateFilter(
|
||||
filter.removeCustomFieldCriterion(
|
||||
removedCriterion.criterionOption.type,
|
||||
valueIndex
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onClearAllCriteria() {
|
||||
updateFilter(filter.clearCriteria());
|
||||
}
|
||||
|
||||
const filterListToolbarProps: IFilteredListToolbar = {
|
||||
filter,
|
||||
setFilter: updateFilter,
|
||||
listSelect,
|
||||
showEditFilter,
|
||||
view: view,
|
||||
operations: operations,
|
||||
zoomable: zoomable,
|
||||
onEdit: renderEditDialog ? onEdit : undefined,
|
||||
onDelete: renderDeleteDialog ? onDelete : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="item-list-container">
|
||||
{providedToolbar ? (
|
||||
providedToolbar(filterListToolbarProps)
|
||||
) : (
|
||||
<FilteredListToolbar {...filterListToolbarProps} />
|
||||
)}
|
||||
<FilterTags
|
||||
criteria={filter.criteria}
|
||||
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
||||
onRemoveCriterion={onRemoveCriterion}
|
||||
onRemoveAll={() => onClearAllCriteria()}
|
||||
/>
|
||||
{modal}
|
||||
|
||||
<PagedList
|
||||
result={result}
|
||||
cachedResult={cachedResult}
|
||||
filter={filter}
|
||||
totalCount={totalCount}
|
||||
onChangePage={onChangePage}
|
||||
metadataByline={metadataByline}
|
||||
>
|
||||
{renderContent(
|
||||
result,
|
||||
// #4780 - use effectiveFilter to ensure filterHook is applied
|
||||
effectiveFilter,
|
||||
selectedIds,
|
||||
onSelectChange,
|
||||
onChangePage,
|
||||
pages
|
||||
)}
|
||||
</PagedList>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IItemListContextProps<
|
||||
T extends QueryResult,
|
||||
E extends IHasID,
|
||||
M = unknown
|
||||
> {
|
||||
filterMode: GQL.FilterMode;
|
||||
defaultSort?: string;
|
||||
defaultFilter?: ListFilterModel;
|
||||
useResult: (filter: ListFilterModel) => T;
|
||||
useMetadataInfo?: (filter: ListFilterModel) => M;
|
||||
getCount: (data: T) => number;
|
||||
getItems: (data: T) => E[];
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
view?: View;
|
||||
alterQuery?: boolean;
|
||||
selectable?: boolean;
|
||||
}
|
||||
|
||||
// Provides the contexts for the ItemList component. Includes functionality to scroll
|
||||
// to top on page change.
|
||||
export const ItemListContext = <
|
||||
T extends QueryResult,
|
||||
E extends IHasID,
|
||||
M = unknown
|
||||
>(
|
||||
props: PropsWithChildren<IItemListContextProps<T, E, M>>
|
||||
) => {
|
||||
const {
|
||||
filterMode,
|
||||
defaultSort,
|
||||
defaultFilter: providedDefaultFilter,
|
||||
useResult,
|
||||
useMetadataInfo,
|
||||
getCount,
|
||||
getItems,
|
||||
view,
|
||||
filterHook,
|
||||
alterQuery = true,
|
||||
selectable,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
const { configuration: config } = useConfigurationContext();
|
||||
|
||||
const emptyFilter = useMemo(
|
||||
() =>
|
||||
providedDefaultFilter?.clone() ??
|
||||
new ListFilterModel(filterMode, config, {
|
||||
defaultSortBy: defaultSort,
|
||||
}),
|
||||
[config, filterMode, defaultSort, providedDefaultFilter]
|
||||
);
|
||||
|
||||
const [filter, setFilterState] = useState<ListFilterModel>(
|
||||
() =>
|
||||
new ListFilterModel(filterMode, config, { defaultSortBy: defaultSort })
|
||||
);
|
||||
|
||||
const { defaultFilter } = useDefaultFilter(emptyFilter, view);
|
||||
|
||||
return (
|
||||
<FilterContext filter={filter} setFilter={setFilterState}>
|
||||
<SetFilterURL defaultFilter={defaultFilter} setURL={alterQuery}>
|
||||
<QueryResultContext
|
||||
filterHook={filterHook}
|
||||
useResult={useResult}
|
||||
useMetadataInfo={useMetadataInfo}
|
||||
getCount={getCount}
|
||||
getItems={getItems}
|
||||
>
|
||||
{({ items }) => (
|
||||
<ListContext selectable={selectable} items={items}>
|
||||
{children}
|
||||
</ListContext>
|
||||
)}
|
||||
</QueryResultContext>
|
||||
</SetFilterURL>
|
||||
</FilterContext>
|
||||
);
|
||||
};
|
||||
|
||||
export const showWhenSelected = <T extends QueryResult>(
|
||||
result: T,
|
||||
filter: ListFilterModel,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import React, { useState } from "react";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import Mousetrap from "mousetrap";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
|
||||
import { useFilteredItemList } from "../List/ItemList";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
|
|
@ -11,33 +11,269 @@ import {
|
|||
queryFindTagsForList,
|
||||
mutateMetadataAutoTag,
|
||||
useFindTagsForList,
|
||||
useTagDestroy,
|
||||
useTagsDestroy,
|
||||
} from "src/core/StashService";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
|
||||
import NavUtils from "src/utils/navigation";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog";
|
||||
import { ExportDialog } from "../Shared/ExportDialog";
|
||||
import { tagRelationHook } from "../../core/tags";
|
||||
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { TagMergeModal } from "./TagMergeDialog";
|
||||
import { Tag } from "./TagSelect";
|
||||
import { TagCardGrid } from "./TagCardGrid";
|
||||
import { EditTagsDialog } from "./EditTagsDialog";
|
||||
import { View } from "../List/views";
|
||||
import { IItemListOperation } from "../List/FilteredListToolbar";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import {
|
||||
FilteredListToolbar,
|
||||
IItemListOperation,
|
||||
} from "../List/FilteredListToolbar";
|
||||
import { PatchComponent, PatchContainerComponent } from "src/patch";
|
||||
import { TagTagger } from "../Tagger/tags/TagTagger";
|
||||
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 { 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 { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
|
||||
import { FavoriteTagCriterionOption } from "src/models/list-filter/criteria/favorite";
|
||||
|
||||
function getItems(result: GQL.FindTagsForListQueryResult) {
|
||||
return result?.data?.findTags?.tags ?? [];
|
||||
const TagList: React.FC<{
|
||||
tags: GQL.TagListDataFragment[];
|
||||
filter: ListFilterModel;
|
||||
selectedIds: Set<string>;
|
||||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
|
||||
onDelete: (tag: GQL.TagListDataFragment) => void;
|
||||
onAutoTag: (tag: GQL.TagListDataFragment) => void;
|
||||
}> = PatchComponent(
|
||||
"TagList",
|
||||
({ tags, filter, selectedIds, onSelectChange, onDelete, onAutoTag }) => {
|
||||
if (tags.length === 0 && filter.displayMode !== DisplayMode.Tagger) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<TagCardGrid
|
||||
tags={tags}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.List) {
|
||||
const tagElements = tags.map((tag) => {
|
||||
return (
|
||||
<div key={tag.id} className="tag-list-row row">
|
||||
<Link to={`/tags/${tag.id}`}>{tag.name}</Link>
|
||||
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="tag-list-button"
|
||||
onClick={() => onAutoTag(tag)}
|
||||
>
|
||||
<FormattedMessage id="actions.auto_tag" />
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagScenesUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.scenes"
|
||||
values={{
|
||||
count: tag.scene_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.scene_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagImagesUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.images"
|
||||
values={{
|
||||
count: tag.image_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.image_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagGalleriesUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.galleries"
|
||||
values={{
|
||||
count: tag.gallery_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.gallery_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagSceneMarkersUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.markers"
|
||||
values={{
|
||||
count: tag.scene_marker_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.scene_marker_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="tag-list-count">
|
||||
<FormattedMessage id="total" />:{" "}
|
||||
<FormattedNumber
|
||||
value={
|
||||
(tag.scene_count || 0) +
|
||||
(tag.scene_marker_count || 0) +
|
||||
(tag.image_count || 0) +
|
||||
(tag.gallery_count || 0)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<Button variant="danger" onClick={() => onDelete(tag)}>
|
||||
<Icon icon={faTrashAlt} color="danger" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return <div className="col col-sm-8 m-auto">{tagElements}</div>;
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Tagger) {
|
||||
return <TagTagger tags={tags} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
const TagFilterSidebarSections = PatchContainerComponent(
|
||||
"FilteredTagList.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";
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilteredSidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
showEditFilter={showEditFilter}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
view={view}
|
||||
focus={focus}
|
||||
/>
|
||||
|
||||
<TagFilterSidebarSections>
|
||||
{/* <SidebarTagsFilter
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
/> */}
|
||||
<SidebarBooleanFilter
|
||||
title={<FormattedMessage id="favourite" />}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
option={FavoriteTagCriterionOption}
|
||||
sectionID="favourite"
|
||||
/>
|
||||
</TagFilterSidebarSections>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<Button className="sidebar-close-button" onClick={onClose}>
|
||||
<FormattedMessage id={showResultsId} values={{ count }} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function useViewRandom(filter: ListFilterModel, count: number) {
|
||||
const history = useHistory();
|
||||
|
||||
const viewRandom = useCallback(async () => {
|
||||
// query for a random tag
|
||||
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 queryFindTagsForList(filterCopy);
|
||||
if (singleResult.data.findTags.tags.length === 1) {
|
||||
const { id } = singleResult.data.findTags.tags[0];
|
||||
// navigate to the tag page
|
||||
history.push(`/tags/${id}`);
|
||||
}
|
||||
}, [history, filter, count]);
|
||||
|
||||
return viewRandom;
|
||||
}
|
||||
|
||||
function getCount(result: GQL.FindTagsForListQueryResult) {
|
||||
return result?.data?.findTags?.count ?? 0;
|
||||
function useAddKeybinds(filter: ListFilterModel, count: number) {
|
||||
const viewRandom = useViewRandom(filter, count);
|
||||
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("p r", () => {
|
||||
viewRandom();
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("p r");
|
||||
};
|
||||
}, [viewRandom]);
|
||||
}
|
||||
|
||||
interface ITagList {
|
||||
|
|
@ -46,105 +282,155 @@ interface ITagList {
|
|||
extraOperations?: IItemListOperation<GQL.FindTagsForListQueryResult>[];
|
||||
}
|
||||
|
||||
export const TagList: React.FC<ITagList> = PatchComponent(
|
||||
"TagList",
|
||||
({ filterHook, alterQuery, extraOperations = [] }) => {
|
||||
const Toast = useToast();
|
||||
const [deletingTag, setDeletingTag] =
|
||||
useState<Partial<GQL.TagListDataFragment> | null>(null);
|
||||
|
||||
const filterMode = GQL.FilterMode.Tags;
|
||||
const view = View.Tags;
|
||||
|
||||
function getDeleteTagInput() {
|
||||
const tagInput: Partial<GQL.TagDestroyInput> = {};
|
||||
if (deletingTag) {
|
||||
tagInput.id = deletingTag.id;
|
||||
}
|
||||
return tagInput as GQL.TagDestroyInput;
|
||||
}
|
||||
const [deleteTag] = useTagDestroy(getDeleteTagInput());
|
||||
|
||||
export const FilteredTagList = PatchComponent(
|
||||
"FilteredTagList",
|
||||
(props: ITagList) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const [mergeTags, setMergeTags] = useState<Tag[] | undefined>(undefined);
|
||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||
const [isExportAll, setIsExportAll] = useState(false);
|
||||
const Toast = useToast();
|
||||
|
||||
const otherOperations = [
|
||||
...extraOperations,
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.view_random" }),
|
||||
onClick: viewRandom,
|
||||
},
|
||||
{
|
||||
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
|
||||
onClick: merge,
|
||||
isDisplayed: showWhenSelected,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.export" }),
|
||||
onClick: onExport,
|
||||
isDisplayed: showWhenSelected,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.export_all" }),
|
||||
onClick: onExportAll,
|
||||
},
|
||||
];
|
||||
const searchFocus = useFocus();
|
||||
|
||||
function addKeybinds(
|
||||
result: GQL.FindTagsForListQueryResult,
|
||||
filter: ListFilterModel
|
||||
) {
|
||||
Mousetrap.bind("p r", () => {
|
||||
viewRandom(result, filter);
|
||||
const { filterHook, alterQuery, extraOperations = [] } = props;
|
||||
|
||||
const view = View.Tags;
|
||||
|
||||
// States
|
||||
const {
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
sectionOpen,
|
||||
setSectionOpen,
|
||||
loading: sidebarStateLoading,
|
||||
} = useSidebarState(view);
|
||||
|
||||
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
|
||||
useFilteredItemList({
|
||||
filterStateProps: {
|
||||
filterMode: GQL.FilterMode.Tags,
|
||||
view,
|
||||
useURL: alterQuery,
|
||||
},
|
||||
queryResultProps: {
|
||||
useResult: useFindTagsForList,
|
||||
getCount: (r) => r.data?.findTags.count ?? 0,
|
||||
getItems: (r) => r.data?.findTags.tags ?? [],
|
||||
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(effectiveFilter, 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");
|
||||
};
|
||||
});
|
||||
|
||||
const onCloseEditDelete = useCloseEditDelete({
|
||||
closeModal,
|
||||
onSelectNone,
|
||||
result,
|
||||
});
|
||||
|
||||
const viewRandom = useViewRandom(effectiveFilter, totalCount);
|
||||
|
||||
function onExport(all: boolean) {
|
||||
showModal(
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
studios: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: all,
|
||||
},
|
||||
}}
|
||||
onClose={() => closeModal()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function viewRandom(
|
||||
result: GQL.FindTagsForListQueryResult,
|
||||
filter: ListFilterModel
|
||||
) {
|
||||
// query for a random tag
|
||||
if (result.data?.findTags) {
|
||||
const { count } = result.data.findTags;
|
||||
|
||||
const index = Math.floor(Math.random() * count);
|
||||
const filterCopy = cloneDeep(filter);
|
||||
filterCopy.itemsPerPage = 1;
|
||||
filterCopy.currentPage = index + 1;
|
||||
const singleResult = await queryFindTagsForList(filterCopy);
|
||||
if (singleResult.data.findTags.tags.length === 1) {
|
||||
const { id } = singleResult.data.findTags.tags[0];
|
||||
// navigate to the tag page
|
||||
history.push(`/tags/${id}`);
|
||||
}
|
||||
}
|
||||
function onEdit() {
|
||||
showModal(
|
||||
<EditTagsDialog selected={selectedItems} onClose={onCloseEditDelete} />
|
||||
);
|
||||
}
|
||||
|
||||
async function merge(
|
||||
result: GQL.FindTagsForListQueryResult,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>
|
||||
) {
|
||||
const selected =
|
||||
result.data?.findTags.tags.filter((t) => selectedIds.has(t.id)) ?? [];
|
||||
setMergeTags(selected);
|
||||
function onDelete(tag?: GQL.TagListDataFragment) {
|
||||
const itemsToDelete = tag ? [tag] : selectedItems;
|
||||
|
||||
showModal(
|
||||
<DeleteEntityDialog
|
||||
selected={itemsToDelete}
|
||||
onClose={onCloseEditDelete}
|
||||
singularEntity={intl.formatMessage({ id: "tag" })}
|
||||
pluralEntity={intl.formatMessage({ id: "tags" })}
|
||||
destroyMutation={useTagsDestroy}
|
||||
onDeleted={() => {
|
||||
itemsToDelete.forEach((t) =>
|
||||
tagRelationHook(
|
||||
t,
|
||||
{ parents: t.parents ?? [], children: t.children ?? [] },
|
||||
{ parents: [], children: [] }
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function onExport() {
|
||||
setIsExportAll(false);
|
||||
setIsExportDialogOpen(true);
|
||||
}
|
||||
|
||||
async function onExportAll() {
|
||||
setIsExportAll(true);
|
||||
setIsExportDialogOpen(true);
|
||||
function onMerge() {
|
||||
showModal(
|
||||
<TagMergeModal
|
||||
tags={selectedItems}
|
||||
onClose={(mergedId?: string) => {
|
||||
onCloseEditDelete();
|
||||
if (mergedId) {
|
||||
history.push(`/tags/${mergedId}`);
|
||||
}
|
||||
}}
|
||||
show
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function onAutoTag(tag: GQL.TagListDataFragment) {
|
||||
|
|
@ -157,269 +443,151 @@ export const TagList: React.FC<ITagList> = PatchComponent(
|
|||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
try {
|
||||
const oldRelations = {
|
||||
parents: deletingTag?.parents ?? [],
|
||||
children: deletingTag?.children ?? [],
|
||||
};
|
||||
await deleteTag();
|
||||
tagRelationHook(deletingTag as GQL.TagListDataFragment, oldRelations, {
|
||||
parents: [],
|
||||
children: [],
|
||||
});
|
||||
Toast.success(
|
||||
intl.formatMessage(
|
||||
{ id: "toast.delete_past_tense" },
|
||||
{
|
||||
count: 1,
|
||||
singularEntity: intl.formatMessage({ id: "tag" }),
|
||||
pluralEntity: intl.formatMessage({ id: "tags" }),
|
||||
}
|
||||
)
|
||||
);
|
||||
setDeletingTag(null);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
}
|
||||
const convertedExtraOperations = extraOperations.map((op) => ({
|
||||
text: op.text,
|
||||
onClick: () => op.onClick(result, filter, selectedIds),
|
||||
isDisplayed: () => op.isDisplayed?.(result, filter, selectedIds) ?? true,
|
||||
}));
|
||||
|
||||
function renderContent(
|
||||
result: GQL.FindTagsForListQueryResult,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>,
|
||||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
|
||||
) {
|
||||
function renderMergeDialog() {
|
||||
if (mergeTags) {
|
||||
return (
|
||||
<TagMergeModal
|
||||
tags={mergeTags}
|
||||
onClose={(mergedId?: string) => {
|
||||
setMergeTags(undefined);
|
||||
if (mergedId) {
|
||||
history.push(`/tags/${mergedId}`);
|
||||
}
|
||||
}}
|
||||
show
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
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.merge" })}…`,
|
||||
onClick: () => onMerge(),
|
||||
isDisplayed: () => hasSelection,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.export" }),
|
||||
onClick: () => onExport(false),
|
||||
isDisplayed: () => hasSelection,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.export_all" }),
|
||||
onClick: () => onExport(true),
|
||||
},
|
||||
];
|
||||
|
||||
function maybeRenderExportDialog() {
|
||||
if (isExportDialogOpen) {
|
||||
return (
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
tags: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: isExportAll,
|
||||
},
|
||||
}}
|
||||
onClose={() => setIsExportDialogOpen(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
// render
|
||||
if (sidebarStateLoading) return null;
|
||||
|
||||
function renderTags() {
|
||||
if (!result.data?.findTags) return;
|
||||
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<TagCardGrid
|
||||
tags={result.data.findTags.tags}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.List) {
|
||||
const deleteAlert = (
|
||||
<ModalComponent
|
||||
onHide={() => {}}
|
||||
show={!!deletingTag}
|
||||
icon={faTrashAlt}
|
||||
accept={{
|
||||
onClick: onDelete,
|
||||
variant: "danger",
|
||||
text: intl.formatMessage({ id: "actions.delete" }),
|
||||
}}
|
||||
cancel={{ onClick: () => setDeletingTag(null) }}
|
||||
>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id="dialogs.delete_confirm"
|
||||
values={{ entityName: deletingTag && deletingTag.name }}
|
||||
/>
|
||||
</span>
|
||||
</ModalComponent>
|
||||
);
|
||||
|
||||
const tagElements = result.data.findTags.tags.map((tag) => {
|
||||
return (
|
||||
<div key={tag.id} className="tag-list-row row">
|
||||
<Link to={`/tags/${tag.id}`}>{tag.name}</Link>
|
||||
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="tag-list-button"
|
||||
onClick={() => onAutoTag(tag)}
|
||||
>
|
||||
<FormattedMessage id="actions.auto_tag" />
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagScenesUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.scenes"
|
||||
values={{
|
||||
count: tag.scene_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.scene_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagImagesUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.images"
|
||||
values={{
|
||||
count: tag.image_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.image_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagGalleriesUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.galleries"
|
||||
values={{
|
||||
count: tag.gallery_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.gallery_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagSceneMarkersUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.markers"
|
||||
values={{
|
||||
count: tag.scene_marker_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.scene_marker_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="tag-list-count">
|
||||
<FormattedMessage id="total" />:{" "}
|
||||
<FormattedNumber
|
||||
value={
|
||||
(tag.scene_count || 0) +
|
||||
(tag.scene_marker_count || 0) +
|
||||
(tag.image_count || 0) +
|
||||
(tag.gallery_count || 0)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<Button variant="danger" onClick={() => setDeletingTag(tag)}>
|
||||
<Icon icon={faTrashAlt} color="danger" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="col col-sm-8 m-auto">
|
||||
{tagElements}
|
||||
{deleteAlert}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Wall) {
|
||||
return <h1>TODO</h1>;
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Tagger) {
|
||||
return <TagTagger tags={result.data.findTags.tags} />;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{renderMergeDialog()}
|
||||
{maybeRenderExportDialog()}
|
||||
{renderTags()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderEditDialog(
|
||||
selectedTags: GQL.TagListDataFragment[],
|
||||
onClose: (confirmed: boolean) => void
|
||||
) {
|
||||
return <EditTagsDialog selected={selectedTags} onClose={onClose} />;
|
||||
}
|
||||
|
||||
function renderDeleteDialog(
|
||||
selectedTags: GQL.TagListDataFragment[],
|
||||
onClose: (confirmed: boolean) => void
|
||||
) {
|
||||
return (
|
||||
<DeleteEntityDialog
|
||||
selected={selectedTags}
|
||||
onClose={onClose}
|
||||
singularEntity={intl.formatMessage({ id: "tag" })}
|
||||
pluralEntity={intl.formatMessage({ id: "tags" })}
|
||||
destroyMutation={useTagsDestroy}
|
||||
onDeleted={() => {
|
||||
selectedTags.forEach((t) =>
|
||||
tagRelationHook(
|
||||
t,
|
||||
{ parents: t.parents ?? [], children: t.children ?? [] },
|
||||
{ parents: [], children: [] }
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const operations = (
|
||||
<ListOperations
|
||||
items={items.length}
|
||||
hasSelection={hasSelection}
|
||||
operations={otherOperations}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
operationsMenuClassName="tag-list-operations-dropdown"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ItemListContext
|
||||
filterMode={filterMode}
|
||||
useResult={useFindTagsForList}
|
||||
getItems={getItems}
|
||||
getCount={getCount}
|
||||
alterQuery={alterQuery}
|
||||
filterHook={filterHook}
|
||||
view={view}
|
||||
selectable
|
||||
<div
|
||||
className={cx("item-list-container tag-list", {
|
||||
"hide-sidebar": !showSidebar,
|
||||
})}
|
||||
>
|
||||
<ItemList
|
||||
view={view}
|
||||
otherOperations={otherOperations}
|
||||
addKeybinds={addKeybinds}
|
||||
renderContent={renderContent}
|
||||
renderEditDialog={renderEditDialog}
|
||||
renderDeleteDialog={renderDeleteDialog}
|
||||
/>
|
||||
</ItemListContext>
|
||||
{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)}
|
||||
>
|
||||
<FilteredListToolbar
|
||||
filter={filter}
|
||||
listSelect={listSelect}
|
||||
setFilter={setFilter}
|
||||
showEditFilter={showEditFilter}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
operationComponent={operations}
|
||||
view={view}
|
||||
zoomable
|
||||
/>
|
||||
|
||||
<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}>
|
||||
<TagList
|
||||
filter={effectiveFilter}
|
||||
tags={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
onDelete={(tag) => onDelete(tag)}
|
||||
onAutoTag={(tag) => onAutoTag(tag)}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</SidebarPaneContent>
|
||||
</SidebarPane>
|
||||
</SidebarStateContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { Helmet } from "react-helmet";
|
|||
import { useTitleProps } from "src/hooks/title";
|
||||
import Tag from "./TagDetails/Tag";
|
||||
import TagCreate from "./TagDetails/TagCreate";
|
||||
import { TagList } from "./TagList";
|
||||
import { FilteredTagList } from "./TagList";
|
||||
|
||||
const Tags: React.FC = () => {
|
||||
return <TagList />;
|
||||
return <FilteredTagList />;
|
||||
};
|
||||
|
||||
const TagRoutes: React.FC = () => {
|
||||
|
|
|
|||
|
|
@ -236,6 +236,7 @@ Returns `void`.
|
|||
- `FilteredSceneList`
|
||||
- `FilteredSceneMarkerList`
|
||||
- `FilteredStudioList`
|
||||
- `FilteredTagList`
|
||||
- `FolderSelect`
|
||||
- `FrontPage`
|
||||
- `GalleryCard`
|
||||
|
|
@ -353,6 +354,7 @@ Returns `void`.
|
|||
- `TagCardGrid`
|
||||
- `TagIDSelect`
|
||||
- `TagLink`
|
||||
- `TagList`
|
||||
- `TagRecommendationRow`
|
||||
- `TagSelect`
|
||||
- `TagSelect.sort`
|
||||
|
|
|
|||
1
ui/v2.5/src/pluginApi.d.ts
vendored
1
ui/v2.5/src/pluginApi.d.ts
vendored
|
|
@ -673,6 +673,7 @@ declare namespace PluginApi {
|
|||
FilteredSceneList: React.FC<any>;
|
||||
FilteredSceneMarkerList: React.FC<any>;
|
||||
FilteredStudioList: React.FC<any>;
|
||||
FilteredTagList: React.FC<any>;
|
||||
FolderSelect: React.FC<any>;
|
||||
FrontPage: React.FC<any>;
|
||||
GalleryCard: React.FC<any>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue