From 8c454582c71df21dc074afe7ecc356f8eb4b95cc Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 14 Mar 2024 11:17:44 +1100 Subject: [PATCH] Add support for favorite Studios (#4675) * Backend changes * Add favorite icon to studio cards * Add favorite button to studio page * Add studio favorite filtering --- graphql/schema/types/filters.graphql | 2 ++ graphql/schema/types/studio.graphql | 3 ++ internal/api/resolver_mutation_studio.go | 2 ++ pkg/models/jsonschema/studio.go | 1 + pkg/models/model_studio.go | 2 ++ pkg/models/studio.go | 4 +++ pkg/sqlite/database.go | 2 +- .../migrations/56_studio_favorite.up.sql | 1 + pkg/sqlite/setup_test.go | 6 ++++ pkg/sqlite/studio.go | 5 +++ pkg/studio/export.go | 1 + pkg/studio/export_test.go | 8 +++-- pkg/studio/import.go | 1 + ui/v2.5/graphql/data/studio.graphql | 1 + .../components/Performers/PerformerCard.tsx | 27 ++++------------ ui/v2.5/src/components/Performers/styles.scss | 21 ------------ .../src/components/Shared/FavoriteIcon.tsx | 24 ++++++++++++++ ui/v2.5/src/components/Shared/styles.scss | 24 ++++++++++++++ ui/v2.5/src/components/Studios/StudioCard.tsx | 22 +++++++++++++ .../Studios/StudioDetails/Studio.tsx | 21 ++++++++++++ ui/v2.5/src/components/Studios/styles.scss | 32 +++++++++++++++++++ .../models/list-filter/criteria/favorite.ts | 20 +++++++++--- ui/v2.5/src/models/list-filter/performers.ts | 4 +-- ui/v2.5/src/models/list-filter/studios.ts | 2 ++ ui/v2.5/src/models/list-filter/types.ts | 1 + 25 files changed, 185 insertions(+), 52 deletions(-) create mode 100644 pkg/sqlite/migrations/56_studio_favorite.up.sql create mode 100644 ui/v2.5/src/components/Shared/FavoriteIcon.tsx diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 3164a010b..9b23fec7a 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -325,6 +325,8 @@ input StudioFilterType { is_missing: String # rating expressed as 1-100 rating100: IntCriterionInput + "Filter by favorite" + favorite: Boolean "Filter by scene count" scene_count: IntCriterionInput "Filter by image count" diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index fbc4241a2..ff4eb5011 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -16,6 +16,7 @@ type Studio { stash_ids: [StashID!]! # rating expressed as 1-100 rating100: Int + favorite: Boolean! details: String created_at: Time! updated_at: Time! @@ -31,6 +32,7 @@ input StudioCreateInput { stash_ids: [StashIDInput!] # rating expressed as 1-100 rating100: Int + favorite: Boolean details: String aliases: [String!] ignore_auto_tag: Boolean @@ -46,6 +48,7 @@ input StudioUpdateInput { stash_ids: [StashIDInput!] # rating expressed as 1-100 rating100: Int + favorite: Boolean details: String aliases: [String!] ignore_auto_tag: Boolean diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index 74b6e6c20..05d84a979 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -35,6 +35,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio newStudio.Name = input.Name newStudio.URL = translator.string(input.URL) newStudio.Rating = input.Rating100 + newStudio.Favorite = translator.bool(input.Favorite) newStudio.Details = translator.string(input.Details) newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) newStudio.Aliases = models.NewRelatedStrings(input.Aliases) @@ -103,6 +104,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio updatedStudio.URL = translator.optionalString(input.URL, "url") updatedStudio.Details = translator.optionalString(input.Details, "details") updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100") + updatedStudio.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedStudio.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") updatedStudio.Aliases = translator.updateStrings(input.Aliases, "aliases") updatedStudio.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids") diff --git a/pkg/models/jsonschema/studio.go b/pkg/models/jsonschema/studio.go index d6932a28c..84842fa14 100644 --- a/pkg/models/jsonschema/studio.go +++ b/pkg/models/jsonschema/studio.go @@ -18,6 +18,7 @@ type Studio struct { CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` Rating int `json:"rating,omitempty"` + Favorite bool `json:"favorite,omitempty"` Details string `json:"details,omitempty"` Aliases []string `json:"aliases,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"` diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index 109535be1..e6e8b7b20 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -14,6 +14,7 @@ type Studio struct { UpdatedAt time.Time `json:"updated_at"` // Rating expressed in 1-100 scale Rating *int `json:"rating"` + Favorite bool `json:"favorite"` Details string `json:"details"` IgnoreAutoTag bool `json:"ignore_auto_tag"` @@ -37,6 +38,7 @@ type StudioPartial struct { ParentID OptionalInt // Rating expressed in 1-100 scale Rating OptionalInt + Favorite OptionalBool Details OptionalString CreatedAt OptionalTime UpdatedAt OptionalTime diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 2a54077ee..9cc6b907e 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -16,6 +16,8 @@ type StudioFilterType struct { IsMissing *string `json:"is_missing"` // Filter by rating expressed as 1-100 Rating100 *IntCriterionInput `json:"rating100"` + // Filter by favorite + Favorite *bool `json:"favorite"` // Filter by scene count SceneCount *IntCriterionInput `json:"scene_count"` // Filter by image count @@ -44,6 +46,7 @@ type StudioCreateInput struct { Image *string `json:"image"` StashIds []StashID `json:"stash_ids"` Rating100 *int `json:"rating100"` + Favorite *bool `json:"favorite"` Details *string `json:"details"` Aliases []string `json:"aliases"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` @@ -58,6 +61,7 @@ type StudioUpdateInput struct { Image *string `json:"image"` StashIds []StashID `json:"stash_ids"` Rating100 *int `json:"rating100"` + Favorite *bool `json:"favorite"` Details *string `json:"details"` Aliases []string `json:"aliases"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index e0d6678f6..4209c60a8 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 55 +var appSchemaVersion uint = 56 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/56_studio_favorite.up.sql b/pkg/sqlite/migrations/56_studio_favorite.up.sql new file mode 100644 index 000000000..f1741455e --- /dev/null +++ b/pkg/sqlite/migrations/56_studio_favorite.up.sql @@ -0,0 +1 @@ +ALTER TABLE `studios` ADD COLUMN `favorite` boolean not null default '0'; diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index c812d36f9..b67bad0ce 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1610,6 +1610,11 @@ func createStudioFromModel(ctx context.Context, sqb *sqlite.StudioStore, studio return nil } +func getStudioBoolValue(index int) bool { + index = index % 2 + return index == 1 +} + // createStudios creates n studios with plain Name and o studios with camel cased NaMe included func createStudios(ctx context.Context, n int, o int) error { sqb := db.Studio @@ -1630,6 +1635,7 @@ func createStudios(ctx context.Context, n int, o int) error { studio := models.Studio{ Name: name, URL: getStudioStringValue(index, urlField), + Favorite: getStudioBoolValue(index), IgnoreAutoTag: getIgnoreAutoTag(i), } // only add aliases for some scenes diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 6df618ca1..83425475b 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -36,6 +36,7 @@ type studioRow struct { UpdatedAt Timestamp `db:"updated_at"` // expressed as 1-100 Rating null.Int `db:"rating"` + Favorite bool `db:"favorite"` Details zero.String `db:"details"` IgnoreAutoTag bool `db:"ignore_auto_tag"` @@ -51,6 +52,7 @@ func (r *studioRow) fromStudio(o models.Studio) { r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} r.Rating = intFromPtr(o.Rating) + r.Favorite = o.Favorite r.Details = zero.StringFrom(o.Details) r.IgnoreAutoTag = o.IgnoreAutoTag } @@ -64,6 +66,7 @@ func (r *studioRow) resolve() *models.Studio { CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, Rating: nullIntPtr(r.Rating), + Favorite: r.Favorite, Details: r.Details.String, IgnoreAutoTag: r.IgnoreAutoTag, } @@ -82,6 +85,7 @@ func (r *studioRowRecord) fromPartial(o models.StudioPartial) { r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) r.setNullInt("rating", o.Rating) + r.setBool("favorite", o.Favorite) r.setNullString("details", o.Details) r.setBool("ignore_auto_tag", o.IgnoreAutoTag) } @@ -496,6 +500,7 @@ func (qb *StudioStore) makeFilter(ctx context.Context, studioFilter *models.Stud query.handleCriterion(ctx, stringCriterionHandler(studioFilter.Details, studioTable+".details")) query.handleCriterion(ctx, stringCriterionHandler(studioFilter.URL, studioTable+".url")) query.handleCriterion(ctx, intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil)) + query.handleCriterion(ctx, boolCriterionHandler(studioFilter.Favorite, studioTable+".favorite", nil)) query.handleCriterion(ctx, boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil)) query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { diff --git a/pkg/studio/export.go b/pkg/studio/export.go index 9d6d79299..483058c10 100644 --- a/pkg/studio/export.go +++ b/pkg/studio/export.go @@ -24,6 +24,7 @@ func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models Name: studio.Name, URL: studio.URL, Details: studio.Details, + Favorite: studio.Favorite, IgnoreAutoTag: studio.IgnoreAutoTag, CreatedAt: json.JSONTime{Time: studio.CreatedAt}, UpdatedAt: json.JSONTime{Time: studio.UpdatedAt}, diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index eb489f8a9..da6da8ad4 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -62,6 +62,7 @@ func createFullStudio(id int, parentID int) models.Studio { Name: studioName, URL: url, Details: details, + Favorite: true, CreatedAt: createTime, UpdatedAt: updateTime, Rating: &rating, @@ -89,9 +90,10 @@ func createEmptyStudio(id int) models.Studio { func createFullJSONStudio(parentStudio, image string, aliases []string) *jsonschema.Studio { return &jsonschema.Studio{ - Name: studioName, - URL: url, - Details: details, + Name: studioName, + URL: url, + Details: details, + Favorite: true, CreatedAt: json.JSONTime{ Time: createTime, }, diff --git a/pkg/studio/import.go b/pkg/studio/import.go index 1af5ec5c3..bfee4133f 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -144,6 +144,7 @@ func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio { URL: studioJSON.URL, Aliases: models.NewRelatedStrings(studioJSON.Aliases), Details: studioJSON.Details, + Favorite: studioJSON.Favorite, IgnoreAutoTag: studioJSON.IgnoreAutoTag, CreatedAt: studioJSON.CreatedAt.GetTime(), UpdatedAt: studioJSON.UpdatedAt.GetTime(), diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index f40b4a620..576faea23 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -31,6 +31,7 @@ fragment StudioData on Studio { } details rating100 + favorite aliases } diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 50da0bcb5..4792e452c 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -17,12 +17,12 @@ import { } from "src/models/list-filter/criteria/criterion"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import GenderIcon from "./GenderIcon"; -import { faHeart, faTag } from "@fortawesome/free-solid-svg-icons"; +import { faTag } from "@fortawesome/free-solid-svg-icons"; import { RatingBanner } from "../Shared/RatingBanner"; -import cx from "classnames"; import { usePerformerUpdate } from "src/core/StashService"; import { ILabeledId } from "src/models/list-filter/types"; import ScreenUtils from "src/utils/screen"; +import { FavoriteIcon } from "../Shared/FavoriteIcon"; export interface IPerformerCardExtraCriteria { scenes?: Criterion[]; @@ -82,24 +82,6 @@ export const PerformerCard: React.FC = ({ setCardWidth(fittedCardWidth); }, [containerWidth]); - function renderFavoriteIcon() { - return ( - e.preventDefault()}> - - - ); - } - function onToggleFavorite(v: boolean) { if (performer.id) { updatePerformer({ @@ -292,7 +274,10 @@ export const PerformerCard: React.FC = ({ } overlays={ <> - {renderFavoriteIcon()} + {maybeRenderRatingBanner()} {maybeRenderFlag()} diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 359813e02..3dbee55c9 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -80,36 +80,15 @@ } 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 { diff --git a/ui/v2.5/src/components/Shared/FavoriteIcon.tsx b/ui/v2.5/src/components/Shared/FavoriteIcon.tsx new file mode 100644 index 000000000..874209522 --- /dev/null +++ b/ui/v2.5/src/components/Shared/FavoriteIcon.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { Icon } from "../Shared/Icon"; +import { Button } from "react-bootstrap"; +import { faHeart } from "@fortawesome/free-solid-svg-icons"; +import cx from "classnames"; + +export const FavoriteIcon: React.FC<{ + favorite: boolean; + onToggleFavorite: (v: boolean) => void; +}> = ({ favorite, onToggleFavorite }) => { + return ( + + ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 62c04bb9e..2ac707f06 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -528,3 +528,27 @@ div.react-datepicker { align-items: baseline; display: flex; } + +button.btn.favorite-button { + opacity: 1; + transition: opacity 0.5s; + + &.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; + } +} diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index e0915597b..007635cce 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -11,6 +11,8 @@ import { FormattedMessage } from "react-intl"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import { RatingBanner } from "../Shared/RatingBanner"; import ScreenUtils from "src/utils/screen"; +import { FavoriteIcon } from "../Shared/FavoriteIcon"; +import { useStudioUpdate } from "src/core/StashService"; interface IProps { studio: GQL.StudioDataFragment; @@ -70,6 +72,7 @@ export const StudioCard: React.FC = ({ selected, onSelectedChanged, }) => { + const [updateStudio] = useStudioUpdate(); const [cardWidth, setCardWidth] = useState(); useEffect(() => { @@ -83,6 +86,19 @@ export const StudioCard: React.FC = ({ setCardWidth(fittedCardWidth); }, [containerWidth]); + function onToggleFavorite(v: boolean) { + if (studio.id) { + updateStudio({ + variables: { + input: { + id: studio.id, + favorite: v, + }, + }, + }); + } + } + function maybeRenderScenesPopoverButton() { if (!studio.scene_count) return; @@ -193,6 +209,12 @@ export const StudioCard: React.FC = ({ } + overlays={ + onToggleFavorite(v)} + /> + } popovers={maybeRenderPopoverButtonGroup()} selected={selected} selecting={selecting} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 244532b0c..d72aa89e3 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -37,6 +37,7 @@ import { faLink, faChevronDown, faChevronUp, + faHeart, } from "@fortawesome/free-solid-svg-icons"; import TextUtils from "src/utils/text"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; @@ -154,6 +155,19 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { } }, [setTabKey, populatedDefaultTab, tabKey]); + function setFavorite(v: boolean) { + if (studio.id) { + updateStudio({ + variables: { + input: { + id: studio.id, + favorite: v, + }, + }, + }); + } + } + // set up hotkeys useEffect(() => { Mousetrap.bind("e", () => toggleEditing()); @@ -161,6 +175,7 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { setIsDeleteAlertOpen(true); }); Mousetrap.bind(",", () => setCollapsed(!collapsed)); + Mousetrap.bind("f", () => setFavorite(!studio.favorite)); return () => { Mousetrap.unbind("e"); @@ -284,6 +299,12 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { const renderClickableIcons = () => ( + {studio.url && (