From 208c19a81d2d236f5aa26243d3216a2c45f37828 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:50:42 +1100 Subject: [PATCH] Replace tag list view with tag list table (#6703) * Replace tag list view with tag list table Uses same styling as performer list table * Remove "count" suffix from count headers in performer list --- .../Performers/PerformerListTable.tsx | 6 +- ui/v2.5/src/components/Tags/TagList.tsx | 123 +--------- ui/v2.5/src/components/Tags/TagListTable.tsx | 230 ++++++++++++++++++ 3 files changed, 244 insertions(+), 115 deletions(-) create mode 100644 ui/v2.5/src/components/Tags/TagListTable.tsx diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx index 3b500cee6..c155d1298 100644 --- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx +++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx @@ -334,19 +334,19 @@ export const PerformerListTable: React.FC = ( }, { value: "scene_count", - label: intl.formatMessage({ id: "scene_count" }), + label: intl.formatMessage({ id: "scenes" }), defaultShow: true, render: SceneCountCell, }, { value: "gallery_count", - label: intl.formatMessage({ id: "gallery_count" }), + label: intl.formatMessage({ id: "galleries" }), defaultShow: true, render: GalleryCountCell, }, { value: "image_count", - label: intl.formatMessage({ id: "image_count" }), + label: intl.formatMessage({ id: "images" }), defaultShow: true, render: ImageCountCell, }, diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index 38cc13141..8d6cbbeee 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -5,22 +5,17 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { useFilteredItemList } from "../List/ItemList"; import { Button } from "react-bootstrap"; -import { Link, useHistory } from "react-router-dom"; +import { useHistory } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { queryFindTagsForList, - mutateMetadataAutoTag, useFindTagsForList, 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 { FormattedMessage, useIntl } from "react-intl"; 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 { TagCardGrid } from "./TagCardGrid"; import { EditTagsDialog } from "./EditTagsDialog"; @@ -51,17 +46,16 @@ 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"; +import { TagListTable } from "./TagListTable"; const TagList: React.FC<{ tags: GQL.TagListDataFragment[]; filter: ListFilterModel; selectedIds: Set; 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 }) => { + ({ tags, filter, selectedIds, onSelectChange }) => { if (tags.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } @@ -77,95 +71,13 @@ const TagList: React.FC<{ ); } if (filter.displayMode === DisplayMode.List) { - const tagElements = tags.map((tag) => { - return ( -
- {tag.name} - -
- - - - - - - :{" "} - - - -
-
- ); - }); - - return
{tagElements}
; + return ( + + ); } if (filter.displayMode === DisplayMode.Tagger) { return ; @@ -287,7 +199,6 @@ export const FilteredTagList = PatchComponent( (props: ITagList) => { const intl = useIntl(); const history = useHistory(); - const Toast = useToast(); const searchFocus = useFocus(); @@ -433,16 +344,6 @@ export const FilteredTagList = PatchComponent( ); } - async function onAutoTag(tag: GQL.TagListDataFragment) { - if (!tag) return; - try { - await mutateMetadataAutoTag({ tags: [tag.id] }); - Toast.success(intl.formatMessage({ id: "toast.started_auto_tagging" })); - } catch (e) { - Toast.error(e); - } - } - const convertedExtraOperations = extraOperations.map((op) => ({ text: op.text, onClick: () => op.onClick(result, filter, selectedIds), @@ -566,8 +467,6 @@ export const FilteredTagList = PatchComponent( tags={items} selectedIds={selectedIds} onSelectChange={onSelectChange} - onDelete={(tag) => onDelete(tag)} - onAutoTag={(tag) => onAutoTag(tag)} /> diff --git a/ui/v2.5/src/components/Tags/TagListTable.tsx b/ui/v2.5/src/components/Tags/TagListTable.tsx new file mode 100644 index 000000000..f593c0d1f --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagListTable.tsx @@ -0,0 +1,230 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ + +import React from "react"; +import { useIntl } from "react-intl"; +import { Button } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import * as GQL from "src/core/generated-graphql"; +import { Icon } from "../Shared/Icon"; +import NavUtils from "src/utils/navigation"; +import { faHeart } from "@fortawesome/free-solid-svg-icons"; +import { useTagUpdate } from "src/core/StashService"; +import { useTableColumns } from "src/hooks/useTableColumns"; +import cx from "classnames"; +import { IColumn, ListTable } from "../List/ListTable"; + +interface ITagListTableProps { + tags: GQL.TagListDataFragment[]; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +} + +const TABLE_NAME = "tags"; + +export const TagListTable: React.FC = ( + props: ITagListTableProps +) => { + const intl = useIntl(); + + const [updateTag] = useTagUpdate(); + + function setFavorite(v: boolean, tagId: string) { + if (tagId) { + updateTag({ + variables: { + input: { + id: tagId, + favorite: v, + }, + }, + }); + } + } + + const ImageCell = (tag: GQL.TagListDataFragment) => ( + + {tag.name + + ); + + const NameCell = (tag: GQL.TagListDataFragment) => ( + +
+ {tag.name} +
+ + ); + + const AliasesCell = (tag: GQL.TagListDataFragment) => { + let aliases = tag.aliases ? tag.aliases.join(", ") : ""; + return ( + + {aliases} + + ); + }; + + const FavoriteCell = (tag: GQL.TagListDataFragment) => ( + + ); + + const SceneCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.scene_count} + + ); + + const GalleryCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.gallery_count} + + ); + + const ImageCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.image_count} + + ); + + const GroupCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.group_count} + + ); + + const StudioCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.studio_count} + + ); + + const PerformerCountCell = (tag: GQL.TagListDataFragment) => ( + + {tag.performer_count} + + ); + + interface IColumnSpec { + value: string; + label: string; + defaultShow?: boolean; + mandatory?: boolean; + render?: (tag: GQL.TagListDataFragment, index: number) => React.ReactNode; + } + + const allColumns: IColumnSpec[] = [ + { + value: "image", + label: intl.formatMessage({ id: "image" }), + defaultShow: true, + render: ImageCell, + }, + { + value: "name", + label: intl.formatMessage({ id: "name" }), + mandatory: true, + defaultShow: true, + render: NameCell, + }, + { + value: "aliases", + label: intl.formatMessage({ id: "aliases" }), + defaultShow: true, + render: AliasesCell, + }, + { + value: "favourite", + label: intl.formatMessage({ id: "favourite" }), + defaultShow: true, + render: FavoriteCell, + }, + { + value: "scene_count", + label: intl.formatMessage({ id: "scenes" }), + defaultShow: true, + render: SceneCountCell, + }, + { + value: "gallery_count", + label: intl.formatMessage({ id: "galleries" }), + defaultShow: true, + render: GalleryCountCell, + }, + { + value: "image_count", + label: intl.formatMessage({ id: "images" }), + defaultShow: true, + render: ImageCountCell, + }, + { + value: "group_count", + label: intl.formatMessage({ id: "groups" }), + defaultShow: true, + render: GroupCountCell, + }, + { + value: "performer_count", + label: intl.formatMessage({ id: "performers" }), + defaultShow: true, + render: PerformerCountCell, + }, + { + value: "studio_count", + label: intl.formatMessage({ id: "studios" }), + defaultShow: true, + render: StudioCountCell, + }, + ]; + + const defaultColumns = allColumns + .filter((col) => col.defaultShow) + .map((col) => col.value); + + const { selectedColumns, saveColumns } = useTableColumns( + TABLE_NAME, + defaultColumns + ); + + const columnRenderFuncs: Record< + string, + (tag: GQL.TagListDataFragment, index: number) => React.ReactNode + > = {}; + allColumns.forEach((col) => { + if (col.render) { + columnRenderFuncs[col.value] = col.render; + } + }); + + function renderCell( + column: IColumn, + tag: GQL.TagListDataFragment, + index: number + ) { + const render = columnRenderFuncs[column.value]; + + if (render) return render(tag, index); + } + + return ( + saveColumns(c)} + selectedIds={props.selectedIds} + onSelectChange={props.onSelectChange} + renderCell={renderCell} + /> + ); +};