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/systray v1.2.4
|
||||
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/vektah/gqlparser/v2 v2.4.1
|
||||
)
|
||||
@@ -90,7 +91,6 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rs/zerolog v1.26.1 // 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/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/stretchr/objx v0.2.0 // indirect
|
||||
|
||||
@@ -185,4 +185,5 @@ fragment ConfigData on ConfigResult {
|
||||
defaults {
|
||||
...ConfigDefaultSettingsData
|
||||
}
|
||||
ui
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ mutation ConfigureDefaults($input: ConfigDefaultSettingsInput!) {
|
||||
}
|
||||
}
|
||||
|
||||
mutation ConfigureUI($input: Map!) {
|
||||
configureUI(input: $input)
|
||||
}
|
||||
|
||||
mutation GenerateAPIKey($input: GenerateAPIKeyInput!) {
|
||||
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) {
|
||||
...SavedFilterData
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""The query root for this schema"""
|
||||
type Query {
|
||||
# Filters
|
||||
findSavedFilters(mode: FilterMode!): [SavedFilter!]!
|
||||
findSavedFilter(id: ID!): SavedFilter
|
||||
findSavedFilters(mode: FilterMode): [SavedFilter!]!
|
||||
findDefaultFilter(mode: FilterMode!): SavedFilter
|
||||
|
||||
"""Find a scene by ID or Checksum"""
|
||||
@@ -238,6 +239,11 @@ type Mutation {
|
||||
configureScraping(input: ConfigScrapingInput!): ConfigScrapingResult!
|
||||
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"""
|
||||
generateAPIKey(input: GenerateAPIKeyInput!): String!
|
||||
|
||||
|
||||
@@ -413,6 +413,7 @@ type ConfigResult {
|
||||
dlna: ConfigDLNAResult!
|
||||
scraping: ConfigScrapingResult!
|
||||
defaults: ConfigDefaultSettingsResult!
|
||||
ui: Map!
|
||||
}
|
||||
|
||||
"""Directory structure of a path"""
|
||||
|
||||
@@ -4,4 +4,9 @@ Timestamp is a point in time. It is always output as RFC3339-compatible time poi
|
||||
It can be input as a RFC3339 string, or as "<4h" for "4 hours in the past" or ">5m"
|
||||
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
|
||||
}
|
||||
|
||||
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(),
|
||||
Scraping: makeConfigScrapingResult(),
|
||||
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) {
|
||||
client := stashbox.NewClient(models.StashBox{Endpoint: input.Endpoint, APIKey: input.APIKey}, r.txnManager)
|
||||
user, err := client.GetUser(ctx)
|
||||
|
||||
@@ -2,13 +2,33 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"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 {
|
||||
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
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -153,6 +153,8 @@ const (
|
||||
ImageLightboxScrollMode = "image_lightbox.scroll_mode"
|
||||
ImageLightboxScrollAttemptsBeforeChange = "image_lightbox.scroll_attempts_before_change"
|
||||
|
||||
UI = "ui"
|
||||
|
||||
defaultImageLightboxSlideshowDelay = 5000
|
||||
|
||||
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 {
|
||||
// use custom.css in the same directory as the config file
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
func (_m *SavedFilterReaderWriter) Create(obj models.SavedFilter) (*models.SavedFilter, error) {
|
||||
ret := _m.Called(obj)
|
||||
@@ -118,6 +141,29 @@ func (_m *SavedFilterReaderWriter) FindDefault(mode models.FilterMode) (*models.
|
||||
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
|
||||
func (_m *SavedFilterReaderWriter) SetDefault(obj models.SavedFilter) (*models.SavedFilter, error) {
|
||||
ret := _m.Called(obj)
|
||||
|
||||
@@ -482,6 +482,7 @@ func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) {
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetCaptions provides a mock function with given fields: sceneID
|
||||
func (_m *SceneReaderWriter) GetCaptions(sceneID int) ([]*models.SceneCaption, error) {
|
||||
ret := _m.Called(sceneID)
|
||||
|
||||
@@ -751,13 +752,13 @@ func (_m *SceneReaderWriter) Update(updatedScene models.ScenePartial) (*models.S
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// UpdateCaptions provides a mock function with given fields: id, newCaptions
|
||||
func (_m *SceneReaderWriter) UpdateCaptions(sceneID int, captions []*models.SceneCaption) error {
|
||||
ret := _m.Called(sceneID, captions)
|
||||
// UpdateCaptions provides a mock function with given fields: id, captions
|
||||
func (_m *SceneReaderWriter) UpdateCaptions(id int, captions []*models.SceneCaption) error {
|
||||
ret := _m.Called(id, captions)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(int, []*models.SceneCaption) error); ok {
|
||||
r0 = rf(sceneID, captions)
|
||||
r0 = rf(id, captions)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package models
|
||||
|
||||
type SavedFilterReader interface {
|
||||
All() ([]*SavedFilter, error)
|
||||
Find(id int) (*SavedFilter, error)
|
||||
FindMany(ids []int, ignoreNotFound bool) ([]*SavedFilter, error)
|
||||
FindByMode(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
|
||||
}
|
||||
|
||||
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) {
|
||||
// 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
|
||||
}
|
||||
|
||||
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 { PageNotFound } from "./components/PageNotFound";
|
||||
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 { Settings } from "./components/Settings/Settings";
|
||||
import { Stats } from "./components/Stats";
|
||||
@@ -119,7 +119,7 @@ export const App: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/" component={Recommendations} />
|
||||
<Route exact path="/" component={FrontPage} />
|
||||
<Route path="/scenes" component={Scenes} />
|
||||
<Route path="/images" component={Images} />
|
||||
<Route path="/galleries" component={Galleries} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
### ✨ New Features
|
||||
* 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
|
||||
* Fix folder-based galleries not auto-tagging correctly if folder name contains `.` characters. ([#2658](https://github.com/stashapp/stash/pull/2658))
|
||||
* Fix scene cover in scene edit panel not being updated when changing scenes. ([#2657](https://github.com/stashapp/stash/pull/2657))
|
||||
* Fix moved gallery zip files not being rescanned. ([#2611](https://github.com/stashapp/stash/pull/2611))
|
||||
* Fix moved gallery zip files not being rescanned. ([#2611](https://github.com/stashapp/stash/pull/2611))
|
||||
|
||||
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-right: 0;
|
||||
}
|
||||
|
||||
.recommendations-footer {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
margin-bottom: 1em;
|
||||
margin-top: 1em;
|
||||
|
||||
button:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-recommendations {
|
||||
@@ -24,6 +35,25 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
font-size: 1.25rem;
|
||||
@@ -41,10 +71,98 @@
|
||||
|
||||
.recommendations-container .studio-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;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.slick-slider {
|
||||
box-sizing: border-box;
|
||||
@@ -310,7 +428,6 @@
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,37 +1,52 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { FindGalleriesQueryResult } from "src/core/generated-graphql";
|
||||
import { useFindGalleries } from "src/core/StashService";
|
||||
import Slider from "react-slick";
|
||||
import { GalleryCard } from "./GalleryCard";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
filter: ListFilterModel;
|
||||
result: FindGalleriesQueryResult;
|
||||
header: String;
|
||||
linkText: String;
|
||||
}
|
||||
|
||||
export const GalleryRecommendationRow: FunctionComponent<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 (
|
||||
<div className="recommendation-row gallery-recommendations">
|
||||
<div className="recommendation-row-head">
|
||||
<div>
|
||||
<h2>{props.header}</h2>
|
||||
</div>
|
||||
<RecommendationRow
|
||||
className="gallery-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<a href={`/galleries?${props.filter.makeQueryParameters()}`}>
|
||||
{props.linkText}
|
||||
<FormattedMessage id="view_all" />
|
||||
</a>
|
||||
</div>
|
||||
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}>
|
||||
{props.result.data?.findGalleries.galleries.map((gallery) => (
|
||||
<GalleryCard key={gallery.id} gallery={gallery} zoomIndex={1} />
|
||||
))}
|
||||
}
|
||||
>
|
||||
<Slider
|
||||
{...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>
|
||||
</div>
|
||||
</RecommendationRow>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,9 +11,9 @@ import { RatingBanner } from "../Shared/RatingBanner";
|
||||
interface IImageCardProps {
|
||||
image: GQL.SlimImageDataFragment;
|
||||
selecting?: boolean;
|
||||
selected: boolean | undefined;
|
||||
selected?: boolean | undefined;
|
||||
zoomIndex: number;
|
||||
onSelectedChanged: (selected: boolean, shiftKey: boolean) => void;
|
||||
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => 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 { FindMoviesQueryResult } from "src/core/generated-graphql";
|
||||
import React from "react";
|
||||
import { useFindMovies } from "src/core/StashService";
|
||||
import Slider from "react-slick";
|
||||
import { MovieCard } from "./MovieCard";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
filter: ListFilterModel;
|
||||
result: FindMoviesQueryResult;
|
||||
header: String;
|
||||
linkText: String;
|
||||
}
|
||||
|
||||
export const MovieRecommendationRow: FunctionComponent<IProps> = (
|
||||
props: IProps
|
||||
) => {
|
||||
const cardCount = props.result.data?.findMovies.count;
|
||||
export const MovieRecommendationRow: React.FC<IProps> = (props: IProps) => {
|
||||
const result = useFindMovies(props.filter);
|
||||
const cardCount = result.data?.findMovies.count;
|
||||
|
||||
if (!result.loading && !cardCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recommendation-row movie-recommendations">
|
||||
<div className="recommendation-row-head">
|
||||
<div>
|
||||
<h2>{props.header}</h2>
|
||||
</div>
|
||||
<RecommendationRow
|
||||
className="movie-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<a href={`/movies?${props.filter.makeQueryParameters()}`}>
|
||||
{props.linkText}
|
||||
<FormattedMessage id="view_all" />
|
||||
</a>
|
||||
</div>
|
||||
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}>
|
||||
{props.result.data?.findMovies.movies.map((p) => (
|
||||
<MovieCard key={p.id} movie={p} />
|
||||
))}
|
||||
}
|
||||
>
|
||||
<Slider
|
||||
{...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>
|
||||
</div>
|
||||
</RecommendationRow>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,37 +1,52 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { FindPerformersQueryResult } from "src/core/generated-graphql";
|
||||
import { useFindPerformers } from "src/core/StashService";
|
||||
import Slider from "react-slick";
|
||||
import { PerformerCard } from "./PerformerCard";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
filter: ListFilterModel;
|
||||
result: FindPerformersQueryResult;
|
||||
header: String;
|
||||
linkText: String;
|
||||
}
|
||||
|
||||
export const PerformerRecommendationRow: FunctionComponent<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 (
|
||||
<div className="recommendation-row performer-recommendations">
|
||||
<div className="recommendation-row-head">
|
||||
<div>
|
||||
<h2>{props.header}</h2>
|
||||
</div>
|
||||
<RecommendationRow
|
||||
className="performer-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<a href={`/performers?${props.filter.makeQueryParameters()}`}>
|
||||
{props.linkText}
|
||||
<FormattedMessage id="view_all" />
|
||||
</a>
|
||||
</div>
|
||||
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}>
|
||||
{props.result.data?.findPerformers.performers.map((p) => (
|
||||
<PerformerCard key={p.id} performer={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} />
|
||||
))}
|
||||
</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 { FindScenesQueryResult } from "src/core/generated-graphql";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { useFindScenes } from "src/core/StashService";
|
||||
import Slider from "react-slick";
|
||||
import { SceneCard } from "./SceneCard";
|
||||
import { SceneQueue } from "src/models/sceneQueue";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
filter: ListFilterModel;
|
||||
result: FindScenesQueryResult;
|
||||
queue: SceneQueue;
|
||||
header: String;
|
||||
linkText: String;
|
||||
}
|
||||
|
||||
export const SceneRecommendationRow: FunctionComponent<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 (
|
||||
<div className="recommendation-row scene-recommendations">
|
||||
<div className="recommendation-row-head">
|
||||
<div>
|
||||
<h2>{props.header}</h2>
|
||||
</div>
|
||||
<RecommendationRow
|
||||
className="scene-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<a href={`/scenes?${props.filter.makeQueryParameters()}`}>
|
||||
{props.linkText}
|
||||
<FormattedMessage id="view_all" />
|
||||
</a>
|
||||
</div>
|
||||
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}>
|
||||
{props.result.data?.findScenes.scenes.map((scene, index) => (
|
||||
<SceneCard
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
queue={props.queue}
|
||||
index={index}
|
||||
zoomIndex={1}
|
||||
/>
|
||||
))}
|
||||
}
|
||||
>
|
||||
<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
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
queue={queue}
|
||||
index={index}
|
||||
zoomIndex={1}
|
||||
/>
|
||||
))}
|
||||
</Slider>
|
||||
</div>
|
||||
</RecommendationRow>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Spinner } from "react-bootstrap";
|
||||
import { IUIConfig } from "src/core/config";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
useConfiguration,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
useConfigureGeneral,
|
||||
useConfigureInterface,
|
||||
useConfigureScraping,
|
||||
useConfigureUI,
|
||||
} from "src/core/StashService";
|
||||
import { useToast } from "src/hooks";
|
||||
import { withoutTypename } from "src/utils";
|
||||
@@ -29,6 +31,7 @@ export interface ISettingsContextState {
|
||||
defaults: GQL.ConfigDefaultSettingsInput;
|
||||
scraping: GQL.ConfigScrapingInput;
|
||||
dlna: GQL.ConfigDlnaInput;
|
||||
ui: IUIConfig;
|
||||
|
||||
// apikey isn't directly settable, so expose it here
|
||||
apiKey: string;
|
||||
@@ -38,6 +41,7 @@ export interface ISettingsContextState {
|
||||
saveDefaults: (input: Partial<GQL.ConfigDefaultSettingsInput>) => void;
|
||||
saveScraping: (input: Partial<GQL.ConfigScrapingInput>) => void;
|
||||
saveDLNA: (input: Partial<GQL.ConfigDlnaInput>) => void;
|
||||
saveUI: (input: IUIConfig) => void;
|
||||
}
|
||||
|
||||
export const SettingStateContext = React.createContext<ISettingsContextState>({
|
||||
@@ -48,12 +52,14 @@ export const SettingStateContext = React.createContext<ISettingsContextState>({
|
||||
defaults: {},
|
||||
scraping: {},
|
||||
dlna: {},
|
||||
ui: {},
|
||||
apiKey: "",
|
||||
saveGeneral: () => {},
|
||||
saveInterface: () => {},
|
||||
saveDefaults: () => {},
|
||||
saveScraping: () => {},
|
||||
saveDLNA: () => {},
|
||||
saveUI: () => {},
|
||||
});
|
||||
|
||||
export const SettingsContext: React.FC = ({ children }) => {
|
||||
@@ -92,6 +98,10 @@ export const SettingsContext: React.FC = ({ children }) => {
|
||||
>();
|
||||
const [updateDLNAConfig] = useConfigureDLNA();
|
||||
|
||||
const [ui, setUI] = useState({});
|
||||
const [pendingUI, setPendingUI] = useState<{} | undefined>();
|
||||
const [updateUIConfig] = useConfigureUI();
|
||||
|
||||
const [updateSuccess, setUpdateSuccess] = useState<boolean | undefined>();
|
||||
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
@@ -121,6 +131,7 @@ export const SettingsContext: React.FC = ({ children }) => {
|
||||
setDefaults({ ...withoutTypename(data.configuration.defaults) });
|
||||
setScraping({ ...withoutTypename(data.configuration.scraping) });
|
||||
setDLNA({ ...withoutTypename(data.configuration.dlna) });
|
||||
setUI({ ...withoutTypename(data.configuration.ui) });
|
||||
setApiKey(data.configuration.general.apiKey);
|
||||
}, [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() {
|
||||
if (updateSuccess === false) {
|
||||
return (
|
||||
@@ -401,7 +462,8 @@ export const SettingsContext: React.FC = ({ children }) => {
|
||||
pendingInterface ||
|
||||
pendingDefaults ||
|
||||
pendingScraping ||
|
||||
pendingDLNA
|
||||
pendingDLNA ||
|
||||
pendingUI
|
||||
) {
|
||||
return (
|
||||
<div className="loading-indicator">
|
||||
@@ -432,11 +494,13 @@ export const SettingsContext: React.FC = ({ children }) => {
|
||||
defaults,
|
||||
scraping,
|
||||
dlna,
|
||||
ui,
|
||||
saveGeneral,
|
||||
saveInterface,
|
||||
saveDefaults,
|
||||
saveScraping,
|
||||
saveDLNA,
|
||||
saveUI,
|
||||
}}
|
||||
>
|
||||
{maybeRenderLoadingIndicator()}
|
||||
|
||||
@@ -1,37 +1,52 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { FindStudiosQueryResult } from "src/core/generated-graphql";
|
||||
import { useFindStudios } from "src/core/StashService";
|
||||
import Slider from "react-slick";
|
||||
import { StudioCard } from "./StudioCard";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
filter: ListFilterModel;
|
||||
result: FindStudiosQueryResult;
|
||||
header: String;
|
||||
linkText: String;
|
||||
}
|
||||
|
||||
export const StudioRecommendationRow: FunctionComponent<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 (
|
||||
<div className="recommendation-row studio-recommendations">
|
||||
<div className="recommendation-row-head">
|
||||
<div>
|
||||
<h2>{props.header}</h2>
|
||||
</div>
|
||||
<RecommendationRow
|
||||
className="studio-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<a href={`/studios?${props.filter.makeQueryParameters()}`}>
|
||||
{props.linkText}
|
||||
<FormattedMessage id="view_all" />
|
||||
</a>
|
||||
</div>
|
||||
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}>
|
||||
{props.result.data?.findStudios.studios.map((studio) => (
|
||||
<StudioCard key={studio.id} studio={studio} hideParent={true} />
|
||||
))}
|
||||
}
|
||||
>
|
||||
<Slider
|
||||
{...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>
|
||||
</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({
|
||||
variables: {
|
||||
mode,
|
||||
@@ -813,6 +820,12 @@ export const useConfigureDefaults = () =>
|
||||
update: deleteCache([GQL.ConfigurationDocument]),
|
||||
});
|
||||
|
||||
export const useConfigureUI = () =>
|
||||
GQL.useConfigureUiMutation({
|
||||
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
|
||||
update: deleteCache([GQL.ConfigurationDocument]),
|
||||
});
|
||||
|
||||
export const useJobsSubscribe = () => GQL.useJobsSubscribeSubscription();
|
||||
|
||||
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/Movies/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/SceneDuplicateChecker/styles.scss";
|
||||
@import "src/components/SceneFilenameParser/styles.scss";
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"create_entity": "Create {entityType}",
|
||||
"create_marker": "Create Marker",
|
||||
"created_entity": "Created {entity_type}: {entity_name}",
|
||||
"customise": "Customise",
|
||||
"delete": "Delete",
|
||||
"delete_entity": "Delete {entityType}",
|
||||
"delete_file": "Delete file",
|
||||
@@ -734,6 +735,12 @@
|
||||
"filters": "Filters",
|
||||
"framerate": "Frame Rate",
|
||||
"frames_per_second": "{value} frames per second",
|
||||
"front_page": {
|
||||
"types": {
|
||||
"premade_filter": "Premade Filter",
|
||||
"saved_filter": "Saved Filter"
|
||||
}
|
||||
},
|
||||
"galleries": "Galleries",
|
||||
"gallery": "Gallery",
|
||||
"gallery_count": "Gallery Count",
|
||||
@@ -826,11 +833,8 @@
|
||||
"queue": "Queue",
|
||||
"random": "Random",
|
||||
"rating": "Rating",
|
||||
"recently_added_performers": "Recently Added Performers",
|
||||
"recently_added_studios": "Recently Added Studios",
|
||||
"recently_released_galleries": "Recently Released Galleries",
|
||||
"recently_released_movies": "Recently Released Movies",
|
||||
"recently_released_scenes": "Recently Released Scenes",
|
||||
"recently_added_objects": "Recently Added {objects}",
|
||||
"recently_released_objects": "Recently Released {objects}",
|
||||
"resolution": "Resolution",
|
||||
"scene": "Scene",
|
||||
"sceneTagger": "Scene Tagger",
|
||||
@@ -967,6 +971,7 @@
|
||||
"total": "Total",
|
||||
"true": "True",
|
||||
"twitter": "Twitter",
|
||||
"type": "Type",
|
||||
"updated_at": "Updated At",
|
||||
"url": "URL",
|
||||
"videos": "Videos",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const filterData = <T>(data?: (T | null | undefined)[] | null) =>
|
||||
data ? (data.filter((item) => item) as T[]) : [];
|
||||
|
||||
interface ITypename {
|
||||
export interface ITypename {
|
||||
__typename?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user