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:
WithoutPants 2026-02-27 07:44:23 +11:00 committed by GitHub
parent 660feabced
commit ead0c7fe07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 528 additions and 727 deletions

View file

@ -618,7 +618,7 @@ export const FilteredImageList = PatchComponent(
showModal(
<ExportDialog
exportInput={{
groups: {
images: {
ids: Array.from(selectedIds.values()),
all: all,
},

View file

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

View file

@ -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>
);
}
);

View file

@ -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 = () => {

View file

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

View file

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