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
This commit is contained in:
WithoutPants 2026-03-19 08:50:42 +11:00 committed by GitHub
parent b76dd089f5
commit 208c19a81d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 244 additions and 115 deletions

View file

@ -334,19 +334,19 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (
},
{
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,
},

View file

@ -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<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 }) => {
({ 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 (
<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>;
return (
<TagListTable
tags={tags}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.Tagger) {
return <TagTagger tags={tags} />;
@ -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)}
/>
</LoadedContent>

View file

@ -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<string>;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
const TABLE_NAME = "tags";
export const TagListTable: React.FC<ITagListTableProps> = (
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) => (
<Link to={`/tags/${tag.id}`}>
<img
loading="lazy"
className="image-thumbnail"
alt={tag.name ?? ""}
src={tag.image_path ?? ""}
/>
</Link>
);
const NameCell = (tag: GQL.TagListDataFragment) => (
<Link to={`/tags/${tag.id}`}>
<div className="ellips-data" title={tag.name}>
{tag.name}
</div>
</Link>
);
const AliasesCell = (tag: GQL.TagListDataFragment) => {
let aliases = tag.aliases ? tag.aliases.join(", ") : "";
return (
<span className="ellips-data" title={aliases}>
{aliases}
</span>
);
};
const FavoriteCell = (tag: GQL.TagListDataFragment) => (
<Button
className={cx("minimal", tag.favorite ? "favorite" : "not-favorite")}
onClick={() => setFavorite(!tag.favorite, tag.id)}
>
<Icon icon={faHeart} />
</Button>
);
const SceneCountCell = (tag: GQL.TagListDataFragment) => (
<Link to={NavUtils.makeTagScenesUrl(tag)}>
<span>{tag.scene_count}</span>
</Link>
);
const GalleryCountCell = (tag: GQL.TagListDataFragment) => (
<Link to={NavUtils.makeTagGalleriesUrl(tag)}>
<span>{tag.gallery_count}</span>
</Link>
);
const ImageCountCell = (tag: GQL.TagListDataFragment) => (
<Link to={NavUtils.makeTagImagesUrl(tag)}>
<span>{tag.image_count}</span>
</Link>
);
const GroupCountCell = (tag: GQL.TagListDataFragment) => (
<Link to={NavUtils.makeTagGroupsUrl(tag)}>
<span>{tag.group_count}</span>
</Link>
);
const StudioCountCell = (tag: GQL.TagListDataFragment) => (
<Link to={NavUtils.makeTagStudiosUrl(tag)}>
<span>{tag.studio_count}</span>
</Link>
);
const PerformerCountCell = (tag: GQL.TagListDataFragment) => (
<Link to={NavUtils.makeTagPerformersUrl(tag)}>
<span>{tag.performer_count}</span>
</Link>
);
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 (
<ListTable
className="tag-table"
items={props.tags}
allColumns={allColumns}
columns={selectedColumns}
setColumns={(c) => saveColumns(c)}
selectedIds={props.selectedIds}
onSelectChange={props.onSelectChange}
renderCell={renderCell}
/>
);
};