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:
WithoutPants 2024-03-14 11:17:44 +11:00 committed by GitHub
parent e5929389b4
commit 8c454582c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 185 additions and 52 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,6 +31,7 @@ fragment StudioData on Studio {
}
details
rating100
favorite
aliases
}

View File

@ -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<CriterionValue>[];
@ -82,24 +82,6 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
setCardWidth(fittedCardWidth);
}, [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) {
if (performer.id) {
updatePerformer({
@ -292,7 +274,10 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
}
overlays={
<>
{renderFavoriteIcon()}
<FavoriteIcon
favorite={performer.favorite}
onToggleFavorite={onToggleFavorite}
/>
{maybeRenderRatingBanner()}
{maybeRenderFlag()}
</>

View File

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

View 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>
);
};

View File

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

View File

@ -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<IProps> = ({
selected,
onSelectedChanged,
}) => {
const [updateStudio] = useStudioUpdate();
const [cardWidth, setCardWidth] = useState<number>();
useEffect(() => {
@ -83,6 +86,19 @@ export const StudioCard: React.FC<IProps> = ({
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<IProps> = ({
<RatingBanner rating={studio.rating100} />
</div>
}
overlays={
<FavoriteIcon
favorite={studio.favorite}
onToggleFavorite={(v) => onToggleFavorite(v)}
/>
}
popovers={maybeRenderPopoverButtonGroup()}
selected={selected}
selecting={selecting}

View File

@ -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<IProps> = ({ 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<IProps> = ({ 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<IProps> = ({ studio, tabKey }) => {
const renderClickableIcons = () => (
<span className="name-icons">
<Button
className={cx("minimal", studio.favorite ? "favorite" : "not-favorite")}
onClick={() => setFavorite(!studio.favorite)}
>
<Icon icon={faHeart} />
</Button>
{studio.url && (
<Button
as={ExternalLink}

View File

@ -5,3 +5,35 @@
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;
}
}
}
}

View File

@ -1,14 +1,26 @@
import { BooleanCriterion, BooleanCriterionOption } from "./criterion";
export const FavoriteCriterionOption = new BooleanCriterionOption(
export const FavoritePerformerCriterionOption = new BooleanCriterionOption(
"favourite",
"filter_favorites",
() => new FavoriteCriterion()
() => new FavoritePerformerCriterion()
);
export class FavoriteCriterion extends BooleanCriterion {
export class FavoritePerformerCriterion extends BooleanCriterion {
constructor() {
super(FavoriteCriterionOption);
super(FavoritePerformerCriterionOption);
}
}
export const FavoriteStudioCriterionOption = new BooleanCriterionOption(
"favourite",
"favorite",
() => new FavoriteStudioCriterion()
);
export class FavoriteStudioCriterion extends BooleanCriterion {
constructor() {
super(FavoriteStudioCriterionOption);
}
}

View File

@ -6,7 +6,7 @@ import {
createDateCriterionOption,
createMandatoryTimestampCriterionOption,
} from "./criteria/criterion";
import { FavoriteCriterionOption } from "./criteria/favorite";
import { FavoritePerformerCriterionOption } from "./criteria/favorite";
import { GenderCriterionOption } from "./criteria/gender";
import { CircumcisedCriterionOption } from "./criteria/circumcised";
import { PerformerIsMissingCriterionOption } from "./criteria/is-missing";
@ -81,7 +81,7 @@ const stringCriteria: CriterionType[] = [
];
const criterionOptions = [
FavoriteCriterionOption,
FavoritePerformerCriterionOption,
GenderCriterionOption,
CircumcisedCriterionOption,
PerformerIsMissingCriterionOption,

View File

@ -5,6 +5,7 @@ import {
createStringCriterionOption,
createMandatoryTimestampCriterionOption,
} from "./criteria/criterion";
import { FavoriteStudioCriterionOption } from "./criteria/favorite";
import { StudioIsMissingCriterionOption } from "./criteria/is-missing";
import { RatingCriterionOption } from "./criteria/rating";
import { StashIDCriterionOption } from "./criteria/stash-ids";
@ -36,6 +37,7 @@ const sortByOptions = ["name", "random", "rating"]
const displayModeOptions = [DisplayMode.Grid, DisplayMode.Tagger];
const criterionOptions = [
FavoriteStudioCriterionOption,
createMandatoryStringCriterionOption("name"),
createStringCriterionOption("details"),
ParentStudiosCriterionOption,

View File

@ -135,6 +135,7 @@ export type CriterionType =
| "audio_codec"
| "duration"
| "filter_favorites"
| "favorite"
| "has_markers"
| "is_missing"
| "tags"