Tag Favoriting (#4728)

* Add missing key unbind
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
Dankonite
2024-05-08 20:04:58 -06:00
committed by GitHub
parent 1cee1ccfe2
commit 29859fa4ad
22 changed files with 162 additions and 9 deletions

View File

@@ -419,6 +419,9 @@ input TagFilterType {
"Filter by tag aliases"
aliases: StringCriterionInput
"Filter by favorite"
favorite: Boolean
"Filter by tag description"
description: StringCriterionInput

View File

@@ -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

View File

@@ -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")

View File

@@ -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"`

View File

@@ -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

View File

@@ -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

View File

@@ -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"`

View File

@@ -30,7 +30,7 @@ const (
dbConnTimeout = 30
)
var appSchemaVersion uint = 56
var appSchemaVersion uint = 57
//go:embed migrations/*.sql
var migrationsBox embed.FS

View File

@@ -0,0 +1 @@
ALTER TABLE `tags` ADD COLUMN `favorite` boolean not null default '0';

View File

@@ -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
}

View File

@@ -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))

View File

@@ -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},

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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

View File

@@ -181,6 +181,7 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
Mousetrap.unbind(",");
Mousetrap.unbind("f");
};
});

View File

@@ -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<IProps> = ({
onSelectedChanged,
}) => {
const [cardWidth, setCardWidth] = useState<number>();
const [updateTag] = useTagUpdate();
useEffect(() => {
if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile())
return;
@@ -65,7 +68,36 @@ export const TagCard: React.FC<IProps> = ({
);
}
}
function renderFavoriteIcon() {
return (
<Link to="" onClick={(e) => e.preventDefault()}>
<Button
className={cx(
"minimal",
"mousetrap",
"favorite-button",
tag.favorite ? "favorite" : "not-favorite"
)}
onClick={() => onToggleFavorite!(!tag.favorite)}
>
<Icon icon={faHeart} size="2x" />
</Button>
</Link>
);
}
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<IProps> = ({
{maybeRenderChildren()}
</>
}
overlays={<>{renderFavoriteIcon()}</>}
popovers={maybeRenderPopoverButtonGroup()}
selected={selected}
selecting={selecting}

View File

@@ -33,6 +33,7 @@ import { TagMergeModal } from "./TagMergeDialog";
import {
faChevronDown,
faChevronUp,
faHeart,
faSignInAlt,
faSignOutAlt,
faTrashAlt,
@@ -140,6 +141,19 @@ const TagPage: React.FC<IProps> = ({ 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<IProps> = ({ 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<IProps> = ({ tag, tabKey }) => {
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
Mousetrap.unbind(",");
Mousetrap.unbind("f");
};
});
@@ -298,6 +314,17 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
}
}
const renderClickableIcons = () => (
<span className="name-icons">
<Button
className={cx("minimal", tag.favorite ? "favorite" : "not-favorite")}
onClick={() => setFavorite(!tag.favorite)}
>
<Icon icon={faHeart} />
</Button>
</span>
);
function renderMergeButton() {
return (
<Dropdown>
@@ -528,10 +555,11 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
)}
</div>
<div className="row">
<div className="studio-head col">
<div className="tag-head col">
<h2>
<span className="tag-name">{tag.name}</span>
{maybeRenderShowCollapseButton()}
{renderClickableIcons()}
</h2>
{maybeRenderAliases()}
{maybeRenderDetails()}

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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"),

View File

@@ -191,6 +191,7 @@ export type CriterionType =
| "parent_count"
| "child_count"
| "performer_favorite"
| "favorite"
| "performer_age"
| "duplicated"
| "ignore_auto_tag"