Customize recommendations (#2592)

* refactored common code in recommendation row
* Implement front page options in config
* Allow customisation from front page
* Rename recommendations to front page
* Add generic UI settings
* Support adding premade filters

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
CJ
2022-06-13 19:34:04 -05:00
committed by GitHub
parent ff724d82cc
commit 9264c15540
38 changed files with 1549 additions and 292 deletions

2
go.mod
View File

@@ -53,6 +53,7 @@ require (
github.com/kermieisinthehouse/gosx-notifier v0.1.1 github.com/kermieisinthehouse/gosx-notifier v0.1.1
github.com/kermieisinthehouse/systray v1.2.4 github.com/kermieisinthehouse/systray v1.2.4
github.com/lucasb-eyer/go-colorful v1.2.0 github.com/lucasb-eyer/go-colorful v1.2.0
github.com/spf13/cast v1.4.1
github.com/vearutop/statigz v1.1.6 github.com/vearutop/statigz v1.1.6
github.com/vektah/gqlparser/v2 v2.4.1 github.com/vektah/gqlparser/v2 v2.4.1
) )
@@ -90,7 +91,6 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/zerolog v1.26.1 // indirect github.com/rs/zerolog v1.26.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/cobra v1.4.0 // indirect github.com/spf13/cobra v1.4.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/stretchr/objx v0.2.0 // indirect github.com/stretchr/objx v0.2.0 // indirect

View File

@@ -185,4 +185,5 @@ fragment ConfigData on ConfigResult {
defaults { defaults {
...ConfigDefaultSettingsData ...ConfigDefaultSettingsData
} }
ui
} }

View File

@@ -36,6 +36,10 @@ mutation ConfigureDefaults($input: ConfigDefaultSettingsInput!) {
} }
} }
mutation ConfigureUI($input: Map!) {
configureUI(input: $input)
}
mutation GenerateAPIKey($input: GenerateAPIKeyInput!) { mutation GenerateAPIKey($input: GenerateAPIKeyInput!) {
generateAPIKey(input: $input) generateAPIKey(input: $input)
} }

View File

@@ -1,4 +1,10 @@
query FindSavedFilters($mode: FilterMode!) { query FindSavedFilter($id: ID!) {
findSavedFilter(id: $id) {
...SavedFilterData
}
}
query FindSavedFilters($mode: FilterMode) {
findSavedFilters(mode: $mode) { findSavedFilters(mode: $mode) {
...SavedFilterData ...SavedFilterData
} }

View File

@@ -1,7 +1,8 @@
"""The query root for this schema""" """The query root for this schema"""
type Query { type Query {
# Filters # Filters
findSavedFilters(mode: FilterMode!): [SavedFilter!]! findSavedFilter(id: ID!): SavedFilter
findSavedFilters(mode: FilterMode): [SavedFilter!]!
findDefaultFilter(mode: FilterMode!): SavedFilter findDefaultFilter(mode: FilterMode!): SavedFilter
"""Find a scene by ID or Checksum""" """Find a scene by ID or Checksum"""
@@ -238,6 +239,11 @@ type Mutation {
configureScraping(input: ConfigScrapingInput!): ConfigScrapingResult! configureScraping(input: ConfigScrapingInput!): ConfigScrapingResult!
configureDefaults(input: ConfigDefaultSettingsInput!): ConfigDefaultSettingsResult! configureDefaults(input: ConfigDefaultSettingsInput!): ConfigDefaultSettingsResult!
# overwrites the entire UI configuration
configureUI(input: Map!): Map!
# sets a single UI key value
configureUISetting(key: String!, value: Any): Map!
"""Generate and set (or clear) API key""" """Generate and set (or clear) API key"""
generateAPIKey(input: GenerateAPIKeyInput!): String! generateAPIKey(input: GenerateAPIKeyInput!): String!

View File

@@ -413,6 +413,7 @@ type ConfigResult {
dlna: ConfigDLNAResult! dlna: ConfigDLNAResult!
scraping: ConfigScrapingResult! scraping: ConfigScrapingResult!
defaults: ConfigDefaultSettingsResult! defaults: ConfigDefaultSettingsResult!
ui: Map!
} }
"""Directory structure of a path""" """Directory structure of a path"""

View File

@@ -5,3 +5,8 @@ It can be input as a RFC3339 string, or as "<4h" for "4 hours in the past" or ">
for "5 minutes in the future" for "5 minutes in the future"
""" """
scalar Timestamp scalar Timestamp
# generic JSON object
scalar Map
scalar Any

View File

@@ -501,3 +501,23 @@ func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input models.Gene
return newAPIKey, nil return newAPIKey, nil
} }
func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]interface{}) (map[string]interface{}, error) {
c := config.GetInstance()
c.SetUIConfiguration(input)
if err := c.Write(); err != nil {
return c.GetUIConfiguration(), err
}
return c.GetUIConfiguration(), nil
}
func (r *mutationResolver) ConfigureUISetting(ctx context.Context, key string, value interface{}) (map[string]interface{}, error) {
c := config.GetInstance()
cfg := c.GetUIConfiguration()
cfg[key] = value
return r.ConfigureUI(ctx, cfg)
}

View File

