From 29859fa4ad23ebaac3781e68de56955d03866968 Mon Sep 17 00:00:00 2001 From: Dankonite <117317087+Dankonite@users.noreply.github.com> Date: Wed, 8 May 2024 20:04:58 -0600 Subject: [PATCH] Tag Favoriting (#4728) * Add missing key unbind --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/types/filters.graphql | 3 ++ graphql/schema/types/tag.graphql | 6 +-- internal/api/resolver_mutation_tag.go | 2 + pkg/models/jsonschema/tag.go | 1 + pkg/models/model_tag.go | 2 + pkg/models/tag.go | 2 + .../stashbox/graphql/generated_models.go | 1 + pkg/sqlite/database.go | 2 +- pkg/sqlite/migrations/57_tag_favorite.up.sql | 1 + pkg/sqlite/setup_test.go | 5 +- pkg/sqlite/tag.go | 5 ++ pkg/tag/export.go | 1 + pkg/tag/export_test.go | 2 + pkg/tag/import.go | 1 + ui/v2.5/graphql/data/tag.graphql | 2 + .../Studios/StudioDetails/Studio.tsx | 1 + ui/v2.5/src/components/Tags/TagCard.tsx | 39 ++++++++++++-- .../src/components/Tags/TagDetails/Tag.tsx | 30 ++++++++++- ui/v2.5/src/components/Tags/styles.scss | 51 +++++++++++++++++++ .../models/list-filter/criteria/favorite.ts | 11 ++++ ui/v2.5/src/models/list-filter/tags.ts | 2 + ui/v2.5/src/models/list-filter/types.ts | 1 + 22 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 pkg/sqlite/migrations/57_tag_favorite.up.sql diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index ff06980cf..b30f3bc9c 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -419,6 +419,9 @@ input TagFilterType { "Filter by tag aliases" aliases: StringCriterionInput + "Filter by favorite" + favorite: Boolean + "Filter by tag description" description: StringCriterionInput diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index eba9b1996..69b8221c5 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -6,7 +6,7 @@ type Tag { ignore_auto_tag: Boolean! created_at: Time! updated_at: Time! - + favorite: Boolean! image_path: String # Resolver scene_count(depth: Int): Int! # Resolver scene_marker_count(depth: Int): Int! # Resolver @@ -25,7 +25,7 @@ input TagCreateInput { description: String aliases: [String!] ignore_auto_tag: Boolean - + favorite: Boolean "This should be a URL or a base64 encoded data URL" image: String @@ -39,7 +39,7 @@ input TagUpdateInput { description: String aliases: [String!] ignore_auto_tag: Boolean - + favorite: Boolean "This should be a URL or a base64 encoded data URL" image: String diff --git a/internal/api/resolver_mutation_tag.go b/internal/api/resolver_mutation_tag.go index d3a522e55..2c3128c58 100644 --- a/internal/api/resolver_mutation_tag.go +++ b/internal/api/resolver_mutation_tag.go @@ -33,6 +33,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput) newTag := models.NewTag() newTag.Name = input.Name + newTag.Favorite = translator.bool(input.Favorite) newTag.Description = translator.string(input.Description) newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) @@ -136,6 +137,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) // Populate tag from the input updatedTag := models.NewTagPartial() + updatedTag.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") updatedTag.Description = translator.optionalString(input.Description, "description") diff --git a/pkg/models/jsonschema/tag.go b/pkg/models/jsonschema/tag.go index 48555098b..18da3787b 100644 --- a/pkg/models/jsonschema/tag.go +++ b/pkg/models/jsonschema/tag.go @@ -12,6 +12,7 @@ import ( type Tag struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` + Favorite bool `json:"favorite,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 f8c49c532..04f5ac1a2 100644 --- a/pkg/models/model_tag.go +++ b/pkg/models/model_tag.go @@ -7,6 +7,7 @@ import ( type Tag struct { ID int `json:"id"` Name string `json:"name"` + Favorite bool `json:"favorite"` Description string `json:"description"` IgnoreAutoTag bool `json:"ignore_auto_tag"` CreatedAt time.Time `json:"created_at"` @@ -24,6 +25,7 @@ func NewTag() Tag { type TagPartial struct { Name OptionalString Description OptionalString + Favorite OptionalBool IgnoreAutoTag OptionalBool CreatedAt OptionalTime UpdatedAt OptionalTime diff --git a/pkg/models/tag.go b/pkg/models/tag.go index b2cff5a0e..710d1953e 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -8,6 +8,8 @@ type TagFilterType struct { Name *StringCriterionInput `json:"name"` // Filter by tag aliases Aliases *StringCriterionInput `json:"aliases"` + // Filter by tag favorites + Favorite *bool `json:"favorite"` // Filter by tag description Description *StringCriterionInput `json:"description"` // Filter to only include tags missing this property diff --git a/pkg/scraper/stashbox/graphql/generated_models.go b/pkg/scraper/stashbox/graphql/generated_models.go index 780419fad..061053c7d 100644 --- a/pkg/scraper/stashbox/graphql/generated_models.go +++ b/pkg/scraper/stashbox/graphql/generated_models.go @@ -1034,6 +1034,7 @@ type TagQueryInput struct { // Filter to search name - assumes like query unless quoted Name *string `json:"name,omitempty"` // Filter to category ID + IsFavorite *bool `json:"is_favorite,omitempty"` CategoryID *string `json:"category_id,omitempty"` Page int `json:"page"` PerPage int `json:"per_page"` diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 4209c60a8..02616cd07 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 56 +var appSchemaVersion uint = 57 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/57_tag_favorite.up.sql b/pkg/sqlite/migrations/57_tag_favorite.up.sql new file mode 100644 index 000000000..1c7671eb4 --- /dev/null +++ b/pkg/sqlite/migrations/57_tag_favorite.up.sql @@ -0,0 +1 @@ +ALTER TABLE `tags` ADD COLUMN `favorite` boolean not null default '0'; \ No newline at end of file diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index b67bad0ce..6dbcefba2 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1480,7 +1480,10 @@ func createPerformers(ctx context.Context, n int, o int) error { return nil } - +func getTagBoolValue(index int) bool { + index = index % 2 + return index == 1 +} func getTagStringValue(index int, field string) string { return "tag_" + strconv.FormatInt(int64(index), 10) + "_" + field } diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 7eaff3d5c..bf33e04c5 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -29,6 +29,7 @@ const ( type tagRow struct { ID int `db:"id" goqu:"skipinsert"` Name null.String `db:"name"` // TODO: make schema non-nullable + Favorite bool `db:"favorite"` Description zero.String `db:"description"` IgnoreAutoTag bool `db:"ignore_auto_tag"` CreatedAt Timestamp `db:"created_at"` @@ -41,6 +42,7 @@ type tagRow struct { func (r *tagRow) fromTag(o models.Tag) { r.ID = o.ID r.Name = null.StringFrom(o.Name) + r.Favorite = o.Favorite r.Description = zero.StringFrom(o.Description) r.IgnoreAutoTag = o.IgnoreAutoTag r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} @@ -51,6 +53,7 @@ func (r *tagRow) resolve() *models.Tag { ret := &models.Tag{ ID: r.ID, Name: r.Name.String, + Favorite: r.Favorite, Description: r.Description.String, IgnoreAutoTag: r.IgnoreAutoTag, CreatedAt: r.CreatedAt.Timestamp, @@ -81,6 +84,7 @@ type tagRowRecord struct { func (r *tagRowRecord) fromPartial(o models.TagPartial) { r.setString("name", o.Name) r.setNullString("description", o.Description) + r.setBool("favorite", o.Favorite) r.setBool("ignore_auto_tag", o.IgnoreAutoTag) r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) @@ -498,6 +502,7 @@ func (qb *TagStore) makeFilter(ctx context.Context, tagFilter *models.TagFilterT query.handleCriterion(ctx, stringCriterionHandler(tagFilter.Name, tagTable+".name")) query.handleCriterion(ctx, tagAliasCriterionHandler(qb, tagFilter.Aliases)) + query.handleCriterion(ctx, boolCriterionHandler(tagFilter.Favorite, tagTable+".favorite", nil)) query.handleCriterion(ctx, stringCriterionHandler(tagFilter.Description, tagTable+".description")) query.handleCriterion(ctx, boolCriterionHandler(tagFilter.IgnoreAutoTag, tagTable+".ignore_auto_tag", nil)) diff --git a/pkg/tag/export.go b/pkg/tag/export.go index fe2205874..d1d284a5f 100644 --- a/pkg/tag/export.go +++ b/pkg/tag/export.go @@ -22,6 +22,7 @@ func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) newTagJSON := jsonschema.Tag{ Name: tag.Name, Description: tag.Description, + Favorite: tag.Favorite, IgnoreAutoTag: tag.IgnoreAutoTag, CreatedAt: json.JSONTime{Time: tag.CreatedAt}, UpdatedAt: json.JSONTime{Time: tag.UpdatedAt}, diff --git a/pkg/tag/export_test.go b/pkg/tag/export_test.go index 1018f8d2d..afe96996b 100644 --- a/pkg/tag/export_test.go +++ b/pkg/tag/export_test.go @@ -37,6 +37,7 @@ func createTag(id int) models.Tag { return models.Tag{ ID: id, Name: tagName, + Favorite: true, Description: description, IgnoreAutoTag: autoTagIgnored, CreatedAt: createTime, @@ -47,6 +48,7 @@ func createTag(id int) models.Tag { func createJSONTag(aliases []string, image string, parents []string) *jsonschema.Tag { return &jsonschema.Tag{ Name: tagName, + Favorite: true, Description: description, Aliases: aliases, IgnoreAutoTag: autoTagIgnored, diff --git a/pkg/tag/import.go b/pkg/tag/import.go index 6905d15ad..56421fdf6 100644 --- a/pkg/tag/import.go +++ b/pkg/tag/import.go @@ -39,6 +39,7 @@ func (i *Importer) PreImport(ctx context.Context) error { i.tag = models.Tag{ Name: i.Input.Name, Description: i.Input.Description, + Favorite: i.Input.Favorite, IgnoreAutoTag: i.Input.IgnoreAutoTag, CreatedAt: i.Input.CreatedAt.GetTime(), UpdatedAt: i.Input.UpdatedAt.GetTime(), diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index cfaa28137..b71f487ab 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -4,6 +4,7 @@ fragment TagData on Tag { description aliases ignore_auto_tag + favorite image_path scene_count scene_count_all: scene_count(depth: -1) @@ -28,6 +29,7 @@ fragment TagData on Tag { fragment SelectTagData on Tag { id name + favorite description aliases image_path diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 8b4e44d3f..35052b091 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -181,6 +181,7 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); Mousetrap.unbind(","); + Mousetrap.unbind("f"); }; }); diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index dfd994ce8..cff2326b6 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -1,4 +1,4 @@ -import { ButtonGroup } from "react-bootstrap"; +import { Button, ButtonGroup } from "react-bootstrap"; import React, { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; @@ -8,7 +8,10 @@ import { TruncatedText } from "../Shared/TruncatedText"; import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import ScreenUtils from "src/utils/screen"; - +import { Icon } from "../Shared/Icon"; +import { faHeart } from "@fortawesome/free-solid-svg-icons"; +import cx from "classnames"; +import { useTagUpdate } from "src/core/StashService"; interface IProps { tag: GQL.TagDataFragment; containerWidth?: number; @@ -27,7 +30,7 @@ export const TagCard: React.FC = ({ onSelectedChanged, }) => { const [cardWidth, setCardWidth] = useState(); - + const [updateTag] = useTagUpdate(); useEffect(() => { if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile()) return; @@ -65,7 +68,36 @@ export const TagCard: React.FC = ({ ); } } + function renderFavoriteIcon() { + return ( + e.preventDefault()}> + + + ); + } + function onToggleFavorite(v: boolean) { + if (tag.id) { + updateTag({ + variables: { + input: { + id: tag.id, + favorite: v, + }, + }, + }); + } + } function maybeRenderParents() { if (tag.parents.length === 1) { const parent = tag.parents[0]; @@ -230,6 +262,7 @@ export const TagCard: React.FC = ({ {maybeRenderChildren()} } + overlays={<>{renderFavoriteIcon()}} popovers={maybeRenderPopoverButtonGroup()} selected={selected} selecting={selecting} diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index f610879bc..81a60c0f2 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -33,6 +33,7 @@ import { TagMergeModal } from "./TagMergeDialog"; import { faChevronDown, faChevronUp, + faHeart, faSignInAlt, faSignOutAlt, faTrashAlt, @@ -140,6 +141,19 @@ const TagPage: React.FC = ({ tag, tabKey }) => { } }, [setTabKey, populatedDefaultTab, tabKey]); + function setFavorite(v: boolean) { + if (tag.id) { + updateTag({ + variables: { + input: { + id: tag.id, + favorite: v, + }, + }, + }); + } + } + // set up hotkeys useEffect(() => { Mousetrap.bind("e", () => toggleEditing()); @@ -147,6 +161,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { setIsDeleteAlertOpen(true); }); Mousetrap.bind(",", () => setCollapsed(!collapsed)); + Mousetrap.bind("f", () => setFavorite(!tag.favorite)); return () => { if (isEditing) { @@ -156,6 +171,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); Mousetrap.unbind(","); + Mousetrap.unbind("f"); }; }); @@ -298,6 +314,17 @@ const TagPage: React.FC = ({ tag, tabKey }) => { } } + const renderClickableIcons = () => ( + + + + ); + function renderMergeButton() { return ( @@ -528,10 +555,11 @@ const TagPage: React.FC = ({ tag, tabKey }) => { )}
-
+

{tag.name} {maybeRenderShowCollapseButton()} + {renderClickableIcons()}

{maybeRenderAliases()} {maybeRenderDetails()} diff --git a/ui/v2.5/src/components/Tags/styles.scss b/ui/v2.5/src/components/Tags/styles.scss index 8e27309ec..2e6b73d73 100644 --- a/ui/v2.5/src/components/Tags/styles.scss +++ b/ui/v2.5/src/components/Tags/styles.scss @@ -31,6 +31,57 @@ margin: 0 auto; object-fit: contain; } + + button.btn.favorite-button { + opacity: 1; + padding: 0; + position: absolute; + right: 5px; + top: 10px; + transition: opacity 0.5s; + + svg.fa-icon { + margin-left: 0.4rem; + margin-right: 0.4rem; + } + + &.not-favorite { + color: rgba(191, 204, 214, 0.5); + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); + opacity: 0; + } + + &.favorite { + color: #ff7373; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); + } + + &:hover, + &:active, + &:focus, + &:active:focus { + background: none; + box-shadow: none; + } + } + + &:hover button.btn.favorite-button.not-favorite { + opacity: 1; + } +} + +#tag-page { + .tag-head { + .name-icons { + .not-favorite { + color: rgba(191, 204, 214, 0.5); + } + + .favorite { + color: #ff7373; + } + } + } } .tag-details { diff --git a/ui/v2.5/src/models/list-filter/criteria/favorite.ts b/ui/v2.5/src/models/list-filter/criteria/favorite.ts index eec17f2ec..8083c9e12 100644 --- a/ui/v2.5/src/models/list-filter/criteria/favorite.ts +++ b/ui/v2.5/src/models/list-filter/criteria/favorite.ts @@ -11,6 +11,17 @@ export class FavoritePerformerCriterion extends BooleanCriterion { super(FavoritePerformerCriterionOption); } } +export const FavoriteTagCriterionOption = new BooleanCriterionOption( + "favourite", + "favorite", + () => new FavoriteTagCriterion() +); + +export class FavoriteTagCriterion extends BooleanCriterion { + constructor() { + super(FavoriteTagCriterionOption); + } +} export const FavoriteStudioCriterionOption = new BooleanCriterionOption( "favourite", diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index 12daff1c2..fe1a906f0 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -13,6 +13,7 @@ import { ChildTagsCriterionOption, ParentTagsCriterionOption, } from "./criteria/tags"; +import { FavoriteTagCriterionOption } from "./criteria/favorite"; const defaultSortBy = "name"; const sortByOptions = ["name", "random"] @@ -42,6 +43,7 @@ const sortByOptions = ["name", "random"] const displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; const criterionOptions = [ + FavoriteTagCriterionOption, createMandatoryStringCriterionOption("name"), TagIsMissingCriterionOption, createStringCriterionOption("aliases"), diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index b859b16f8..3becd1041 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -191,6 +191,7 @@ export type CriterionType = | "parent_count" | "child_count" | "performer_favorite" + | "favorite" | "performer_age" | "duplicated" | "ignore_auto_tag"