mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Add support for favorite Studios (#4675)
* Backend changes * Add favorite icon to studio cards * Add favorite button to studio page * Add studio favorite filtering
This commit is contained in:
parent
e5929389b4
commit
8c454582c7
25 changed files with 185 additions and 52 deletions
|
|
@ -325,6 +325,8 @@ input StudioFilterType {
|
||||||
is_missing: String
|
is_missing: String
|
||||||
# rating expressed as 1-100
|
# rating expressed as 1-100
|
||||||
rating100: IntCriterionInput
|
rating100: IntCriterionInput
|
||||||
|
"Filter by favorite"
|
||||||
|
favorite: Boolean
|
||||||
"Filter by scene count"
|
"Filter by scene count"
|
||||||
scene_count: IntCriterionInput
|
scene_count: IntCriterionInput
|
||||||
"Filter by image count"
|
"Filter by image count"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ type Studio {
|
||||||
stash_ids: [StashID!]!
|
stash_ids: [StashID!]!
|
||||||
# rating expressed as 1-100
|
# rating expressed as 1-100
|
||||||
rating100: Int
|
rating100: Int
|
||||||
|
favorite: Boolean!
|
||||||
details: String
|
details: String
|
||||||
created_at: Time!
|
created_at: Time!
|
||||||
updated_at: Time!
|
updated_at: Time!
|
||||||
|
|
@ -31,6 +32,7 @@ input StudioCreateInput {
|
||||||
stash_ids: [StashIDInput!]
|
stash_ids: [StashIDInput!]
|
||||||
# rating expressed as 1-100
|
# rating expressed as 1-100
|
||||||
rating100: Int
|
rating100: Int
|
||||||
|
favorite: Boolean
|
||||||
details: String
|
details: String
|
||||||
aliases: [String!]
|
aliases: [String!]
|
||||||
ignore_auto_tag: Boolean
|
ignore_auto_tag: Boolean
|
||||||
|
|
@ -46,6 +48,7 @@ input StudioUpdateInput {
|
||||||
stash_ids: [StashIDInput!]
|
stash_ids: [StashIDInput!]
|
||||||
# rating expressed as 1-100
|
# rating expressed as 1-100
|
||||||
rating100: Int
|
rating100: Int
|
||||||
|
favorite: Boolean
|
||||||
details: String
|
details: String
|
||||||
aliases: [String!]
|
aliases: [String!]
|
||||||
ignore_auto_tag: Boolean
|
ignore_auto_tag: Boolean
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
||||||
newStudio.Name = input.Name
|
newStudio.Name = input.Name
|
||||||
newStudio.URL = translator.string(input.URL)
|
newStudio.URL = translator.string(input.URL)
|
||||||
newStudio.Rating = input.Rating100
|
newStudio.Rating = input.Rating100
|
||||||
|
newStudio.Favorite = translator.bool(input.Favorite)
|
||||||
newStudio.Details = translator.string(input.Details)
|
newStudio.Details = translator.string(input.Details)
|
||||||
newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
|
newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
|
||||||
newStudio.Aliases = models.NewRelatedStrings(input.Aliases)
|
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.URL = translator.optionalString(input.URL, "url")
|
||||||
updatedStudio.Details = translator.optionalString(input.Details, "details")
|
updatedStudio.Details = translator.optionalString(input.Details, "details")
|
||||||
updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100")
|
updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100")
|
||||||
|
updatedStudio.Favorite = translator.optionalBool(input.Favorite, "favorite")
|
||||||
updatedStudio.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
|
updatedStudio.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
|
||||||
updatedStudio.Aliases = translator.updateStrings(input.Aliases, "aliases")
|
updatedStudio.Aliases = translator.updateStrings(input.Aliases, "aliases")
|
||||||
updatedStudio.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
|
updatedStudio.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ type Studio struct {
|
||||||
CreatedAt json.JSONTime `json:"created_at,omitempty"`
|
CreatedAt json.JSONTime `json:"created_at,omitempty"`
|
||||||
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
|
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
|
||||||
Rating int `json:"rating,omitempty"`
|
Rating int `json:"rating,omitempty"`
|
||||||
|
Favorite bool `json:"favorite,omitempty"`
|
||||||
Details string `json:"details,omitempty"`
|
Details string `json:"details,omitempty"`
|
||||||
Aliases []string `json:"aliases,omitempty"`
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
StashIDs []models.StashID `json:"stash_ids,omitempty"`
|
StashIDs []models.StashID `json:"stash_ids,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ type Studio struct {
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
// Rating expressed in 1-100 scale
|
// Rating expressed in 1-100 scale
|
||||||
Rating *int `json:"rating"`
|
Rating *int `json:"rating"`
|
||||||
|
Favorite bool `json:"favorite"`
|
||||||
Details string `json:"details"`
|
Details string `json:"details"`
|
||||||
IgnoreAutoTag bool `json:"ignore_auto_tag"`
|
IgnoreAutoTag bool `json:"ignore_auto_tag"`
|
||||||
|
|
||||||
|
|
@ -37,6 +38,7 @@ type StudioPartial struct {
|
||||||
ParentID OptionalInt
|
ParentID OptionalInt
|
||||||
// Rating expressed in 1-100 scale
|
// Rating expressed in 1-100 scale
|
||||||
Rating OptionalInt
|
Rating OptionalInt
|
||||||
|
Favorite OptionalBool
|
||||||
Details OptionalString
|
Details OptionalString
|
||||||
CreatedAt OptionalTime
|
CreatedAt OptionalTime
|
||||||
UpdatedAt OptionalTime
|
UpdatedAt OptionalTime
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ type StudioFilterType struct {
|
||||||
IsMissing *string `json:"is_missing"`
|
IsMissing *string `json:"is_missing"`
|
||||||
// Filter by rating expressed as 1-100
|
// Filter by rating expressed as 1-100
|
||||||
Rating100 *IntCriterionInput `json:"rating100"`
|
Rating100 *IntCriterionInput `json:"rating100"`
|
||||||
|
// Filter by favorite
|
||||||
|
Favorite *bool `json:"favorite"`
|
||||||
// Filter by scene count
|
// Filter by scene count
|
||||||
SceneCount *IntCriterionInput `json:"scene_count"`
|
SceneCount *IntCriterionInput `json:"scene_count"`
|
||||||
// Filter by image count
|
// Filter by image count
|
||||||
|
|
@ -44,6 +46,7 @@ type StudioCreateInput struct {
|
||||||
Image *string `json:"image"`
|
Image *string `json:"image"`
|
||||||
StashIds []StashID `json:"stash_ids"`
|
StashIds []StashID `json:"stash_ids"`
|
||||||
Rating100 *int `json:"rating100"`
|
Rating100 *int `json:"rating100"`
|
||||||
|
Favorite *bool `json:"favorite"`
|
||||||
Details *string `json:"details"`
|
Details *string `json:"details"`
|
||||||
Aliases []string `json:"aliases"`
|
Aliases []string `json:"aliases"`
|
||||||
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
|
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
|
||||||
|
|
@ -58,6 +61,7 @@ type StudioUpdateInput struct {
|
||||||
Image *string `json:"image"`
|
Image *string `json:"image"`
|
||||||
StashIds []StashID `json:"stash_ids"`
|
StashIds []StashID `json:"stash_ids"`
|
||||||
Rating100 *int `json:"rating100"`
|
Rating100 *int `json:"rating100"`
|
||||||
|
Favorite *bool `json:"favorite"`
|
||||||
Details *string `json:"details"`
|
Details *string `json:"details"`
|
||||||
Aliases []string `json:"aliases"`
|
Aliases []string `json:"aliases"`
|
||||||
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
|
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ const (
|
||||||
dbConnTimeout = 30
|
dbConnTimeout = 30
|
||||||
)
|
)
|
||||||
|
|
||||||
var appSchemaVersion uint = 55
|
var appSchemaVersion uint = 56
|
||||||
|
|
||||||
//go:embed migrations/*.sql
|
//go:embed migrations/*.sql
|
||||||
var migrationsBox embed.FS
|
var migrationsBox embed.FS
|
||||||
|
|
|
||||||
1
pkg/sqlite/migrations/56_studio_favorite.up.sql
Normal file
1
pkg/sqlite/migrations/56_studio_favorite.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `studios` ADD COLUMN `favorite` boolean not null default '0';
|
||||||
|
|
@ -1610,6 +1610,11 @@ func createStudioFromModel(ctx context.Context, sqb *sqlite.StudioStore, studio
|
||||||
return nil
|
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
|
// 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 {
|
func createStudios(ctx context.Context, n int, o int) error {
|
||||||
sqb := db.Studio
|
sqb := db.Studio
|
||||||
|
|
@ -1630,6 +1635,7 @@ func createStudios(ctx context.Context, n int, o int) error {
|
||||||
studio := models.Studio{
|
studio := models.Studio{
|
||||||
Name: name,
|
Name: name,
|
||||||
URL: getStudioStringValue(index, urlField),
|
URL: getStudioStringValue(index, urlField),
|
||||||
|
Favorite: getStudioBoolValue(index),
|
||||||
IgnoreAutoTag: getIgnoreAutoTag(i),
|
IgnoreAutoTag: getIgnoreAutoTag(i),
|
||||||
}
|
}
|
||||||
// only add aliases for some scenes
|
// only add aliases for some scenes
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type studioRow struct {
|
||||||
UpdatedAt Timestamp `db:"updated_at"`
|
UpdatedAt Timestamp `db:"updated_at"`
|
||||||
// expressed as 1-100
|
// expressed as 1-100
|
||||||
Rating null.Int `db:"rating"`
|
Rating null.Int `db:"rating"`
|
||||||
|
Favorite bool `db:"favorite"`
|
||||||
Details zero.String `db:"details"`
|
Details zero.String `db:"details"`
|
||||||
IgnoreAutoTag bool `db:"ignore_auto_tag"`
|
IgnoreAutoTag bool `db:"ignore_auto_tag"`
|
||||||
|
|
||||||
|
|
@ -51,6 +52,7 @@ func (r *studioRow) fromStudio(o models.Studio) {
|
||||||
r.CreatedAt = Timestamp{Timestamp: o.CreatedAt}
|
r.CreatedAt = Timestamp{Timestamp: o.CreatedAt}
|
||||||
r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}
|
r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}
|
||||||
r.Rating = intFromPtr(o.Rating)
|
r.Rating = intFromPtr(o.Rating)
|
||||||
|
r.Favorite = o.Favorite
|
||||||
r.Details = zero.StringFrom(o.Details)
|
r.Details = zero.StringFrom(o.Details)
|
||||||
r.IgnoreAutoTag = o.IgnoreAutoTag
|
r.IgnoreAutoTag = o.IgnoreAutoTag
|
||||||
}
|
}
|
||||||
|
|
@ -64,6 +66,7 @@ func (r *studioRow) resolve() *models.Studio {
|
||||||
CreatedAt: r.CreatedAt.Timestamp,
|
CreatedAt: r.CreatedAt.Timestamp,
|
||||||
UpdatedAt: r.UpdatedAt.Timestamp,
|
UpdatedAt: r.UpdatedAt.Timestamp,
|
||||||
Rating: nullIntPtr(r.Rating),
|
Rating: nullIntPtr(r.Rating),
|
||||||
|
Favorite: r.Favorite,
|
||||||
Details: r.Details.String,
|
Details: r.Details.String,
|
||||||
IgnoreAutoTag: r.IgnoreAutoTag,
|
IgnoreAutoTag: r.IgnoreAutoTag,
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +85,7 @@ func (r *studioRowRecord) fromPartial(o models.StudioPartial) {
|
||||||
r.setTimestamp("created_at", o.CreatedAt)
|
r.setTimestamp("created_at", o.CreatedAt)
|
||||||
r.setTimestamp("updated_at", o.UpdatedAt)
|
r.setTimestamp("updated_at", o.UpdatedAt)
|
||||||
r.setNullInt("rating", o.Rating)
|
r.setNullInt("rating", o.Rating)
|
||||||
|
r.setBool("favorite", o.Favorite)
|
||||||
r.setNullString("details", o.Details)
|
r.setNullString("details", o.Details)
|
||||||
r.setBool("ignore_auto_tag", o.IgnoreAutoTag)
|
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.Details, studioTable+".details"))
|
||||||
query.handleCriterion(ctx, stringCriterionHandler(studioFilter.URL, studioTable+".url"))
|
query.handleCriterion(ctx, stringCriterionHandler(studioFilter.URL, studioTable+".url"))
|
||||||
query.handleCriterion(ctx, intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil))
|
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, boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil))
|
||||||
|
|
||||||
query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
|
query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models
|
||||||
Name: studio.Name,
|
Name: studio.Name,
|
||||||
URL: studio.URL,
|
URL: studio.URL,
|
||||||
Details: studio.Details,
|
Details: studio.Details,
|
||||||
|
Favorite: studio.Favorite,
|
||||||
IgnoreAutoTag: studio.IgnoreAutoTag,
|
IgnoreAutoTag: studio.IgnoreAutoTag,
|
||||||
CreatedAt: json.JSONTime{Time: studio.CreatedAt},
|
CreatedAt: json.JSONTime{Time: studio.CreatedAt},
|
||||||
UpdatedAt: json.JSONTime{Time: studio.UpdatedAt},
|
UpdatedAt: json.JSONTime{Time: studio.UpdatedAt},
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ func createFullStudio(id int, parentID int) models.Studio {
|
||||||
Name: studioName,
|
Name: studioName,
|
||||||
URL: url,
|
URL: url,
|
||||||
Details: details,
|
Details: details,
|
||||||
|
Favorite: true,
|
||||||
CreatedAt: createTime,
|
CreatedAt: createTime,
|
||||||
UpdatedAt: updateTime,
|
UpdatedAt: updateTime,
|
||||||
Rating: &rating,
|
Rating: &rating,
|
||||||
|
|
@ -89,9 +90,10 @@ func createEmptyStudio(id int) models.Studio {
|
||||||
|
|
||||||
func createFullJSONStudio(parentStudio, image string, aliases []string) *jsonschema.Studio {
|
func createFullJSONStudio(parentStudio, image string, aliases []string) *jsonschema.Studio {
|
||||||
return &jsonschema.Studio{
|
return &jsonschema.Studio{
|
||||||
Name: studioName,
|
Name: studioName,
|
||||||
URL: url,
|
URL: url,
|
||||||
Details: details,
|
Details: details,
|
||||||
|
Favorite: true,
|
||||||
CreatedAt: json.JSONTime{
|
CreatedAt: json.JSONTime{
|
||||||
Time: createTime,
|
Time: createTime,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio {
|
||||||
URL: studioJSON.URL,
|
URL: studioJSON.URL,
|
||||||
Aliases: models.NewRelatedStrings(studioJSON.Aliases),
|
Aliases: models.NewRelatedStrings(studioJSON.Aliases),
|
||||||
Details: studioJSON.Details,
|
Details: studioJSON.Details,
|
||||||
|
Favorite: studioJSON.Favorite,
|
||||||
IgnoreAutoTag: studioJSON.IgnoreAutoTag,
|
IgnoreAutoTag: studioJSON.IgnoreAutoTag,
|
||||||
CreatedAt: studioJSON.CreatedAt.GetTime(),
|
CreatedAt: studioJSON.CreatedAt.GetTime(),
|
||||||
UpdatedAt: studioJSON.UpdatedAt.GetTime(),
|
UpdatedAt: studioJSON.UpdatedAt.GetTime(),
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ fragment StudioData on Studio {
|
||||||
}
|
}
|
||||||
details
|
details
|
||||||
rating100
|
rating100
|
||||||
|
favorite
|
||||||
aliases
|
aliases
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,12 @@ import {
|
||||||
} from "src/models/list-filter/criteria/criterion";
|
} from "src/models/list-filter/criteria/criterion";
|
||||||
import { PopoverCountButton } from "../Shared/PopoverCountButton";
|
import { PopoverCountButton } from "../Shared/PopoverCountButton";
|
||||||
import GenderIcon from "./GenderIcon";
|
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 { RatingBanner } from "../Shared/RatingBanner";
|
||||||
import cx from "classnames";
|
|
||||||
import { usePerformerUpdate } from "src/core/StashService";
|
import { usePerformerUpdate } from "src/core/StashService";
|
||||||
import { ILabeledId } from "src/models/list-filter/types";
|
import { ILabeledId } from "src/models/list-filter/types";
|
||||||
import ScreenUtils from "src/utils/screen";
|
import ScreenUtils from "src/utils/screen";
|
||||||
|
import { FavoriteIcon } from "../Shared/FavoriteIcon";
|
||||||
|
|
||||||
export interface IPerformerCardExtraCriteria {
|
export interface IPerformerCardExtraCriteria {
|
||||||
scenes?: Criterion<CriterionValue>[];
|
scenes?: Criterion<CriterionValue>[];
|
||||||
|
|
@ -82,24 +82,6 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
|
||||||
setCardWidth(fittedCardWidth);
|
setCardWidth(fittedCardWidth);
|
||||||
}, [containerWidth]);
|
}, [containerWidth]);
|
||||||
|
|
||||||
function renderFavoriteIcon() {
|
|
||||||
return (
|
|
||||||
<Link to="" onClick={(e) => e.preventDefault()}>
|
|
||||||
<Button
|
|
||||||
className={cx(
|
|
||||||
"minimal",
|
|
||||||
"mousetrap",
|
|
||||||
"favorite-button",
|
|
||||||
performer.favorite ? "favorite" : "not-favorite"
|
|
||||||
)}
|
|
||||||
onClick={() => onToggleFavorite!(!performer.favorite)}
|
|
||||||
>
|
|
||||||
<Icon icon={faHeart} size="2x" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onToggleFavorite(v: boolean) {
|
function onToggleFavorite(v: boolean) {
|
||||||
if (performer.id) {
|
if (performer.id) {
|
||||||
updatePerformer({
|
updatePerformer({
|
||||||
|
|
@ -292,7 +274,10 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
|
||||||
}
|
}
|
||||||
overlays={
|
overlays={
|
||||||
<>
|
<>
|
||||||
{renderFavoriteIcon()}
|
<FavoriteIcon
|
||||||
|
favorite={performer.favorite}
|
||||||
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
/>
|
||||||
{maybeRenderRatingBanner()}
|
{maybeRenderRatingBanner()}
|
||||||
{maybeRenderFlag()}
|
{maybeRenderFlag()}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -80,36 +80,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
button.btn.favorite-button {
|
button.btn.favorite-button {
|
||||||
opacity: 1;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 5px;
|
right: 5px;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
transition: opacity 0.5s;
|
|
||||||
|
|
||||||
svg.fa-icon {
|
svg.fa-icon {
|
||||||
margin-left: 0.4rem;
|
margin-left: 0.4rem;
|
||||||
margin-right: 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 {
|
&:hover button.btn.favorite-button.not-favorite {
|
||||||
|
|
|
||||||
24
ui/v2.5/src/components/Shared/FavoriteIcon.tsx
Normal file
24
ui/v2.5/src/components/Shared/FavoriteIcon.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Button
|
||||||
|
className={cx(
|
||||||
|
"minimal",
|
||||||
|
"mousetrap",
|
||||||
|
"favorite-button",
|
||||||
|
favorite ? "favorite" : "not-favorite"
|
||||||
|
)}
|
||||||
|
onClick={() => onToggleFavorite!(!favorite)}
|
||||||
|
>
|
||||||
|
<Icon icon={faHeart} size="2x" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -528,3 +528,27 @@ div.react-datepicker {
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
display: flex;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import { FormattedMessage } from "react-intl";
|
||||||
import { PopoverCountButton } from "../Shared/PopoverCountButton";
|
import { PopoverCountButton } from "../Shared/PopoverCountButton";
|
||||||
import { RatingBanner } from "../Shared/RatingBanner";
|
import { RatingBanner } from "../Shared/RatingBanner";
|
||||||
import ScreenUtils from "src/utils/screen";
|
import ScreenUtils from "src/utils/screen";
|
||||||
|
import { FavoriteIcon } from "../Shared/FavoriteIcon";
|
||||||
|
import { useStudioUpdate } from "src/core/StashService";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
studio: GQL.StudioDataFragment;
|
studio: GQL.StudioDataFragment;
|
||||||
|
|
@ -70,6 +72,7 @@ export const StudioCard: React.FC<IProps> = ({
|
||||||
selected,
|
selected,
|
||||||
onSelectedChanged,
|
onSelectedChanged,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [updateStudio] = useStudioUpdate();
|
||||||
const [cardWidth, setCardWidth] = useState<number>();
|
const [cardWidth, setCardWidth] = useState<number>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -83,6 +86,19 @@ export const StudioCard: React.FC<IProps> = ({
|
||||||
setCardWidth(fittedCardWidth);
|
setCardWidth(fittedCardWidth);
|
||||||
}, [containerWidth]);
|
}, [containerWidth]);
|
||||||
|
|
||||||
|
function onToggleFavorite(v: boolean) {
|
||||||
|
if (studio.id) {
|
||||||
|
updateStudio({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: studio.id,
|
||||||
|
favorite: v,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function maybeRenderScenesPopoverButton() {
|
function maybeRenderScenesPopoverButton() {
|
||||||
if (!studio.scene_count) return;
|
if (!studio.scene_count) return;
|
||||||
|
|
||||||
|
|
@ -193,6 +209,12 @@ export const StudioCard: React.FC<IProps> = ({
|
||||||
<RatingBanner rating={studio.rating100} />
|
<RatingBanner rating={studio.rating100} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
overlays={
|
||||||
|
<FavoriteIcon
|
||||||
|
favorite={studio.favorite}
|
||||||
|
onToggleFavorite={(v) => onToggleFavorite(v)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
popovers={maybeRenderPopoverButtonGroup()}
|
popovers={maybeRenderPopoverButtonGroup()}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
selecting={selecting}
|
selecting={selecting}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import {
|
||||||
faLink,
|
faLink,
|
||||||
faChevronDown,
|
faChevronDown,
|
||||||
faChevronUp,
|
faChevronUp,
|
||||||
|
faHeart,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import TextUtils from "src/utils/text";
|
import TextUtils from "src/utils/text";
|
||||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||||
|
|
@ -154,6 +155,19 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
|
||||||
}
|
}
|
||||||
}, [setTabKey, populatedDefaultTab, tabKey]);
|
}, [setTabKey, populatedDefaultTab, tabKey]);
|
||||||
|
|
||||||
|
function setFavorite(v: boolean) {
|
||||||
|
if (studio.id) {
|
||||||
|
updateStudio({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: studio.id,
|
||||||
|
favorite: v,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// set up hotkeys
|
// set up hotkeys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Mousetrap.bind("e", () => toggleEditing());
|
Mousetrap.bind("e", () => toggleEditing());
|
||||||
|
|
@ -161,6 +175,7 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
|
||||||
setIsDeleteAlertOpen(true);
|
setIsDeleteAlertOpen(true);
|
||||||
});
|
});
|
||||||
Mousetrap.bind(",", () => setCollapsed(!collapsed));
|
Mousetrap.bind(",", () => setCollapsed(!collapsed));
|
||||||
|
Mousetrap.bind("f", () => setFavorite(!studio.favorite));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
Mousetrap.unbind("e");
|
Mousetrap.unbind("e");
|
||||||
|
|
@ -284,6 +299,12 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
|
||||||
|
|
||||||
const renderClickableIcons = () => (
|
const renderClickableIcons = () => (
|
||||||
<span className="name-icons">
|
<span className="name-icons">
|
||||||
|
<Button
|
||||||
|
className={cx("minimal", studio.favorite ? "favorite" : "not-favorite")}
|
||||||
|
onClick={() => setFavorite(!studio.favorite)}
|
||||||
|
>
|
||||||
|
<Icon icon={faHeart} />
|
||||||
|
</Button>
|
||||||
{studio.url && (
|
{studio.url && (
|
||||||
<Button
|
<Button
|
||||||
as={ExternalLink}
|
as={ExternalLink}
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,35 @@
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.studio-card {
|
||||||
|
button.btn.favorite-button {
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
top: 10px;
|
||||||
|
|
||||||
|
svg.fa-icon {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover button.btn.favorite-button.not-favorite {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#studio-page {
|
||||||
|
.studio-head {
|
||||||
|
.name-icons {
|
||||||
|
.not-favorite {
|
||||||
|
color: rgba(191, 204, 214, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite {
|
||||||
|
color: #ff7373;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,26 @@
|
||||||
import { BooleanCriterion, BooleanCriterionOption } from "./criterion";
|
import { BooleanCriterion, BooleanCriterionOption } from "./criterion";
|
||||||
|
|
||||||
export const FavoriteCriterionOption = new BooleanCriterionOption(
|
export const FavoritePerformerCriterionOption = new BooleanCriterionOption(
|
||||||
"favourite",
|
"favourite",
|
||||||
"filter_favorites",
|
"filter_favorites",
|
||||||
() => new FavoriteCriterion()
|
() => new FavoritePerformerCriterion()
|
||||||
);
|
);
|
||||||
|
|
||||||
export class FavoriteCriterion extends BooleanCriterion {
|
export class FavoritePerformerCriterion extends BooleanCriterion {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(FavoriteCriterionOption);
|
super(FavoritePerformerCriterionOption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FavoriteStudioCriterionOption = new BooleanCriterionOption(
|
||||||
|
"favourite",
|
||||||
|
"favorite",
|
||||||
|
() => new FavoriteStudioCriterion()
|
||||||
|
);
|
||||||
|
|
||||||
|
export class FavoriteStudioCriterion extends BooleanCriterion {
|
||||||
|
constructor() {
|
||||||
|
super(FavoriteStudioCriterionOption);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
createDateCriterionOption,
|
createDateCriterionOption,
|
||||||
createMandatoryTimestampCriterionOption,
|
createMandatoryTimestampCriterionOption,
|
||||||
} from "./criteria/criterion";
|
} from "./criteria/criterion";
|
||||||
import { FavoriteCriterionOption } from "./criteria/favorite";
|
import { FavoritePerformerCriterionOption } from "./criteria/favorite";
|
||||||
import { GenderCriterionOption } from "./criteria/gender";
|
import { GenderCriterionOption } from "./criteria/gender";
|
||||||
import { CircumcisedCriterionOption } from "./criteria/circumcised";
|
import { CircumcisedCriterionOption } from "./criteria/circumcised";
|
||||||
import { PerformerIsMissingCriterionOption } from "./criteria/is-missing";
|
import { PerformerIsMissingCriterionOption } from "./criteria/is-missing";
|
||||||
|
|
@ -81,7 +81,7 @@ const stringCriteria: CriterionType[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const criterionOptions = [
|
const criterionOptions = [
|
||||||
FavoriteCriterionOption,
|
FavoritePerformerCriterionOption,
|
||||||
GenderCriterionOption,
|
GenderCriterionOption,
|
||||||
CircumcisedCriterionOption,
|
CircumcisedCriterionOption,
|
||||||
PerformerIsMissingCriterionOption,
|
PerformerIsMissingCriterionOption,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
createStringCriterionOption,
|
createStringCriterionOption,
|
||||||
createMandatoryTimestampCriterionOption,
|
createMandatoryTimestampCriterionOption,
|
||||||
} from "./criteria/criterion";
|
} from "./criteria/criterion";
|
||||||
|
import { FavoriteStudioCriterionOption } from "./criteria/favorite";
|
||||||
import { StudioIsMissingCriterionOption } from "./criteria/is-missing";
|
import { StudioIsMissingCriterionOption } from "./criteria/is-missing";
|
||||||
import { RatingCriterionOption } from "./criteria/rating";
|
import { RatingCriterionOption } from "./criteria/rating";
|
||||||
import { StashIDCriterionOption } from "./criteria/stash-ids";
|
import { StashIDCriterionOption } from "./criteria/stash-ids";
|
||||||
|
|
@ -36,6 +37,7 @@ const sortByOptions = ["name", "random", "rating"]
|
||||||
|
|
||||||
const displayModeOptions = [DisplayMode.Grid, DisplayMode.Tagger];
|
const displayModeOptions = [DisplayMode.Grid, DisplayMode.Tagger];
|
||||||
const criterionOptions = [
|
const criterionOptions = [
|
||||||
|
FavoriteStudioCriterionOption,
|
||||||
createMandatoryStringCriterionOption("name"),
|
createMandatoryStringCriterionOption("name"),
|
||||||
createStringCriterionOption("details"),
|
createStringCriterionOption("details"),
|
||||||
ParentStudiosCriterionOption,
|
ParentStudiosCriterionOption,
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ export type CriterionType =
|
||||||
| "audio_codec"
|
| "audio_codec"
|
||||||
| "duration"
|
| "duration"
|
||||||
| "filter_favorites"
|
| "filter_favorites"
|
||||||
|
| "favorite"
|
||||||
| "has_markers"
|
| "has_markers"
|
||||||
| "is_missing"
|
| "is_missing"
|
||||||
| "tags"
|
| "tags"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue