mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
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:
2
go.mod
2
go.mod
@@ -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
|
||||||
|
|||||||
@@ -185,4 +185,5 @@ fragment ConfigData on ConfigResult {
|
|||||||
defaults {
|
defaults {
|
||||||
...ConfigDefaultSettingsData
|
...ConfigDefaultSettingsData
|
||||||
}
|
}
|
||||||
|
ui
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
86
internal/manager/config/map.go
Normal file
86
internal/manager/config/map.go
Normal 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
|
||||||
|
}
|
||||||
82
internal/manager/config/map_test.go
Normal file
82
internal/manager/config/map_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
168
ui/v2.5/src/components/FrontPage/Control.tsx
Normal file
168
ui/v2.5/src/components/FrontPage/Control.tsx
Normal 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 <></>;
|
||||||
|
}
|
||||||
|
};
|
||||||
82
ui/v2.5/src/components/FrontPage/FrontPage.tsx
Normal file
82
ui/v2.5/src/components/FrontPage/FrontPage.tsx
Normal 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;
|
||||||
407
ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx
Normal file
407
ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
ui/v2.5/src/components/FrontPage/RecommendationRow.tsx
Normal file
24
ui/v2.5/src/components/FrontPage/RecommendationRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
@@ -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%;
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
52
ui/v2.5/src/components/Images/ImageRecommendationRow.tsx
Normal file
52
ui/v2.5/src/components/Images/ImageRecommendationRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 = () =>
|
||||||
|
|||||||
88
ui/v2.5/src/core/config.ts
Normal file
88
ui/v2.5/src/core/config.ts
Normal 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"),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user