@@ -66,6 +66,7 @@ func makeConfigResult() *models.ConfigResult {
Dlna: makeConfigDLNAResult(), Dlna: makeConfigDLNAResult(),
Scraping: makeConfigScrapingResult(), Scraping: makeConfigScrapingResult(),
Defaults: makeConfigDefaultsResult(), Defaults: makeConfigDefaultsResult(),
UI: makeConfigUIResult(),
} }
} }
@@ -216,6 +217,10 @@ func makeConfigDefaultsResult() *models.ConfigDefaultSettingsResult {
} }
} }
func makeConfigUIResult() map[string]interface{} {
return config.GetInstance().GetUIConfiguration()
}
func (r *queryResolver) ValidateStashBoxCredentials(ctx context.Context, input models.StashBoxInput) (*models.StashBoxValidationResult, error) { func (r *queryResolver) ValidateStashBoxCredentials(ctx context.Context, input models.StashBoxInput) (*models.StashBoxValidationResult, error) {
client := stashbox.NewClient(models.StashBox{Endpoint: input.Endpoint, APIKey: input.APIKey}, r.txnManager) client := stashbox.NewClient(models.StashBox{Endpoint: input.Endpoint, APIKey: input.APIKey}, r.txnManager)
user, err := client.GetUser(ctx) user, err := client.GetUser(ctx)

View File

@@ -2,13 +2,33 @@ package api
import ( import (
"context" "context"
"strconv"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
func (r *queryResolver) FindSavedFilters(ctx context.Context, mode models.FilterMode) (ret []*models.SavedFilter, err error) { func (r *queryResolver) FindSavedFilter(ctx context.Context, id string) (ret *models.SavedFilter, err error) {
idInt, err := strconv.Atoi(id)
if err != nil {
return nil, err
}
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.SavedFilter().FindByMode(mode) ret, err = repo.SavedFilter().Find(idInt)
return err
}); err != nil {
return nil, err
}
return ret, err
}
func (r *queryResolver) FindSavedFilters(ctx context.Context, mode *models.FilterMode) (ret []*models.SavedFilter, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
if mode != nil {
ret, err = repo.SavedFilter().FindByMode(*mode)
} else {
ret, err = repo.SavedFilter().All()
}
return err return err
}); err != nil { }); err != nil {
return nil, err return nil, err

View File

@@ -153,6 +153,8 @@ const (
ImageLightboxScrollMode = "image_lightbox.scroll_mode" ImageLightboxScrollMode = "image_lightbox.scroll_mode"
ImageLightboxScrollAttemptsBeforeChange = "image_lightbox.scroll_attempts_before_change" ImageLightboxScrollAttemptsBeforeChange = "image_lightbox.scroll_attempts_before_change"
UI = "ui"
defaultImageLightboxSlideshowDelay = 5000 defaultImageLightboxSlideshowDelay = 5000
DisableDropdownCreatePerformer = "disable_dropdown_create.performer" DisableDropdownCreatePerformer = "disable_dropdown_create.performer"
@@ -971,6 +973,26 @@ func (i *Instance) GetDisableDropdownCreate() *models.ConfigDisableDropdownCreat
} }
} }
func (i *Instance) GetUIConfiguration() map[string]interface{} {
i.RLock()
defer i.RUnlock()
// HACK: viper changes map keys to case insensitive values, so the workaround is to
// convert map keys to snake case for storage
v := i.viper(UI).GetStringMap(UI)
return fromSnakeCaseMap(v)
}
func (i *Instance) SetUIConfiguration(v map[string]interface{}) {
i.RLock()
defer i.RUnlock()
// HACK: viper changes map keys to case insensitive values, so the workaround is to
// convert map keys to snake case for storage
i.viper(UI).Set(UI, toSnakeCaseMap(v))
}
func (i *Instance) GetCSSPath() string { func (i *Instance) GetCSSPath() string {
// use custom.css in the same directory as the config file // use custom.css in the same directory as the config file
configFileUsed := i.GetConfigFile() configFileUsed := i.GetConfigFile()

View File

@@ -0,0 +1,86 @@
package config
import (
"bytes"
"unicode"
"github.com/spf13/cast"
)
// HACK: viper changes map keys to case insensitive values, so the workaround is to
// convert the map to use snake-case keys
// toSnakeCase converts a string to snake_case
// NOTE: a double capital will be converted in a way that will yield a different result
// when converted back to camel case.
// For example: someIDs => some_ids => someIds
func toSnakeCase(v string) string {
var buf bytes.Buffer
underscored := false
for i, c := range v {
if !underscored && unicode.IsUpper(c) && i > 0 {
buf.WriteByte('_')
underscored = true
} else {
underscored = false
}
buf.WriteRune(unicode.ToLower(c))
}
return buf.String()
}
func fromSnakeCase(v string) string {
var buf bytes.Buffer
cap := false
for i, c := range v {
switch {
case c == '_' && i > 0:
cap = true
case cap:
buf.WriteRune(unicode.ToUpper(c))
cap = false
default:
buf.WriteRune(c)
}
}
return buf.String()
}
// copyAndInsensitiviseMap behaves like insensitiviseMap, but creates a copy of
// any map it makes case insensitive.
func toSnakeCaseMap(m map[string]interface{}) map[string]interface{} {
nm := make(map[string]interface{})
for key, val := range m {
adjKey := toSnakeCase(key)
switch v := val.(type) {
case map[interface{}]interface{}:
nm[adjKey] = toSnakeCaseMap(cast.ToStringMap(v))
case map[string]interface{}:
nm[adjKey] = toSnakeCaseMap(v)
default:
nm[adjKey] = v
}
}
return nm
}
func fromSnakeCaseMap(m map[string]interface{}) map[string]interface{} {
nm := make(map[string]interface{})
for key, val := range m {
adjKey := fromSnakeCase(key)
switch v := val.(type) {
case map[interface{}]interface{}:
nm[adjKey] = fromSnakeCaseMap(cast.ToStringMap(v))
case map[string]interface{}:
nm[adjKey] = fromSnakeCaseMap(v)
default:
nm[adjKey] = v
}
}
return nm
}

View File

@@ -0,0 +1,82 @@
package config
import (
"testing"
)
func Test_toSnakeCase(t *testing.T) {
tests := []struct {
name string
v string
want string
}{
{
"basic",
"basic",
"basic",
},
{
"two words",
"twoWords",
"two_words",
},
{
"three word value",
"threeWordValue",
"three_word_value",
},
{
"snake case",
"snake_case",
"snake_case",
},
{
"double capital",
"doubleCApital",
"double_capital",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := toSnakeCase(tt.v); got != tt.want {
t.Errorf("toSnakeCase() = %v, want %v", got, tt.want)
}
})
}
}
func Test_fromSnakeCase(t *testing.T) {
tests := []struct {
name string
v string
want string
}{
{
"basic",
"basic",
"basic",
},
{
"two words",
"two_words",
"twoWords",
},
{
"three word value",
"three_word_value",
"threeWordValue",
},
{
"camel case",
"camelCase",
"camelCase",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := fromSnakeCase(tt.v); got != tt.want {
t.Errorf("fromSnakeCase() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -12,6 +12,29 @@ type SavedFilterReaderWriter struct {
mock.Mock mock.Mock
} }
// All provides a mock function with given fields:
func (_m *SavedFilterReaderWriter) All() ([]*models.SavedFilter, error) {
ret := _m.Called()
var r0 []*models.SavedFilter
if rf, ok := ret.Get(0).(func() []*models.SavedFilter); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.SavedFilter)
}
}
var r1 error
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Create provides a mock function with given fields: obj // Create provides a mock function with given fields: obj
func (_m *SavedFilterReaderWriter) Create(obj models.SavedFilter) (*models.SavedFilter, error) { func (_m *SavedFilterReaderWriter) Create(obj models.SavedFilter) (*models.SavedFilter, error) {
ret := _m.Called(obj) ret := _m.Called(obj)
@@ -118,6 +141,29 @@ func (_m *SavedFilterReaderWriter) FindDefault(mode models.FilterMode) (*models.
return r0, r1 return r0, r1
} }
// FindMany provides a mock function with given fields: ids, ignoreNotFound
func (_m *SavedFilterReaderWriter) FindMany(ids []int, ignoreNotFound bool) ([]*models.SavedFilter, error) {
ret := _m.Called(ids, ignoreNotFound)
var r0 []*models.SavedFilter
if rf, ok := ret.Get(0).(func([]int, bool) []*models.SavedFilter); ok {
r0 = rf(ids, ignoreNotFound)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.SavedFilter)
}
}
var r1 error
if rf, ok := ret.Get(1).(func([]int, bool) error); ok {
r1 = rf(ids, ignoreNotFound)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SetDefault provides a mock function with given fields: obj // SetDefault provides a mock function with given fields: obj
func (_m *SavedFilterReaderWriter) SetDefault(obj models.SavedFilter) (*models.SavedFilter, error) { func (_m *SavedFilterReaderWriter) SetDefault(obj models.SavedFilter) (*models.SavedFilter, error) {
ret := _m.Called(obj) ret := _m.Called(obj)

View File

@@ -482,6 +482,7 @@ func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) {
return r0, r1 return r0, r1
} }
// GetCaptions provides a mock function with given fields: sceneID
func (_m *SceneReaderWriter) GetCaptions(sceneID int) ([]*models.SceneCaption, error) { func (_m *SceneReaderWriter) GetCaptions(sceneID int) ([]*models.SceneCaption, error) {
ret := _m.Called(sceneID) ret := _m.Called(sceneID)
@@ -751,13 +752,13 @@ func (_m *SceneReaderWriter) Update(updatedScene models.ScenePartial) (*models.S
return r0, r1 return r0, r1
} }
// UpdateCaptions provides a mock function with given fields: id, newCaptions // UpdateCaptions provides a mock function with given fields: id, captions
func (_m *SceneReaderWriter) UpdateCaptions(sceneID int, captions []*models.SceneCaption) error { func (_m *SceneReaderWriter) UpdateCaptions(id int, captions []*models.SceneCaption) error {
ret := _m.Called(sceneID, captions) ret := _m.Called(id, captions)
var r0 error var r0 error
if rf, ok := ret.Get(0).(func(int, []*models.SceneCaption) error); ok { if rf, ok := ret.Get(0).(func(int, []*models.SceneCaption) error); ok {
r0 = rf(sceneID, captions) r0 = rf(id, captions)
} else { } else {
r0 = ret.Error(0) r0 = ret.Error(0)
} }

View File

@@ -1,7 +1,9 @@
package models package models
type SavedFilterReader interface { type SavedFilterReader interface {
All() ([]*SavedFilter, error)
Find(id int) (*SavedFilter, error) Find(id int) (*SavedFilter, error)
FindMany(ids []int, ignoreNotFound bool) ([]*SavedFilter, error)
FindByMode(mode FilterMode) ([]*SavedFilter, error) FindByMode(mode FilterMode) ([]*SavedFilter, error)
FindDefault(mode FilterMode) (*SavedFilter, error) FindDefault(mode FilterMode) (*SavedFilter, error)
} }

View File

@@ -81,6 +81,24 @@ func (qb *savedFilterQueryBuilder) Find(id int) (*models.SavedFilter, error) {
return &ret, nil return &ret, nil
} }
func (qb *savedFilterQueryBuilder) FindMany(ids []int, ignoreNotFound bool) ([]*models.SavedFilter, error) {
var filters []*models.SavedFilter
for _, id := range ids {
filter, err := qb.Find(id)
if err != nil {
return nil, err
}
if filter == nil && !ignoreNotFound {
return nil, fmt.Errorf("filter with id %d not found", id)
}
filters = append(filters, filter)
}
return filters, nil
}
func (qb *savedFilterQueryBuilder) FindByMode(mode models.FilterMode) ([]*models.SavedFilter, error) { func (qb *savedFilterQueryBuilder) FindByMode(mode models.FilterMode) ([]*models.SavedFilter, error) {
// exclude empty-named filters - these are the internal default filters // exclude empty-named filters - these are the internal default filters
@@ -108,3 +126,12 @@ func (qb *savedFilterQueryBuilder) FindDefault(mode models.FilterMode) (*models.
return nil, nil return nil, nil
} }
func (qb *savedFilterQueryBuilder) All() ([]*models.SavedFilter, error) {
var ret models.SavedFilters
if err := qb.query(selectAll(savedFilterTable), nil, &ret); err != nil {
return nil, err
}
return []*models.SavedFilter(ret), nil
}

View File

@@ -19,7 +19,7 @@ import Galleries from "./components/Galleries/Galleries";
import { MainNavbar } from "./components/MainNavbar"; import { MainNavbar } from "./components/MainNavbar";
import { PageNotFound } from "./components/PageNotFound"; import { PageNotFound } from "./components/PageNotFound";
import Performers from "./components/Performers/Performers"; import Performers from "./components/Performers/Performers";
import Recommendations from "./components/Recommendations/Recommendations"; import FrontPage from "./components/FrontPage/FrontPage";
import Scenes from "./components/Scenes/Scenes"; import Scenes from "./components/Scenes/Scenes";
import { Settings } from "./components/Settings/Settings"; import { Settings } from "./components/Settings/Settings";
import { Stats } from "./components/Stats"; import { Stats } from "./components/Stats";
@@ -119,7 +119,7 @@ export const App: React.FC = () => {
return ( return (
<Switch> <Switch>
<Route exact path="/" component={Recommendations} /> <Route exact path="/" component={FrontPage} />
<Route path="/scenes" component={Scenes} /> <Route path="/scenes" component={Scenes} />
<Route path="/images" component={Images} /> <Route path="/images" component={Images} />
<Route path="/galleries" component={Galleries} /> <Route path="/galleries" component={Galleries} />

View File

@@ -1,5 +1,6 @@
### ✨ New Features ### ✨ New Features
* Support submitting stash-box scene updates for scenes with stash ids. ([#2577](https://github.com/stashapp/stash/pull/2577)) * Support submitting stash-box scene updates for scenes with stash ids. ([#2577](https://github.com/stashapp/stash/pull/2577))
* Added support for customizing recommendations on home page. ([#2592](https://github.com/stashapp/stash/pull/2592))
### 🐛 Bug fixes ### 🐛 Bug fixes
* Fix folder-based galleries not auto-tagging correctly if folder name contains `.` characters. ([#2658](https://github.com/stashapp/stash/pull/2658)) * Fix folder-based galleries not auto-tagging correctly if folder name contains `.` characters. ([#2658](https://github.com/stashapp/stash/pull/2658))

View File

@@ -0,0 +1,168 @@
import React, { useMemo } from "react";
import { useIntl } from "react-intl";
import {
FrontPageContent,
ICustomFilter,
ISavedFilterRow,
} from "src/core/config";
import * as GQL from "src/core/generated-graphql";
import { useFindSavedFilter } from "src/core/StashService";
import { ListFilterModel } from "src/models/list-filter/filter";
import { GalleryRecommendationRow } from "../Galleries/GalleryRecommendationRow";
import { ImageRecommendationRow } from "../Images/ImageRecommendationRow";
import { MovieRecommendationRow } from "../Movies/MovieRecommendationRow";
import { PerformerRecommendationRow } from "../Performers/PerformerRecommendationRow";
import { SceneRecommendationRow } from "../Scenes/SceneRecommendationRow";
import { StudioRecommendationRow } from "../Studios/StudioRecommendationRow";
interface IFilter {
mode: GQL.FilterMode;
filter: ListFilterModel;
header: string;
}
const RecommendationRow: React.FC<IFilter> = ({ mode, filter, header }) => {
function isTouchEnabled() {
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
}
const isTouch = isTouchEnabled();
switch (mode) {
case GQL.FilterMode.Scenes:
return (
<SceneRecommendationRow
isTouch={isTouch}
filter={filter}
header={header}
/>
);
case GQL.FilterMode.Studios:
return (
<StudioRecommendationRow
isTouch={isTouch}
filter={filter}
header={header}
/>
);
case GQL.FilterMode.Movies:
return (
<MovieRecommendationRow
isTouch={isTouch}
filter={filter}
header={header}
/>
);
case GQL.FilterMode.Performers:
return (
<PerformerRecommendationRow
isTouch={isTouch}
filter={filter}
header={header}
/>
);
case GQL.FilterMode.Galleries:
return (
<GalleryRecommendationRow
isTouch={isTouch}
filter={filter}
header={header}
/>
);
case GQL.FilterMode.Images:
return (
<ImageRecommendationRow
isTouch={isTouch}
filter={filter}
header={header}
/>
);
default:
return <></>;
}
};
interface ISavedFilterResults {
savedFilterID: string;
}
const SavedFilterResults: React.FC<ISavedFilterResults> = ({
savedFilterID,
}) => {
const { loading, data } = useFindSavedFilter(savedFilterID.toString());
const filter = useMemo(() => {
if (!data?.findSavedFilter) return;
const { mode, filter: filterJSON } = data.findSavedFilter;
const ret = new ListFilterModel(mode);
ret.currentPage = 1;
ret.configureFromQueryParameters(JSON.parse(filterJSON));
ret.randomSeed = -1;
return ret;
}, [data?.findSavedFilter]);
if (loading || !data?.findSavedFilter || !filter) {
return <></>;
}
const { name, mode } = data.findSavedFilter;
return <RecommendationRow mode={mode} filter={filter} header={name} />;
};
interface ICustomFilterProps {
customFilter: ICustomFilter;
}
const CustomFilterResults: React.FC<ICustomFilterProps> = ({
customFilter,
}) => {
const intl = useIntl();
const filter = useMemo(() => {
const itemsPerPage = 25;
const ret = new ListFilterModel(customFilter.mode);
ret.sortBy = customFilter.sortBy;
ret.sortDirection = customFilter.direction;
ret.itemsPerPage = itemsPerPage;
ret.currentPage = 1;
ret.randomSeed = -1;
return ret;
}, [customFilter]);
const header = customFilter.message
? intl.formatMessage(
{ id: customFilter.message.id },
customFilter.message.values
)
: customFilter.title ?? "";
return (
<RecommendationRow
mode={customFilter.mode}
filter={filter}
header={header}
/>
);
};
interface IProps {
content: FrontPageContent;
}
export const Control: React.FC<IProps> = ({ content }) => {
switch (content.__typename) {
case "SavedFilter":
return (
<SavedFilterResults
savedFilterID={(content as ISavedFilterRow).savedFilterId.toString()}
/>
);
case "CustomFilter":
return <CustomFilterResults customFilter={content as ICustomFilter} />;
default:
return <></>;
}
};

View File

@@ -0,0 +1,82 @@
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useConfigureUI } from "src/core/StashService";
import { LoadingIndicator } from "src/components/Shared";
import { Button } from "react-bootstrap";
import { FrontPageConfig } from "./FrontPageConfig";
import { useToast } from "src/hooks";
import { Control } from "./Control";
import { ConfigurationContext } from "src/hooks/Config";
import {
FrontPageContent,
generateDefaultFrontPageContent,
IUIConfig,
} from "src/core/config";
const FrontPage: React.FC = () => {
const intl = useIntl();
const Toast = useToast();
const [isEditing, setIsEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [saveUI] = useConfigureUI();
const { configuration, loading } = React.useContext(ConfigurationContext);
async function onUpdateConfig(content?: FrontPageContent[]) {
setIsEditing(false);
if (!content) {
return;
}
setSaving(true);
try {
await saveUI({
variables: {
input: {
frontPageContent: content,
},
},
});
} catch (e) {
Toast.error(e);
}
setSaving(false);
}
if (loading || saving) {
return <LoadingIndicator />;
}
if (isEditing) {
return <FrontPageConfig onClose={(content) => onUpdateConfig(content)} />;
}
const ui = (configuration?.ui ?? {}) as IUIConfig;
if (!ui.frontPageContent) {
const defaultContent = generateDefaultFrontPageContent(intl);
onUpdateConfig(defaultContent);
}
const { frontPageContent } = ui;
return (
<div className="recommendations-container">
<div>
{frontPageContent?.map((content: FrontPageContent, i) => (
<Control key={i} content={content} />
))}
</div>
<div className="recommendations-footer">
<Button onClick={() => setIsEditing(true)}>
<FormattedMessage id={"actions.customise"} />
</Button>
</div>
</div>
);
};
export default FrontPage;

View File

@@ -0,0 +1,407 @@
import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, IntlShape, useIntl } from "react-intl";
import { useFindSavedFilters } from "src/core/StashService";
import { LoadingIndicator } from "src/components/Shared";
import { Button, Form, Modal } from "react-bootstrap";
import {
FilterMode,
FindSavedFiltersQuery,
SavedFilter,
} from "src/core/generated-graphql";
import { ConfigurationContext } from "src/hooks/Config";
import {
IUIConfig,
ISavedFilterRow,
ICustomFilter,
FrontPageContent,
generatePremadeFrontPageContent,
} from "src/core/config";
interface IAddSavedFilterModalProps {
onClose: (content?: FrontPageContent) => void;
existingSavedFilterIDs: string[];
candidates: FindSavedFiltersQuery;
}
const FilterModeToMessageID = {
[FilterMode.Galleries]: "galleries",
[FilterMode.Images]: "images",
[FilterMode.Movies]: "movies",
[FilterMode.Performers]: "performers",
[FilterMode.SceneMarkers]: "markers",
[FilterMode.Scenes]: "scenes",
[FilterMode.Studios]: "studios",
[FilterMode.Tags]: "tags",
};
function filterTitle(intl: IntlShape, f: Pick<SavedFilter, "mode" | "name">) {
return `${intl.formatMessage({ id: FilterModeToMessageID[f.mode] })}: ${
f.name
}`;
}
const AddContentModal: React.FC<IAddSavedFilterModalProps> = ({
onClose,
existingSavedFilterIDs,
candidates,
}) => {
const intl = useIntl();
const premadeFilterOptions = useMemo(
() => generatePremadeFrontPageContent(intl),
[intl]
);
const [contentType, setContentType] = useState(
"front_page.types.premade_filter"
);
const [premadeFilterIndex, setPremadeFilterIndex] = useState<
number | undefined
>(0);
const [savedFilter, setSavedFilter] = useState<string | undefined>();
function onTypeSelected(t: string) {
setContentType(t);
switch (t) {
case "front_page.types.premade_filter":
setPremadeFilterIndex(0);
setSavedFilter(undefined);
break;
case "front_page.types.saved_filter":
setPremadeFilterIndex(undefined);
setSavedFilter(undefined);
break;
}
}
function isValid() {
switch (contentType) {
case "front_page.types.premade_filter":
return premadeFilterIndex !== undefined;
case "front_page.types.saved_filter":
return savedFilter !== undefined;
}
return false;
}
const savedFilterOptions = useMemo(() => {
const ret = [
{
value: "",
text: "",
},
].concat(
candidates.findSavedFilters
.filter((f) => {
// markers not currently supported
return (
f.mode !== FilterMode.SceneMarkers &&
!existingSavedFilterIDs.includes(f.id)
);
})
.map((f) => {
return {
value: f.id,
text: filterTitle(intl, f),
};
})
);
ret.sort((a, b) => {
return a.text.localeCompare(b.text);
});
return ret;
}, [candidates, existingSavedFilterIDs, intl]);
function renderTypeSelect() {
const options = [
"front_page.types.premade_filter",
"front_page.types.saved_filter",
];
return (
<Form.Group controlId="filter">
<Form.Label>
<FormattedMessage id="type" />
</Form.Label>
<Form.Control
as="select"
value={contentType}
onChange={(e) => onTypeSelected(e.target.value)}
className="btn-secondary"
>
{options.map((c) => (
<option key={c} value={c}>
{intl.formatMessage({ id: c })}
</option>
))}
</Form.Control>
</Form.Group>
);
}
function maybeRenderPremadeFiltersSelect() {
if (contentType !== "front_page.types.premade_filter") return;
return (
<Form.Group controlId="premade-filter">
<Form.Label>
<FormattedMessage id="front_page.types.premade_filter" />
</Form.Label>
<Form.Control
as="select"
value={premadeFilterIndex}
onChange={(e) => setPremadeFilterIndex(parseInt(e.target.value))}
className="btn-secondary"
>
{premadeFilterOptions.map((c, i) => (
<option key={i} value={i}>
{intl.formatMessage({ id: c.message!.id }, c.message!.values)}
</option>
))}
</Form.Control>
</Form.Group>
);
}
function maybeRenderSavedFiltersSelect() {
if (contentType !== "front_page.types.saved_filter") return;
return (
<Form.Group controlId="filter">
<Form.Label>
<FormattedMessage id="search_filter.name" />
</Form.Label>
<Form.Control
as="select"
value={savedFilter}
onChange={(e) => setSavedFilter(e.target.value)}
className="btn-secondary"
>
{savedFilterOptions.map((c) => (
<option key={c.value} value={c.value}>
{c.text}
</option>
))}
</Form.Control>
</Form.Group>
);
}
function doAdd() {
switch (contentType) {
case "front_page.types.premade_filter":
onClose(premadeFilterOptions[premadeFilterIndex!]);
return;
case "front_page.types.saved_filter":
onClose({
__typename: "SavedFilter",
savedFilterId: parseInt(savedFilter!),
});
return;
}
onClose();
}
return (
<Modal show onHide={() => onClose()}>
<Modal.Header>
<FormattedMessage id="actions.add" />
</Modal.Header>
<Modal.Body>
<div className="dialog-content">
{renderTypeSelect()}
{maybeRenderSavedFiltersSelect()}
{maybeRenderPremadeFiltersSelect()}
</div>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => onClose()}>
<FormattedMessage id="actions.cancel" />
</Button>
<Button onClick={() => doAdd()} disabled={!isValid()}>
<FormattedMessage id="actions.add" />
</Button>
</Modal.Footer>
</Modal>
);
};
interface IFilterRowProps {
content: FrontPageContent;
allSavedFilters: Pick<SavedFilter, "id" | "mode" | "name">[];
onDelete: () => void;
}
const ContentRow: React.FC<IFilterRowProps> = (props: IFilterRowProps) => {
const intl = useIntl();
function title() {
switch (props.content.__typename) {
case "SavedFilter":
const savedFilter = props.allSavedFilters.find(
(f) =>
f.id === (props.content as ISavedFilterRow).savedFilterId.toString()
);
if (!savedFilter) return "";
return filterTitle(intl, savedFilter);
case "CustomFilter":
const asCustomFilter = props.content as ICustomFilter;
if (asCustomFilter.message)
return intl.formatMessage(
{ id: asCustomFilter.message.id },
asCustomFilter.message.values
);
return asCustomFilter.title ?? "";
}
}
return (
<div className="recommendation-row">
<div className="recommendation-row-head">
<div>
<h2>{title()}</h2>
</div>
<Button
variant="danger"
title={intl.formatMessage({ id: "actions.delete" })}
onClick={() => props.onDelete()}
>
<FormattedMessage id="actions.delete" />
</Button>
</div>
</div>
);
};
interface IFrontPageConfigProps {
onClose: (content?: FrontPageContent[]) => void;
}
export const FrontPageConfig: React.FC<IFrontPageConfigProps> = ({
onClose,
}) => {
const { configuration, loading } = React.useContext(ConfigurationContext);
const ui = configuration?.ui as IUIConfig;
const { data: allFilters, loading: loading2 } = useFindSavedFilters();
const [isAdd, setIsAdd] = useState(false);
const [currentContent, setCurrentContent] = useState<FrontPageContent[]>([]);
const [dragIndex, setDragIndex] = useState<number | undefined>();
useEffect(() => {
if (!allFilters?.findSavedFilters) {
return;
}
if (ui?.frontPageContent) {
setCurrentContent(ui.frontPageContent);
}
}, [allFilters, ui]);
function onDragStart(event: React.DragEvent<HTMLElement>, index: number) {
event.dataTransfer.effectAllowed = "move";
setDragIndex(index);
}
function onDragOver(event: React.DragEvent<HTMLElement>, index?: number) {
if (dragIndex !== undefined && index !== undefined && index !== dragIndex) {
const newFilters = [...currentContent];
const moved = newFilters.splice(dragIndex, 1);
newFilters.splice(index, 0, moved[0]);
setCurrentContent(newFilters);
setDragIndex(index);
}
event.dataTransfer.dropEffect = "move";
event.preventDefault();
}
function onDragOverDefault(event: React.DragEvent<HTMLDivElement>) {
event.dataTransfer.dropEffect = "move";
event.preventDefault();
}
function onDrop() {
// assume we've already set the temp filter list
// feed it up
setDragIndex(undefined);
}
if (loading || loading2) {
return <LoadingIndicator />;
}
const existingSavedFilterIDs = currentContent
.filter((f) => f.__typename === "SavedFilter")
.map((f) => (f as ISavedFilterRow).savedFilterId.toString());
function addSavedFilter(content?: FrontPageContent) {
setIsAdd(false);
if (!content) {
return;
}
setCurrentContent([...currentContent, content]);
}
function deleteSavedFilter(index: number) {
setCurrentContent(currentContent.filter((f, i) => i !== index));
}
return (
<>
{isAdd && allFilters && (
<AddContentModal
candidates={allFilters}
existingSavedFilterIDs={existingSavedFilterIDs}
onClose={addSavedFilter}
/>
)}
<div className="recommendations-container recommendations-container-edit">
<div onDragOver={onDragOverDefault}>
{currentContent.map((content, index) => (
<div
key={index}
draggable
onDragStart={(e) => onDragStart(e, index)}
onDragEnter={(e) => onDragOver(e, index)}
onDrop={() => onDrop()}
>
<ContentRow
key={index}
allSavedFilters={allFilters!.findSavedFilters}
content={content}
onDelete={() => deleteSavedFilter(index)}
/>
</div>
))}
<div className="recommendation-row recommendation-row-add">
<div className="recommendation-row-head">
<Button
className="recommendations-add"
variant="primary"
onClick={() => setIsAdd(true)}
>
<FormattedMessage id="actions.add" />
</Button>
</div>
</div>
</div>
<div className="recommendations-footer">
<Button onClick={() => onClose()} variant="secondary">
<FormattedMessage id={"actions.cancel"} />
</Button>
<Button onClick={() => onClose(currentContent)}>
<FormattedMessage id={"actions.save"} />
</Button>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,24 @@
import React, { PropsWithChildren } from "react";
interface IProps {
className?: string;
header: String;
link: JSX.Element;
}
export const RecommendationRow: React.FC<PropsWithChildren<IProps>> = ({
className,
header,
link,
children,
}) => (
<div className={`recommendation-row ${className}`}>
<div className="recommendation-row-head">
<div>
<h2>{header}</h2>
</div>
{link}
</div>
{children}
</div>
);

View File

@@ -6,6 +6,17 @@
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
} }
.recommendations-footer {
display: flex;
justify-content: right;
margin-bottom: 1em;
margin-top: 1em;
button:not(:last-child) {
margin-right: 10px;
}
}
} }
.no-recommendations { .no-recommendations {
@@ -24,6 +35,25 @@
padding: 15px 0; padding: 15px 0;
} }
.recommendations-container-edit {
.recommendation-row {
background-color: $secondary;
margin-bottom: 10px;
&:not(.recommendation-row-add) {
cursor: grab;
}
}
.recommendation-row-add .recommendation-row-head {
justify-content: center;
}
.recommendation-row-head {
padding: 15px 10px;
}
}
.recommendation-row-head h2 { .recommendation-row-head h2 {
display: inline-flex; display: inline-flex;
font-size: 1.25rem; font-size: 1.25rem;
@@ -41,10 +71,98 @@
.recommendations-container .studio-card hr, .recommendations-container .studio-card hr,
.recommendations-container .movie-card hr, .recommendations-container .movie-card hr,
.recommendations-container .gallery-card hr { .recommendations-container .gallery-card hr,
.recommendations-container .image-card hr {
margin-top: auto; margin-top: auto;
} }
/* skeletons */
.skeleton-card {
-webkit-animation: cardLoadingAnimation 2s infinite ease-in-out;
-moz-animation: cardLoadingAnimation 2s infinite ease-in-out;
-o-animation: cardLoadingAnimation 2s infinite ease-in-out;
animation: cardLoadingAnimation 2s infinite ease-in-out;
background-clip: border-box;
background-color: #30404d;
border: 1px solid rgba(0, 0, 0, 0.13);
border-radius: 3px;
box-shadow: 0 0 0 1px #10161a66, 0 0 #10161a00, 0 0 #10161a00;
display: flex;
flex-direction: column;
margin: 5px;
overflow: hidden;
padding: 0;
position: relative;
word-wrap: break-word;
}
@keyframes cardLoadingAnimation {
50% {
opacity: 0.5;
}
}
.scene-skeleton {
max-width: 320px;
min-height: 365px;
min-width: 320px;
@media (max-width: 576px) {
max-width: 20rem;
min-height: 25.2rem;
min-width: 20rem;
}
}
.movie-skeleton {
max-width: 240px;
min-height: 540px;
min-width: 240px;
@media (max-width: 576px) {
max-width: 16rem;
min-height: 34rem;
min-width: 16rem;
}
}
.performer-skeleton {
max-width: 20rem;
min-height: 39.1rem;
min-width: 20rem;
@media (max-width: 576px) {
max-width: 16rem;
min-height: 33.1rem;
min-width: 16rem;
}
}
.image-skeleton,
.gallery-skeleton {
max-width: 320px;
min-height: 403.5px;
min-width: 320px;
@media (max-width: 576px) {
max-width: 20rem;
min-height: 38.5rem;
min-width: 20rem;
}
}
.studio-skeleton {
max-width: 360px;
min-height: 278px;
min-width: 360px;
@media (max-width: 576px) {
max-width: 20rem;
min-height: 19.8rem;
min-width: 20rem;
}
}
/* Slider */ /* Slider */
.slick-slider { .slick-slider {
box-sizing: border-box; box-sizing: border-box;
@@ -310,7 +428,6 @@
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
position: absolute;
text-align: center; text-align: center;
width: 100%; width: 100%;
} }

View File

@@ -1,37 +1,52 @@
import React, { FunctionComponent } from "react"; import React, { FunctionComponent } from "react";
import { FindGalleriesQueryResult } from "src/core/generated-graphql"; import { useFindGalleries } from "src/core/StashService";
import Slider from "react-slick"; import Slider from "react-slick";
import { GalleryCard } from "./GalleryCard"; import { GalleryCard } from "./GalleryCard";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations"; import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
interface IProps { interface IProps {
isTouch: boolean; isTouch: boolean;
filter: ListFilterModel; filter: ListFilterModel;
result: FindGalleriesQueryResult;
header: String; header: String;
linkText: String;
} }
export const GalleryRecommendationRow: FunctionComponent<IProps> = ( export const GalleryRecommendationRow: FunctionComponent<IProps> = (
props: IProps props: IProps
) => { ) => {
const cardCount = props.result.data?.findGalleries.count; const result = useFindGalleries(props.filter);
const cardCount = result.data?.findGalleries.count;
if (!result.loading && !cardCount) {
return null;
}
return ( return (
<div className="recommendation-row gallery-recommendations"> <RecommendationRow
<div className="recommendation-row-head"> className="gallery-recommendations"
<div> header={props.header}
<h2>{props.header}</h2> link={
</div>
<a href={`/galleries?${props.filter.makeQueryParameters()}`}> <a href={`/galleries?${props.filter.makeQueryParameters()}`}>
{props.linkText} <FormattedMessage id="view_all" />
</a> </a>
</div> }
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}> >
{props.result.data?.findGalleries.galleries.map((gallery) => ( <Slider
<GalleryCard key={gallery.id} gallery={gallery} zoomIndex={1} /> {...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={i} className="gallery-skeleton skeleton-card"></div>
))
: result.data?.findGalleries.galleries.map((g) => (
<GalleryCard key={g.id} gallery={g} zoomIndex={1} />
))} ))}
</Slider> </Slider>
</div> </RecommendationRow>
); );
}; };

View File

@@ -11,9 +11,9 @@ import { RatingBanner } from "../Shared/RatingBanner";
interface IImageCardProps { interface IImageCardProps {
image: GQL.SlimImageDataFragment; image: GQL.SlimImageDataFragment;
selecting?: boolean; selecting?: boolean;
selected: boolean | undefined; selected?: boolean | undefined;
zoomIndex: number; zoomIndex: number;
onSelectedChanged: (selected: boolean, shiftKey: boolean) => void; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
onPreview?: (ev: MouseEvent) => void; onPreview?: (ev: MouseEvent) => void;
} }

View File

@@ -0,0 +1,52 @@
import React, { FunctionComponent } from "react";
import { useFindImages } from "src/core/StashService";
import Slider from "react-slick";
import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { ImageCard } from "./ImageCard";
interface IProps {
isTouch: boolean;
filter: ListFilterModel;
header: String;
}
export const ImageRecommendationRow: FunctionComponent<IProps> = (
props: IProps
) => {
const result = useFindImages(props.filter);
const cardCount = result.data?.findImages.count;
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className="images-recommendations"
header={props.header}
link={
<a href={`/images?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</a>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={i} className="image-skeleton skeleton-card"></div>
))
: result.data?.findImages.images.map((i) => (
<ImageCard key={i.id} image={i} zoomIndex={1} />
))}
</Slider>
</RecommendationRow>
);
};

View File

@@ -1,37 +1,50 @@
import React, { FunctionComponent } from "react"; import React from "react";
import { FindMoviesQueryResult } from "src/core/generated-graphql"; import { useFindMovies } from "src/core/StashService";
import Slider from "react-slick"; import Slider from "react-slick";
import { MovieCard } from "./MovieCard"; import { MovieCard } from "./MovieCard";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations"; import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
interface IProps { interface IProps {
isTouch: boolean; isTouch: boolean;
filter: ListFilterModel; filter: ListFilterModel;
result: FindMoviesQueryResult;
header: String; header: String;
linkText: String;
} }
export const MovieRecommendationRow: FunctionComponent<IProps> = ( export const MovieRecommendationRow: React.FC<IProps> = (props: IProps) => {
props: IProps const result = useFindMovies(props.filter);
) => { const cardCount = result.data?.findMovies.count;
const cardCount = props.result.data?.findMovies.count;
if (!result.loading && !cardCount) {
return null;
}
return ( return (
<div className="recommendation-row movie-recommendations"> <RecommendationRow
<div className="recommendation-row-head"> className="movie-recommendations"
<div> header={props.header}
<h2>{props.header}</h2> link={
</div>
<a href={`/movies?${props.filter.makeQueryParameters()}`}> <a href={`/movies?${props.filter.makeQueryParameters()}`}>
{props.linkText} <FormattedMessage id="view_all" />
</a> </a>
</div> }
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}> >
{props.result.data?.findMovies.movies.map((p) => ( <Slider
<MovieCard key={p.id} movie={p} /> {...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={i} className="movie-skeleton skeleton-card"></div>
))
: result.data?.findMovies.movies.map((m) => (
<MovieCard key={m.id} movie={m} />
))} ))}
</Slider> </Slider>
</div> </RecommendationRow>
); );
}; };

View File

@@ -1,37 +1,52 @@
import React, { FunctionComponent } from "react"; import React, { FunctionComponent } from "react";
import { FindPerformersQueryResult } from "src/core/generated-graphql"; import { useFindPerformers } from "src/core/StashService";
import Slider from "react-slick"; import Slider from "react-slick";
import { PerformerCard } from "./PerformerCard"; import { PerformerCard } from "./PerformerCard";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations"; import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
interface IProps { interface IProps {
isTouch: boolean; isTouch: boolean;
filter: ListFilterModel; filter: ListFilterModel;
result: FindPerformersQueryResult;
header: String; header: String;
linkText: String;
} }
export const PerformerRecommendationRow: FunctionComponent<IProps> = ( export const PerformerRecommendationRow: FunctionComponent<IProps> = (
props: IProps props: IProps
) => { ) => {
const cardCount = props.result.data?.findPerformers.count; const result = useFindPerformers(props.filter);
const cardCount = result.data?.findPerformers.count;
if (!result.loading && !cardCount) {
return null;
}
return ( return (
<div className="recommendation-row performer-recommendations"> <RecommendationRow
<div className="recommendation-row-head"> className="performer-recommendations"
<div> header={props.header}
<h2>{props.header}</h2> link={
</div>
<a href={`/performers?${props.filter.makeQueryParameters()}`}> <a href={`/performers?${props.filter.makeQueryParameters()}`}>
{props.linkText} <FormattedMessage id="view_all" />
</a> </a>
</div> }
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}> >
{props.result.data?.findPerformers.performers.map((p) => ( <Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={i} className="performer-skeleton skeleton-card"></div>
))
: result.data?.findPerformers.performers.map((p) => (
<PerformerCard key={p.id} performer={p} /> <PerformerCard key={p.id} performer={p} />
))} ))}
</Slider> </Slider>
</div> </RecommendationRow>
); );
}; };

View File

@@ -1,174 +0,0 @@
import * as GQL from "src/core/generated-graphql";
import { defineMessages, useIntl } from "react-intl";
import React from "react";
import {
useFindScenes,
useFindMovies,
useFindStudios,
useFindGalleries,
useFindPerformers,
} from "src/core/StashService";
import { SceneRecommendationRow } from "src/components/Scenes/SceneRecommendationRow";
import { StudioRecommendationRow } from "src/components/Studios/StudioRecommendationRow";
import { MovieRecommendationRow } from "src/components/Movies/MovieRecommendationRow";
import { PerformerRecommendationRow } from "src/components/Performers/PerformerRecommendationRow";
import { GalleryRecommendationRow } from "src/components/Galleries/GalleryRecommendationRow";
import { SceneQueue } from "src/models/sceneQueue";
import { ListFilterModel } from "src/models/list-filter/filter";
import { LoadingIndicator } from "src/components/Shared";
const Recommendations: React.FC = () => {
function isTouchEnabled() {
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
}
const isTouch = isTouchEnabled();
const intl = useIntl();
const itemsPerPage = 25;
const scenefilter = new ListFilterModel(GQL.FilterMode.Scenes);
scenefilter.sortBy = "date";
scenefilter.sortDirection = GQL.SortDirectionEnum.Desc;
scenefilter.itemsPerPage = itemsPerPage;
const sceneResult = useFindScenes(scenefilter);
const hasScenes = !!sceneResult?.data?.findScenes?.count;
const studiofilter = new ListFilterModel(GQL.FilterMode.Studios);
studiofilter.sortBy = "created_at";
studiofilter.sortDirection = GQL.SortDirectionEnum.Desc;
studiofilter.itemsPerPage = itemsPerPage;
const studioResult = useFindStudios(studiofilter);
const hasStudios = !!studioResult?.data?.findStudios?.count;
const moviefilter = new ListFilterModel(GQL.FilterMode.Movies);
moviefilter.sortBy = "date";
moviefilter.sortDirection = GQL.SortDirectionEnum.Desc;
moviefilter.itemsPerPage = itemsPerPage;
const movieResult = useFindMovies(moviefilter);
const hasMovies = !!movieResult?.data?.findMovies?.count;
const performerfilter = new ListFilterModel(GQL.FilterMode.Performers);
performerfilter.sortBy = "created_at";
performerfilter.sortDirection = GQL.SortDirectionEnum.Desc;
performerfilter.itemsPerPage = itemsPerPage;
const performerResult = useFindPerformers(performerfilter);
const hasPerformers = !!performerResult?.data?.findPerformers?.count;
const galleryfilter = new ListFilterModel(GQL.FilterMode.Galleries);
galleryfilter.sortBy = "date";
galleryfilter.sortDirection = GQL.SortDirectionEnum.Desc;
galleryfilter.itemsPerPage = itemsPerPage;
const galleryResult = useFindGalleries(galleryfilter);
const hasGalleries = !!galleryResult?.data?.findGalleries?.count;
const messages = defineMessages({
emptyServer: {
id: "empty_server",
defaultMessage:
"Add some scenes to your server to view recommendations on this page.",
},
recentlyAddedStudios: {
id: "recently_added_studios",
defaultMessage: "Recently Added Studios",
},
recentlyAddedPerformers: {
id: "recently_added_performers",
defaultMessage: "Recently Added Performers",
},
recentlyReleasedGalleries: {
id: "recently_released_galleries",
defaultMessage: "Recently Released Galleries",
},
recentlyReleasedMovies: {
id: "recently_released_movies",
defaultMessage: "Recently Released Movies",
},
recentlyReleasedScenes: {
id: "recently_released_scenes",
defaultMessage: "Recently Released Scenes",
},
viewAll: {
id: "view_all",
defaultMessage: "View All",
},
});
if (
sceneResult.loading ||
studioResult.loading ||
movieResult.loading ||
performerResult.loading ||
galleryResult.loading
) {
return <LoadingIndicator />;
} else {
return (
<div className="recommendations-container">
{!hasScenes &&
!hasStudios &&
!hasMovies &&
!hasPerformers &&
!hasGalleries ? (
<div className="no-recommendations">
{intl.formatMessage(messages.emptyServer)}
</div>
) : (
<div>
{hasScenes && (
<SceneRecommendationRow
isTouch={isTouch}
filter={scenefilter}
result={sceneResult}
queue={SceneQueue.fromListFilterModel(scenefilter)}
header={intl.formatMessage(messages.recentlyReleasedScenes)}
linkText={intl.formatMessage(messages.viewAll)}
/>
)}
{hasStudios && (
<StudioRecommendationRow
isTouch={isTouch}
filter={studiofilter}
result={studioResult}
header={intl.formatMessage(messages.recentlyAddedStudios)}
linkText={intl.formatMessage(messages.viewAll)}
/>
)}
{hasMovies && (
<MovieRecommendationRow
isTouch={isTouch}
filter={moviefilter}
result={movieResult}
header={intl.formatMessage(messages.recentlyReleasedMovies)}
linkText={intl.formatMessage(messages.viewAll)}
/>
)}
{hasPerformers && (
<PerformerRecommendationRow
isTouch={isTouch}
filter={performerfilter}
result={performerResult}
header={intl.formatMessage(messages.recentlyAddedPerformers)}
linkText={intl.formatMessage(messages.viewAll)}
/>
)}
{hasGalleries && (
<GalleryRecommendationRow
isTouch={isTouch}
filter={galleryfilter}
result={galleryResult}
header={intl.formatMessage(messages.recentlyReleasedGalleries)}
linkText={intl.formatMessage(messages.viewAll)}
/>
)}
</div>
)}
</div>
);
}
};
export default Recommendations;

View File

@@ -1,45 +1,63 @@
import React, { FunctionComponent } from "react"; import React, { FunctionComponent, useMemo } from "react";
import { FindScenesQueryResult } from "src/core/generated-graphql"; import { useFindScenes } from "src/core/StashService";
import Slider from "react-slick"; import Slider from "react-slick";
import { SceneCard } from "./SceneCard"; import { SceneCard } from "./SceneCard";
import { SceneQueue } from "src/models/sceneQueue"; import { SceneQueue } from "src/models/sceneQueue";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations"; import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
interface IProps { interface IProps {
isTouch: boolean; isTouch: boolean;
filter: ListFilterModel; filter: ListFilterModel;
result: FindScenesQueryResult;
queue: SceneQueue;
header: String; header: String;
linkText: String;
} }
export const SceneRecommendationRow: FunctionComponent<IProps> = ( export const SceneRecommendationRow: FunctionComponent<IProps> = (
props: IProps props: IProps
) => { ) => {
const cardCount = props.result.data?.findScenes.count; const result = useFindScenes(props.filter);
const cardCount = result.data?.findScenes.count;
const queue = useMemo(() => {
return SceneQueue.fromListFilterModel(props.filter);
}, [props.filter]);
if (!result.loading && !cardCount) {
return null;
}
return ( return (
<div className="recommendation-row scene-recommendations"> <RecommendationRow
<div className="recommendation-row-head"> className="scene-recommendations"
<div> header={props.header}
<h2>{props.header}</h2> link={
</div>
<a href={`/scenes?${props.filter.makeQueryParameters()}`}> <a href={`/scenes?${props.filter.makeQueryParameters()}`}>
{props.linkText} <FormattedMessage id="view_all" />
</a> </a>
</div> }
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}> >
{props.result.data?.findScenes.scenes.map((scene, index) => ( <Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={i} className="scene-skeleton skeleton-card"></div>
))
: result.data?.findScenes.scenes.map((scene, index) => (
<SceneCard <SceneCard
key={scene.id} key={scene.id}
scene={scene} scene={scene}
queue={props.queue} queue={queue}
index={index} index={index}
zoomIndex={1} zoomIndex={1}
/> />
))} ))}
</Slider> </Slider>
</div> </RecommendationRow>
); );
}; };

View File

@@ -8,6 +8,7 @@ import React, {
useRef, useRef,
} from "react"; } from "react";
import { Spinner } from "react-bootstrap"; import { Spinner } from "react-bootstrap";
import { IUIConfig } from "src/core/config";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
useConfiguration, useConfiguration,
@@ -16,6 +17,7 @@ import {
useConfigureGeneral, useConfigureGeneral,
useConfigureInterface, useConfigureInterface,
useConfigureScraping, useConfigureScraping,
useConfigureUI,
} from "src/core/StashService"; } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { withoutTypename } from "src/utils"; import { withoutTypename } from "src/utils";
@@ -29,6 +31,7 @@ export interface ISettingsContextState {
defaults: GQL.ConfigDefaultSettingsInput; defaults: GQL.ConfigDefaultSettingsInput;
scraping: GQL.ConfigScrapingInput; scraping: GQL.ConfigScrapingInput;
dlna: GQL.ConfigDlnaInput; dlna: GQL.ConfigDlnaInput;
ui: IUIConfig;
// apikey isn't directly settable, so expose it here // apikey isn't directly settable, so expose it here
apiKey: string; apiKey: string;
@@ -38,6 +41,7 @@ export interface ISettingsContextState {
saveDefaults: (input: Partial<GQL.ConfigDefaultSettingsInput>) => void; saveDefaults: (input: Partial<GQL.ConfigDefaultSettingsInput>) => void;
saveScraping: (input: Partial<GQL.ConfigScrapingInput>) => void; saveScraping: (input: Partial<GQL.ConfigScrapingInput>) => void;
saveDLNA: (input: Partial<GQL.ConfigDlnaInput>) => void; saveDLNA: (input: Partial<GQL.ConfigDlnaInput>) => void;
saveUI: (input: IUIConfig) => void;
} }
export const SettingStateContext = React.createContext<ISettingsContextState>({ export const SettingStateContext = React.createContext<ISettingsContextState>({
@@ -48,12 +52,14 @@ export const SettingStateContext = React.createContext<ISettingsContextState>({
defaults: {}, defaults: {},
scraping: {}, scraping: {},
dlna: {}, dlna: {},
ui: {},
apiKey: "", apiKey: "",
saveGeneral: () => {}, saveGeneral: () => {},
saveInterface: () => {}, saveInterface: () => {},
saveDefaults: () => {}, saveDefaults: () => {},
saveScraping: () => {}, saveScraping: () => {},
saveDLNA: () => {}, saveDLNA: () => {},
saveUI: () => {},
}); });
export const SettingsContext: React.FC = ({ children }) => { export const SettingsContext: React.FC = ({ children }) => {
@@ -92,6 +98,10 @@ export const SettingsContext: React.FC = ({ children }) => {
>(); >();
const [updateDLNAConfig] = useConfigureDLNA(); const [updateDLNAConfig] = useConfigureDLNA();
const [ui, setUI] = useState({});
const [pendingUI, setPendingUI] = useState<{} | undefined>();
const [updateUIConfig] = useConfigureUI();
const [updateSuccess, setUpdateSuccess] = useState<boolean | undefined>(); const [updateSuccess, setUpdateSuccess] = useState<boolean | undefined>();
const [apiKey, setApiKey] = useState(""); const [apiKey, setApiKey] = useState("");
@@ -121,6 +131,7 @@ export const SettingsContext: React.FC = ({ children }) => {
setDefaults({ ...withoutTypename(data.configuration.defaults) }); setDefaults({ ...withoutTypename(data.configuration.defaults) });
setScraping({ ...withoutTypename(data.configuration.scraping) }); setScraping({ ...withoutTypename(data.configuration.scraping) });
setDLNA({ ...withoutTypename(data.configuration.dlna) }); setDLNA({ ...withoutTypename(data.configuration.dlna) });
setUI({ ...withoutTypename(data.configuration.ui) });
setApiKey(data.configuration.general.apiKey); setApiKey(data.configuration.general.apiKey);
}, [data, error]); }, [data, error]);
@@ -387,6 +398,56 @@ export const SettingsContext: React.FC = ({ children }) => {
}); });
} }
// saves the configuration if no further changes are made after a half second
const saveUIConfig = useMemo(
() =>
debounce(async (input: IUIConfig) => {
try {
setUpdateSuccess(undefined);
await updateUIConfig({
variables: {
input,
},
});
setPendingUI(undefined);
onSuccess();
} catch (e) {
setSaveError(e);
}
}, 500),
[updateUIConfig, onSuccess]
);
useEffect(() => {
if (!pendingUI) {
return;
}
saveUIConfig(pendingUI);
}, [pendingUI, saveUIConfig]);
function saveUI(input: IUIConfig) {
if (!ui) {
return;
}
setUI({
...ui,
...input,
});
setPendingUI((current) => {
if (!current) {
return input;
}
return {
...current,
...input,
};
});
}
function maybeRenderLoadingIndicator() { function maybeRenderLoadingIndicator() {
if (updateSuccess === false) { if (updateSuccess === false) {
return ( return (
@@ -401,7 +462,8 @@ export const SettingsContext: React.FC = ({ children }) => {
pendingInterface || pendingInterface ||
pendingDefaults || pendingDefaults ||
pendingScraping || pendingScraping ||
pendingDLNA pendingDLNA ||
pendingUI
) { ) {
return ( return (
<div className="loading-indicator"> <div className="loading-indicator">
@@ -432,11 +494,13 @@ export const SettingsContext: React.FC = ({ children }) => {
defaults, defaults,
scraping, scraping,
dlna, dlna,
ui,
saveGeneral, saveGeneral,
saveInterface, saveInterface,
saveDefaults, saveDefaults,
saveScraping, saveScraping,
saveDLNA, saveDLNA,
saveUI,
}} }}
> >
{maybeRenderLoadingIndicator()} {maybeRenderLoadingIndicator()}

View File

@@ -1,37 +1,52 @@
import React, { FunctionComponent } from "react"; import React, { FunctionComponent } from "react";
import { FindStudiosQueryResult } from "src/core/generated-graphql"; import { useFindStudios } from "src/core/StashService";
import Slider from "react-slick"; import Slider from "react-slick";
import { StudioCard } from "./StudioCard"; import { StudioCard } from "./StudioCard";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations"; import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
interface IProps { interface IProps {
isTouch: boolean; isTouch: boolean;
filter: ListFilterModel; filter: ListFilterModel;
result: FindStudiosQueryResult;
header: String; header: String;
linkText: String;
} }
export const StudioRecommendationRow: FunctionComponent<IProps> = ( export const StudioRecommendationRow: FunctionComponent<IProps> = (
props: IProps props: IProps
) => { ) => {
const cardCount = props.result.data?.findStudios.count; const result = useFindStudios(props.filter);
const cardCount = result.data?.findStudios.count;
if (!result.loading && !cardCount) {
return null;
}
return ( return (
<div className="recommendation-row studio-recommendations"> <RecommendationRow
<div className="recommendation-row-head"> className="studio-recommendations"
<div> header={props.header}
<h2>{props.header}</h2> link={
</div>
<a href={`/studios?${props.filter.makeQueryParameters()}`}> <a href={`/studios?${props.filter.makeQueryParameters()}`}>
{props.linkText} <FormattedMessage id="view_all" />
</a> </a>
</div> }
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}> >
{props.result.data?.findStudios.studios.map((studio) => ( <Slider
<StudioCard key={studio.id} studio={studio} hideParent={true} /> {...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={i} className="studio-skeleton skeleton-card"></div>
))
: result.data?.findStudios.studios.map((s) => (
<StudioCard key={s.id} studio={s} hideParent={true} />
))} ))}
</Slider> </Slider>
</div> </RecommendationRow>
); );
}; };

View File

@@ -44,7 +44,14 @@ const deleteCache = (queries: DocumentNode[]) => {
}); });
}; };
export const useFindSavedFilters = (mode: GQL.FilterMode) => export const useFindSavedFilter = (id: string) =>
GQL.useFindSavedFilterQuery({
variables: {
id,
},
});
export const useFindSavedFilters = (mode?: GQL.FilterMode) =>
GQL.useFindSavedFiltersQuery({ GQL.useFindSavedFiltersQuery({
variables: { variables: {
mode, mode,
@@ -813,6 +820,12 @@ export const useConfigureDefaults = () =>
update: deleteCache([GQL.ConfigurationDocument]), update: deleteCache([GQL.ConfigurationDocument]),
}); });
export const useConfigureUI = () =>
GQL.useConfigureUiMutation({
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
update: deleteCache([GQL.ConfigurationDocument]),
});
export const useJobsSubscribe = () => GQL.useJobsSubscribeSubscription(); export const useJobsSubscribe = () => GQL.useJobsSubscribeSubscription();
export const useConfigureDLNA = () => export const useConfigureDLNA = () =>

View File

@@ -0,0 +1,88 @@
import { IntlShape } from "react-intl";
import { ITypename } from "src/utils";
import { FilterMode, SortDirectionEnum } from "./generated-graphql";
// NOTE: double capitals aren't converted correctly in the backend
export interface ISavedFilterRow extends ITypename {
__typename: "SavedFilter";
savedFilterId: number;
}
export interface IMessage {
id: string;
values: { [key: string]: string };
}
export interface ICustomFilter extends ITypename {
__typename: "CustomFilter";
message?: IMessage;
title?: string;
mode: FilterMode;
sortBy: string;
direction: SortDirectionEnum;
}
export type FrontPageContent = ISavedFilterRow | ICustomFilter;
export interface IUIConfig {
frontPageContent?: FrontPageContent[];
}
function recentlyReleased(
intl: IntlShape,
mode: FilterMode,
objectsID: string
): ICustomFilter {
return {
__typename: "CustomFilter",
message: {
id: "recently_released_objects",
values: { objects: intl.formatMessage({ id: objectsID }) },
},
mode,
sortBy: "date",
direction: SortDirectionEnum.Desc,
};
}
function recentlyAdded(
intl: IntlShape,
mode: FilterMode,
objectsID: string
): ICustomFilter {
return {
__typename: "CustomFilter",
message: {
id: "recently_added_objects",
values: { objects: intl.formatMessage({ id: objectsID }) },
},
mode,
sortBy: "created_at",
direction: SortDirectionEnum.Desc,
};
}
export function generateDefaultFrontPageContent(intl: IntlShape) {
return [
recentlyReleased(intl, FilterMode.Scenes, "scenes"),
recentlyAdded(intl, FilterMode.Studios, "studios"),
recentlyReleased(intl, FilterMode.Movies, "movies"),
recentlyAdded(intl, FilterMode.Performers, "performers"),
recentlyReleased(intl, FilterMode.Galleries, "galleries"),
];
}
export function generatePremadeFrontPageContent(intl: IntlShape) {
return [
recentlyReleased(intl, FilterMode.Scenes, "scenes"),
recentlyAdded(intl, FilterMode.Scenes, "scenes"),
recentlyReleased(intl, FilterMode.Galleries, "galleries"),
recentlyAdded(intl, FilterMode.Galleries, "galleries"),
recentlyAdded(intl, FilterMode.Images, "images"),
recentlyReleased(intl, FilterMode.Movies, "movies"),
recentlyAdded(intl, FilterMode.Movies, "movies"),
recentlyAdded(intl, FilterMode.Studios, "studios"),
recentlyAdded(intl, FilterMode.Performers, "performers"),
];
}

View File

@@ -8,7 +8,7 @@
@import "src/components/List/styles.scss"; @import "src/components/List/styles.scss";
@import "src/components/Movies/styles.scss"; @import "src/components/Movies/styles.scss";
@import "src/components/Performers/styles.scss"; @import "src/components/Performers/styles.scss";
@import "src/components/Recommendations/styles.scss"; @import "src/components/FrontPage/styles.scss";
@import "src/components/Scenes/styles.scss"; @import "src/components/Scenes/styles.scss";
@import "src/components/SceneDuplicateChecker/styles.scss"; @import "src/components/SceneDuplicateChecker/styles.scss";
@import "src/components/SceneFilenameParser/styles.scss"; @import "src/components/SceneFilenameParser/styles.scss";

View File

@@ -22,6 +22,7 @@
"create_entity": "Create {entityType}", "create_entity": "Create {entityType}",
"create_marker": "Create Marker", "create_marker": "Create Marker",
"created_entity": "Created {entity_type}: {entity_name}", "created_entity": "Created {entity_type}: {entity_name}",
"customise": "Customise",
"delete": "Delete", "delete": "Delete",
"delete_entity": "Delete {entityType}", "delete_entity": "Delete {entityType}",
"delete_file": "Delete file", "delete_file": "Delete file",
@@ -734,6 +735,12 @@
"filters": "Filters", "filters": "Filters",
"framerate": "Frame Rate", "framerate": "Frame Rate",
"frames_per_second": "{value} frames per second", "frames_per_second": "{value} frames per second",
"front_page": {
"types": {
"premade_filter": "Premade Filter",
"saved_filter": "Saved Filter"
}
},
"galleries": "Galleries", "galleries": "Galleries",
"gallery": "Gallery", "gallery": "Gallery",
"gallery_count": "Gallery Count", "gallery_count": "Gallery Count",
@@ -826,11 +833,8 @@
"queue": "Queue", "queue": "Queue",
"random": "Random", "random": "Random",
"rating": "Rating", "rating": "Rating",
"recently_added_performers": "Recently Added Performers", "recently_added_objects": "Recently Added {objects}",
"recently_added_studios": "Recently Added Studios", "recently_released_objects": "Recently Released {objects}",
"recently_released_galleries": "Recently Released Galleries",
"recently_released_movies": "Recently Released Movies",
"recently_released_scenes": "Recently Released Scenes",
"resolution": "Resolution", "resolution": "Resolution",
"scene": "Scene", "scene": "Scene",
"sceneTagger": "Scene Tagger", "sceneTagger": "Scene Tagger",
@@ -967,6 +971,7 @@
"total": "Total", "total": "Total",
"true": "True", "true": "True",
"twitter": "Twitter", "twitter": "Twitter",
"type": "Type",
"updated_at": "Updated At", "updated_at": "Updated At",
"url": "URL", "url": "URL",
"videos": "Videos", "videos": "Videos",

View File

@@ -1,7 +1,7 @@
export const filterData = <T>(data?: (T | null | undefined)[] | null) => export const filterData = <T>(data?: (T | null | undefined)[] | null) =>
data ? (data.filter((item) => item) as T[]) : []; data ? (data.filter((item) => item) as T[]) : [];
interface ITypename { export interface ITypename {
__typename?: string; __typename?: string;
} }