Add descriptions to tags and display tag cards on hover (#2708)

* add descriptions to tags
* display tag description and tag image on hover

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
pickleahead 2022-10-06 01:01:06 +02:00 committed by GitHub
parent 480ae46dde
commit 4c73f2f845
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 185 additions and 8 deletions

View file

@ -1,6 +1,7 @@
fragment TagData on Tag { fragment TagData on Tag {
id id
name name
description
aliases aliases
ignore_auto_tag ignore_auto_tag
image_path image_path

View file

@ -1,6 +1,7 @@
type Tag { type Tag {
id: ID! id: ID!
name: String! name: String!
description: String
aliases: [String!]! aliases: [String!]!
ignore_auto_tag: Boolean! ignore_auto_tag: Boolean!
created_at: Time! created_at: Time!
@ -19,6 +20,7 @@ type Tag {
input TagCreateInput { input TagCreateInput {
name: String! name: String!
description: String
aliases: [String!] aliases: [String!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
@ -32,6 +34,7 @@ input TagCreateInput {
input TagUpdateInput { input TagUpdateInput {
id: ID! id: ID!
name: String name: String
description: String
aliases: [String!] aliases: [String!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean

View file

@ -10,6 +10,13 @@ import (
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
func (r *tagResolver) Description(ctx context.Context, obj *models.Tag) (*string, error) {
if obj.Description.Valid {
return &obj.Description.String, nil
}
return nil, nil
}
func (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) { func (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) {
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Tag.FindByChildTagID(ctx, obj.ID) ret, err = r.repository.Tag.FindByChildTagID(ctx, obj.ID)

View file

@ -2,6 +2,7 @@ package api
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"strconv" "strconv"
"time" "time"
@ -34,6 +35,10 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
} }
if input.Description != nil {
newTag.Description = sql.NullString{String: *input.Description, Valid: true}
}
if input.IgnoreAutoTag != nil { if input.IgnoreAutoTag != nil {
newTag.IgnoreAutoTag = *input.IgnoreAutoTag newTag.IgnoreAutoTag = *input.IgnoreAutoTag
} }
@ -195,6 +200,8 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
updatedTag.Name = input.Name updatedTag.Name = input.Name
} }
updatedTag.Description = translator.nullString(input.Description, "description")
t, err = qb.Update(ctx, updatedTag) t, err = qb.Update(ctx, updatedTag)
if err != nil { if err != nil {
return err return err

View file

@ -11,6 +11,7 @@ import (
type Tag struct { type Tag struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Aliases []string `json:"aliases,omitempty"` Aliases []string `json:"aliases,omitempty"`
Image string `json:"image,omitempty"` Image string `json:"image,omitempty"`
Parents []string `json:"parents,omitempty"` Parents []string `json:"parents,omitempty"`

View file

@ -1,10 +1,14 @@
package models package models
import "time" import (
"database/sql"
"time"
)
type Tag struct { type Tag struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"` // TODO make schema not null Name string `db:"name" json:"name"` // TODO make schema not null
Description sql.NullString `db:"description" json:"description"`
IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"`
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
@ -13,6 +17,7 @@ type Tag struct {
type TagPartial struct { type TagPartial struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
Name *string `db:"name" json:"name"` // TODO make schema not null Name *string `db:"name" json:"name"` // TODO make schema not null
Description *sql.NullString `db:"description" json:"description"`
IgnoreAutoTag *bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` IgnoreAutoTag *bool `db:"ignore_auto_tag" json:"ignore_auto_tag"`
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"`

View file

@ -22,7 +22,7 @@ import (
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
) )
var appSchemaVersion uint = 35 var appSchemaVersion uint = 36
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsBox embed.FS var migrationsBox embed.FS

View file

@ -0,0 +1 @@
ALTER TABLE `tags` ADD COLUMN `description` text;

View file

@ -20,6 +20,7 @@ type FinderAliasImageGetter interface {
func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) (*jsonschema.Tag, error) { func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) (*jsonschema.Tag, error) {
newTagJSON := jsonschema.Tag{ newTagJSON := jsonschema.Tag{
Name: tag.Name, Name: tag.Name,
Description: tag.Description.String,
IgnoreAutoTag: tag.IgnoreAutoTag, IgnoreAutoTag: tag.IgnoreAutoTag,
CreatedAt: json.JSONTime{Time: tag.CreatedAt.Timestamp}, CreatedAt: json.JSONTime{Time: tag.CreatedAt.Timestamp},
UpdatedAt: json.JSONTime{Time: tag.UpdatedAt.Timestamp}, UpdatedAt: json.JSONTime{Time: tag.UpdatedAt.Timestamp},

View file

@ -2,6 +2,7 @@ package tag
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
@ -23,7 +24,10 @@ const (
errParentsID = 6 errParentsID = 6
) )
const tagName = "testTag" const (
tagName = "testTag"
description = "description"
)
var ( var (
autoTagIgnored = true autoTagIgnored = true
@ -35,6 +39,10 @@ func createTag(id int) models.Tag {
return models.Tag{ return models.Tag{
ID: id, ID: id,
Name: tagName, Name: tagName,
Description: sql.NullString{
String: description,
Valid: true,
},
IgnoreAutoTag: autoTagIgnored, IgnoreAutoTag: autoTagIgnored,
CreatedAt: models.SQLiteTimestamp{ CreatedAt: models.SQLiteTimestamp{
Timestamp: createTime, Timestamp: createTime,
@ -48,6 +56,7 @@ func createTag(id int) models.Tag {
func createJSONTag(aliases []string, image string, parents []string) *jsonschema.Tag { func createJSONTag(aliases []string, image string, parents []string) *jsonschema.Tag {
return &jsonschema.Tag{ return &jsonschema.Tag{
Name: tagName, Name: tagName,
Description: description,
Aliases: aliases, Aliases: aliases,
IgnoreAutoTag: autoTagIgnored, IgnoreAutoTag: autoTagIgnored,
CreatedAt: json.JSONTime{ CreatedAt: json.JSONTime{

View file

@ -2,6 +2,7 @@ package tag
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
@ -42,6 +43,7 @@ type Importer struct {
func (i *Importer) PreImport(ctx context.Context) error { func (i *Importer) PreImport(ctx context.Context) error {
i.tag = models.Tag{ i.tag = models.Tag{
Name: i.Input.Name, Name: i.Input.Name,
Description: sql.NullString{String: i.Input.Description, Valid: true},
IgnoreAutoTag: i.Input.IgnoreAutoTag, IgnoreAutoTag: i.Input.IgnoreAutoTag,
CreatedAt: models.SQLiteTimestamp{Timestamp: i.Input.CreatedAt.GetTime()}, CreatedAt: models.SQLiteTimestamp{Timestamp: i.Input.CreatedAt.GetTime()},
UpdatedAt: models.SQLiteTimestamp{Timestamp: i.Input.UpdatedAt.GetTime()}, UpdatedAt: models.SQLiteTimestamp{Timestamp: i.Input.UpdatedAt.GetTime()},

View file

@ -40,6 +40,7 @@ func TestImporterPreImport(t *testing.T) {
i := Importer{ i := Importer{
Input: jsonschema.Tag{ Input: jsonschema.Tag{
Name: tagName, Name: tagName,
Description: description,
Image: invalidImage, Image: invalidImage,
IgnoreAutoTag: autoTagIgnored, IgnoreAutoTag: autoTagIgnored,
}, },

View file

@ -247,6 +247,13 @@ export const SettingsInterfacePanel: React.FC = () => {
/> />
</SettingSection> </SettingSection>
<SettingSection headingID="config.ui.tag_panel.heading"> <SettingSection headingID="config.ui.tag_panel.heading">
<BooleanSetting
id="show-tag-card-on-hover"
headingID="config.ui.show_tag_card_on_hover.heading"
subHeadingID="config.ui.show_tag_card_on_hover.description"
checked={ui.showTagCardOnHover ?? true}
onChange={(v) => saveUI({ showTagCardOnHover: v })}
/>
<BooleanSetting <BooleanSetting
id="show-child-tagged-content" id="show-child-tagged-content"
headingID="config.ui.tag_panel.options.show_child_tagged_content.heading" headingID="config.ui.tag_panel.options.show_child_tagged_content.heading"

View file

@ -27,6 +27,7 @@ import { ConfigurationContext } from "src/hooks/Config";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { objectTitle } from "src/core/files"; import { objectTitle } from "src/core/files";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
import { TagPopover } from "../Tags/TagPopover";
export type ValidTypes = export type ValidTypes =
| GQL.SlimPerformerDataFragment | GQL.SlimPerformerDataFragment
@ -659,7 +660,11 @@ export const TagSelect: React.FC<IFilterProps & { excludeIds?: string[] }> = (
}; };
} }
return <reactSelectComponents.Option {...thisOptionProps} />; return (
<TagPopover id={optionProps.data.value}>
<reactSelectComponents.Option {...thisOptionProps} />
</TagPopover>
);
}; };
const filterOption = (option: Option, rawInput: string): boolean => { const filterOption = (option: Option, rawInput: string): boolean => {

View file

@ -14,6 +14,7 @@ import TextUtils from "src/utils/text";
import { objectTitle } from "src/core/files"; import { objectTitle } from "src/core/files";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { TagPopover } from "../Tags/TagPopover";
interface IFile { interface IFile {
path: string; path: string;
@ -37,6 +38,7 @@ interface IProps {
} }
export const TagLink: React.FC<IProps> = (props: IProps) => { export const TagLink: React.FC<IProps> = (props: IProps) => {
let id: string = "";
let link: string = "#"; let link: string = "#";
let title: string = ""; let title: string = "";
if (props.tag) { if (props.tag) {
@ -55,6 +57,7 @@ export const TagLink: React.FC<IProps> = (props: IProps) => {
link = NavUtils.makeTagImagesUrl(props.tag); link = NavUtils.makeTagImagesUrl(props.tag);
break; break;
} }
id = props.tag.id || "";
title = props.tag.name || ""; title = props.tag.name || "";
} else if (props.performer) { } else if (props.performer) {
link = NavUtils.makePerformerScenesUrl(props.performer); link = NavUtils.makePerformerScenesUrl(props.performer);
@ -76,7 +79,9 @@ export const TagLink: React.FC<IProps> = (props: IProps) => {
} }
return ( return (
<Badge className={cx("tag-item", props.className)} variant="secondary"> <Badge className={cx("tag-item", props.className)} variant="secondary">
<TagPopover id={id}>
<Link to={link}>{title}</Link> <Link to={link}>{title}</Link>
</TagPopover>
</Badge> </Badge>
); );
}; };

View file

@ -4,7 +4,7 @@ import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { NavUtils } from "src/utils"; import { NavUtils } from "src/utils";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { Icon } from "../Shared"; import { Icon, TruncatedText } from "../Shared";
import { GridCard } from "../Shared/GridCard"; import { GridCard } from "../Shared/GridCard";
import { PopoverCountButton } from "../Shared/PopoverCountButton"; import { PopoverCountButton } from "../Shared/PopoverCountButton";
import { faMapMarkerAlt, faUser } from "@fortawesome/free-solid-svg-icons"; import { faMapMarkerAlt, faUser } from "@fortawesome/free-solid-svg-icons";
@ -24,6 +24,18 @@ export const TagCard: React.FC<IProps> = ({
selected, selected,
onSelectedChanged, onSelectedChanged,
}) => { }) => {
function maybeRenderDescription() {
if (tag.description) {
return (
<TruncatedText
className="tag-description"
text={tag.description}
lineCount={3}
/>
);
}
}
function maybeRenderParents() { function maybeRenderParents() {
if (tag.parents.length === 1) { if (tag.parents.length === 1) {
const parent = tag.parents[0]; const parent = tag.parents[0];
@ -181,6 +193,7 @@ export const TagCard: React.FC<IProps> = ({
} }
details={ details={
<> <>
{maybeRenderDescription()}
{maybeRenderParents()} {maybeRenderParents()}
{maybeRenderChildren()} {maybeRenderChildren()}
{maybeRenderPopoverButtonGroup()} {maybeRenderPopoverButtonGroup()}

View file

@ -267,6 +267,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
renderImage() renderImage()
)} )}
<h2>{tag.name}</h2> <h2>{tag.name}</h2>
<p>{tag.description}</p>
</div> </div>
{!isEditing ? ( {!isEditing ? (
<> <>

View file

@ -47,6 +47,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
const schema = yup.object({ const schema = yup.object({
name: yup.string().required(), name: yup.string().required(),
description: yup.string().optional().nullable(),
aliases: yup aliases: yup
.array(yup.string().required()) .array(yup.string().required())
.optional() .optional()
@ -65,6 +66,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
const initialValues = { const initialValues = {
name: tag?.name, name: tag?.name,
description: tag?.description,
aliases: tag?.aliases, aliases: tag?.aliases,
parent_ids: (tag?.parents ?? []).map((t) => t.id), parent_ids: (tag?.parents ?? []).map((t) => t.id),
child_ids: (tag?.children ?? []).map((t) => t.id), child_ids: (tag?.children ?? []).map((t) => t.id),
@ -167,6 +169,20 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
</Col> </Col>
</Form.Group> </Form.Group>
<Form.Group controlId="description" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "description" }),
})}
<Col xs={9}>
<Form.Control
as="textarea"
className="text-input"
placeholder={intl.formatMessage({ id: "description" })}
{...formik.getFieldProps("description")}
/>
</Col>
</Form.Group>
<Form.Group controlId="parent_tags" as={Row}> <Form.Group controlId="parent_tags" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: intl.formatMessage({ id: "parent_tags" }), title: intl.formatMessage({ id: "parent_tags" }),

View file

@ -0,0 +1,59 @@
import React from "react";
import { ErrorMessage, LoadingIndicator } from "../Shared";
import { HoverPopover } from "src/components/Shared";
import { useFindTag } from "../../core/StashService";
import { TagCard } from "./TagCard";
import { ConfigurationContext } from "../../hooks/Config";
import { IUIConfig } from "src/core/config";
interface ITagPopoverProps {
id?: string;
}
export const TagPopoverCard: React.FC<ITagPopoverCardProps> = ({ id }) => {
const { data, loading, error } = useFindTag(id ?? "");
if (loading)
return (
<div className="tag-popover-card-placeholder">
<LoadingIndicator card={true} message={""} />
</div>
);
if (error) return <ErrorMessage error={error.message} />;
if (!data?.findTag)
return <ErrorMessage error={`No tag found with id ${id}.`} />;
const tag = data.findTag;
return (
<div className="tag-popover-card">
<TagCard tag={tag} zoomIndex={0} />
</div>
);
};
export const TagPopover: React.FC<ITagPopoverProps> = ({ id, children }) => {
const { configuration: config } = React.useContext(ConfigurationContext);
const showTagCardOnHover =
(config?.ui as IUIConfig)?.showTagCardOnHover ?? true;
if (!id || !showTagCardOnHover) {
return <>{children}</>;
}
return (
<HoverPopover
placement={"top"}
enterDelay={500}
leaveDelay={100}
content={<TagPopoverCard id={id} />}
>
{children}
</HoverPopover>
);
};
interface ITagPopoverCardProps {
id?: string;
}

View file

@ -43,3 +43,28 @@
#tag-merge-menu .dropdown-item { #tag-merge-menu .dropdown-item {
align-items: center; align-items: center;
} }
.tag-card {
.tag-description + div {
margin-top: 1rem;
}
}
.tag-popover-card-placeholder {
display: flex;
max-width: 240px;
min-height: 314px;
width: calc(100vw - 2rem);
}
.tag-popover-card {
padding: 0.5rem;
text-align: left;
.card {
background: transparent;
box-shadow: none;
max-width: calc(100vw - 2rem);
padding: 0;
}
}

View file

@ -30,6 +30,7 @@ export interface IUIConfig {
lastNoteSeen?: number; lastNoteSeen?: number;
showChildTagContent?: boolean; showChildTagContent?: boolean;
showChildStudioContent?: boolean; showChildStudioContent?: boolean;
showTagCardOnHover?: boolean;
} }
function recentlyReleased( function recentlyReleased(

View file

@ -5,6 +5,7 @@ After migrating, please run a scan on your entire library to populate missing da
* Import/export schema has changed and is incompatible with the previous version. * Import/export schema has changed and is incompatible with the previous version.
### ✨ New Features ### ✨ New Features
* Added description field to Tags. ([#2708](https://github.com/stashapp/stash/pull/2708))
* Added option to include sub-studio/sub-tag content in Studio/Tag page. ([#2832](https://github.com/stashapp/stash/pull/2832)) * Added option to include sub-studio/sub-tag content in Studio/Tag page. ([#2832](https://github.com/stashapp/stash/pull/2832))
* Added backup location configuration setting. ([#2953](https://github.com/stashapp/stash/pull/2953)) * Added backup location configuration setting. ([#2953](https://github.com/stashapp/stash/pull/2953))
* Allow overriding UI localisation strings. ([#2837](https://github.com/stashapp/stash/pull/2837)) * Allow overriding UI localisation strings. ([#2837](https://github.com/stashapp/stash/pull/2837))
@ -14,6 +15,7 @@ After migrating, please run a scan on your entire library to populate missing da
* Added release notes dialog. ([#2726](https://github.com/stashapp/stash/pull/2726)) * Added release notes dialog. ([#2726](https://github.com/stashapp/stash/pull/2726))
### 🎨 Improvements ### 🎨 Improvements
* Optionally show Tag card when hovering over tag badge. ([#2708](https://github.com/stashapp/stash/pull/2708))
* Show default thumbnails for scenes and images where the actual image is not found. ([#2949](https://github.com/stashapp/stash/pull/2949)) * Show default thumbnails for scenes and images where the actual image is not found. ([#2949](https://github.com/stashapp/stash/pull/2949))
* Added unix timestamp parsing in the `parseDate` scraper post processor. ([#2817](https://github.com/stashapp/stash/pull/2817)) * Added unix timestamp parsing in the `parseDate` scraper post processor. ([#2817](https://github.com/stashapp/stash/pull/2817))
* Improve matching scene order in the tagger to prioritise matching phashes and durations. ([#2840](https://github.com/stashapp/stash/pull/2840)) * Improve matching scene order in the tagger to prioritise matching phashes and durations. ([#2840](https://github.com/stashapp/stash/pull/2840))

View file

@ -500,6 +500,10 @@
"description": "Show or hide different types of content on the navigation bar", "description": "Show or hide different types of content on the navigation bar",
"heading": "Menu Items" "heading": "Menu Items"
}, },
"show_tag_card_on_hover": {
"description": "Show tag card when hovering tag badges",
"heading": "Tag card tooltips"
},
"performers": { "performers": {
"options": { "options": {
"image_location": { "image_location": {
@ -615,6 +619,7 @@
"death_date": "Death Date", "death_date": "Death Date",
"death_year": "Death Year", "death_year": "Death Year",
"descending": "Descending", "descending": "Descending",
"description": "Description",
"detail": "Detail", "detail": "Detail",
"details": "Details", "details": "Details",
"developmentVersion": "Development Version", "developmentVersion": "Development Version",