diff --git a/graphql/documents/data/tag.graphql b/graphql/documents/data/tag.graphql index 336d3a9c8..ac773cf16 100644 --- a/graphql/documents/data/tag.graphql +++ b/graphql/documents/data/tag.graphql @@ -1,6 +1,7 @@ fragment TagData on Tag { id name + description aliases ignore_auto_tag image_path diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 537fab8b9..c6554e3b4 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -1,6 +1,7 @@ type Tag { id: ID! name: String! + description: String aliases: [String!]! ignore_auto_tag: Boolean! created_at: Time! @@ -19,6 +20,7 @@ type Tag { input TagCreateInput { name: String! + description: String aliases: [String!] ignore_auto_tag: Boolean @@ -32,6 +34,7 @@ input TagCreateInput { input TagUpdateInput { id: ID! name: String + description: String aliases: [String!] ignore_auto_tag: Boolean diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index 3592dd959..db6236a0b 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -10,6 +10,13 @@ import ( "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) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.FindByChildTagID(ctx, obj.ID) diff --git a/internal/api/resolver_mutation_tag.go b/internal/api/resolver_mutation_tag.go index f5befeba7..47986c56e 100644 --- a/internal/api/resolver_mutation_tag.go +++ b/internal/api/resolver_mutation_tag.go @@ -2,6 +2,7 @@ package api import ( "context" + "database/sql" "fmt" "strconv" "time" @@ -34,6 +35,10 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput) UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, } + if input.Description != nil { + newTag.Description = sql.NullString{String: *input.Description, Valid: true} + } + if input.IgnoreAutoTag != nil { newTag.IgnoreAutoTag = *input.IgnoreAutoTag } @@ -195,6 +200,8 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) updatedTag.Name = input.Name } + updatedTag.Description = translator.nullString(input.Description, "description") + t, err = qb.Update(ctx, updatedTag) if err != nil { return err diff --git a/pkg/models/jsonschema/tag.go b/pkg/models/jsonschema/tag.go index 5f7e0bfa7..48555098b 100644 --- a/pkg/models/jsonschema/tag.go +++ b/pkg/models/jsonschema/tag.go @@ -11,6 +11,7 @@ import ( type Tag struct { Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` Aliases []string `json:"aliases,omitempty"` Image string `json:"image,omitempty"` Parents []string `json:"parents,omitempty"` diff --git a/pkg/models/model_tag.go b/pkg/models/model_tag.go index b7ec20c3f..043f84bd6 100644 --- a/pkg/models/model_tag.go +++ b/pkg/models/model_tag.go @@ -1,10 +1,14 @@ package models -import "time" +import ( + "database/sql" + "time" +) type Tag struct { ID int `db:"id" json:"id"` 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"` CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` @@ -13,6 +17,7 @@ type Tag struct { type TagPartial struct { ID int `db:"id" json:"id"` 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"` CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 26e4ead8c..0c913737a 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -22,7 +22,7 @@ import ( "github.com/stashapp/stash/pkg/logger" ) -var appSchemaVersion uint = 35 +var appSchemaVersion uint = 36 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/36_tags_description.up.sql b/pkg/sqlite/migrations/36_tags_description.up.sql new file mode 100644 index 000000000..edee18dc6 --- /dev/null +++ b/pkg/sqlite/migrations/36_tags_description.up.sql @@ -0,0 +1 @@ +ALTER TABLE `tags` ADD COLUMN `description` text; diff --git a/pkg/tag/export.go b/pkg/tag/export.go index 20c1b4adc..93f5e97b3 100644 --- a/pkg/tag/export.go +++ b/pkg/tag/export.go @@ -20,6 +20,7 @@ type FinderAliasImageGetter interface { func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) (*jsonschema.Tag, error) { newTagJSON := jsonschema.Tag{ Name: tag.Name, + Description: tag.Description.String, IgnoreAutoTag: tag.IgnoreAutoTag, CreatedAt: json.JSONTime{Time: tag.CreatedAt.Timestamp}, UpdatedAt: json.JSONTime{Time: tag.UpdatedAt.Timestamp}, diff --git a/pkg/tag/export_test.go b/pkg/tag/export_test.go index 255c940dd..e7a68aaf1 100644 --- a/pkg/tag/export_test.go +++ b/pkg/tag/export_test.go @@ -2,6 +2,7 @@ package tag import ( "context" + "database/sql" "errors" "github.com/stashapp/stash/pkg/models" @@ -23,7 +24,10 @@ const ( errParentsID = 6 ) -const tagName = "testTag" +const ( + tagName = "testTag" + description = "description" +) var ( autoTagIgnored = true @@ -33,8 +37,12 @@ var ( func createTag(id int) models.Tag { return models.Tag{ - ID: id, - Name: tagName, + ID: id, + Name: tagName, + Description: sql.NullString{ + String: description, + Valid: true, + }, IgnoreAutoTag: autoTagIgnored, CreatedAt: models.SQLiteTimestamp{ Timestamp: createTime, @@ -48,6 +56,7 @@ func createTag(id int) models.Tag { func createJSONTag(aliases []string, image string, parents []string) *jsonschema.Tag { return &jsonschema.Tag{ Name: tagName, + Description: description, Aliases: aliases, IgnoreAutoTag: autoTagIgnored, CreatedAt: json.JSONTime{ diff --git a/pkg/tag/import.go b/pkg/tag/import.go index 937ea2359..9a802872d 100644 --- a/pkg/tag/import.go +++ b/pkg/tag/import.go @@ -2,6 +2,7 @@ package tag import ( "context" + "database/sql" "fmt" "github.com/stashapp/stash/pkg/models" @@ -42,6 +43,7 @@ type Importer struct { func (i *Importer) PreImport(ctx context.Context) error { i.tag = models.Tag{ Name: i.Input.Name, + Description: sql.NullString{String: i.Input.Description, Valid: true}, IgnoreAutoTag: i.Input.IgnoreAutoTag, CreatedAt: models.SQLiteTimestamp{Timestamp: i.Input.CreatedAt.GetTime()}, UpdatedAt: models.SQLiteTimestamp{Timestamp: i.Input.UpdatedAt.GetTime()}, diff --git a/pkg/tag/import_test.go b/pkg/tag/import_test.go index e4fb3ce8d..991d36cf5 100644 --- a/pkg/tag/import_test.go +++ b/pkg/tag/import_test.go @@ -40,6 +40,7 @@ func TestImporterPreImport(t *testing.T) { i := Importer{ Input: jsonschema.Tag{ Name: tagName, + Description: description, Image: invalidImage, IgnoreAutoTag: autoTagIgnored, }, diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 07e090a2f..498b5b33a 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -247,6 +247,13 @@ export const SettingsInterfacePanel: React.FC = () => { /> + saveUI({ showTagCardOnHover: v })} + /> = ( }; } - return ; + return ( + + + + ); }; const filterOption = (option: Option, rawInput: string): boolean => { diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index 441c0c3ac..dedb8b6e3 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -14,6 +14,7 @@ import TextUtils from "src/utils/text"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import * as GQL from "src/core/generated-graphql"; +import { TagPopover } from "../Tags/TagPopover"; interface IFile { path: string; @@ -37,6 +38,7 @@ interface IProps { } export const TagLink: React.FC = (props: IProps) => { + let id: string = ""; let link: string = "#"; let title: string = ""; if (props.tag) { @@ -55,6 +57,7 @@ export const TagLink: React.FC = (props: IProps) => { link = NavUtils.makeTagImagesUrl(props.tag); break; } + id = props.tag.id || ""; title = props.tag.name || ""; } else if (props.performer) { link = NavUtils.makePerformerScenesUrl(props.performer); @@ -76,7 +79,9 @@ export const TagLink: React.FC = (props: IProps) => { } return ( - {title} + + {title} + ); }; diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index 3bb2418ec..82286d899 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -4,7 +4,7 @@ import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { NavUtils } from "src/utils"; import { FormattedMessage } from "react-intl"; -import { Icon } from "../Shared"; +import { Icon, TruncatedText } from "../Shared"; import { GridCard } from "../Shared/GridCard"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import { faMapMarkerAlt, faUser } from "@fortawesome/free-solid-svg-icons"; @@ -24,6 +24,18 @@ export const TagCard: React.FC = ({ selected, onSelectedChanged, }) => { + function maybeRenderDescription() { + if (tag.description) { + return ( + + ); + } + } + function maybeRenderParents() { if (tag.parents.length === 1) { const parent = tag.parents[0]; @@ -181,6 +193,7 @@ export const TagCard: React.FC = ({ } details={ <> + {maybeRenderDescription()} {maybeRenderParents()} {maybeRenderChildren()} {maybeRenderPopoverButtonGroup()} diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index b5dc4618b..7d8adbffb 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -267,6 +267,7 @@ const TagPage: React.FC = ({ tag }) => { renderImage() )}

{tag.name}

+

{tag.description}

{!isEditing ? ( <> diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 40b406beb..282340130 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -47,6 +47,7 @@ export const TagEditPanel: React.FC = ({ const schema = yup.object({ name: yup.string().required(), + description: yup.string().optional().nullable(), aliases: yup .array(yup.string().required()) .optional() @@ -65,6 +66,7 @@ export const TagEditPanel: React.FC = ({ const initialValues = { name: tag?.name, + description: tag?.description, aliases: tag?.aliases, parent_ids: (tag?.parents ?? []).map((t) => t.id), child_ids: (tag?.children ?? []).map((t) => t.id), @@ -167,6 +169,20 @@ export const TagEditPanel: React.FC = ({ + + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "description" }), + })} + + + + + {FormUtils.renderLabel({ title: intl.formatMessage({ id: "parent_tags" }), diff --git a/ui/v2.5/src/components/Tags/TagPopover.tsx b/ui/v2.5/src/components/Tags/TagPopover.tsx new file mode 100644 index 000000000..8ec2ff36e --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagPopover.tsx @@ -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 = ({ id }) => { + const { data, loading, error } = useFindTag(id ?? ""); + + if (loading) + return ( +
+ +
+ ); + if (error) return ; + if (!data?.findTag) + return ; + + const tag = data.findTag; + + return ( +
+ +
+ ); +}; + +export const TagPopover: React.FC = ({ id, children }) => { + const { configuration: config } = React.useContext(ConfigurationContext); + + const showTagCardOnHover = + (config?.ui as IUIConfig)?.showTagCardOnHover ?? true; + + if (!id || !showTagCardOnHover) { + return <>{children}; + } + + return ( + } + > + {children} + + ); +}; + +interface ITagPopoverCardProps { + id?: string; +} diff --git a/ui/v2.5/src/components/Tags/styles.scss b/ui/v2.5/src/components/Tags/styles.scss index a459874ec..ba325207e 100644 --- a/ui/v2.5/src/components/Tags/styles.scss +++ b/ui/v2.5/src/components/Tags/styles.scss @@ -43,3 +43,28 @@ #tag-merge-menu .dropdown-item { 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; + } +} diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index bdca294e4..fa39470a6 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -30,6 +30,7 @@ export interface IUIConfig { lastNoteSeen?: number; showChildTagContent?: boolean; showChildStudioContent?: boolean; + showTagCardOnHover?: boolean; } function recentlyReleased( diff --git a/ui/v2.5/src/docs/en/Changelog/v0170.md b/ui/v2.5/src/docs/en/Changelog/v0170.md index 8e219eb8e..ba317a15d 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0170.md +++ b/ui/v2.5/src/docs/en/Changelog/v0170.md @@ -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. ### ✨ 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 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)) @@ -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)) ### 🎨 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)) * 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)) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index d0f2b7247..a487867a7 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -500,6 +500,10 @@ "description": "Show or hide different types of content on the navigation bar", "heading": "Menu Items" }, + "show_tag_card_on_hover": { + "description": "Show tag card when hovering tag badges", + "heading": "Tag card tooltips" + }, "performers": { "options": { "image_location": { @@ -615,6 +619,7 @@ "death_date": "Death Date", "death_year": "Death Year", "descending": "Descending", + "description": "Description", "detail": "Detail", "details": "Details", "developmentVersion": "Development Version",