diff --git a/go.mod b/go.mod index b225d3150..d6ccb07f6 100644 --- a/go.mod +++ b/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 diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 9e7f853fe..58ba6d7a9 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -185,4 +185,5 @@ fragment ConfigData on ConfigResult { defaults { ...ConfigDefaultSettingsData } + ui } diff --git a/graphql/documents/mutations/config.graphql b/graphql/documents/mutations/config.graphql index fff7dbeca..dfd53ed75 100644 --- a/graphql/documents/mutations/config.graphql +++ b/graphql/documents/mutations/config.graphql @@ -36,6 +36,10 @@ mutation ConfigureDefaults($input: ConfigDefaultSettingsInput!) { } } +mutation ConfigureUI($input: Map!) { + configureUI(input: $input) +} + mutation GenerateAPIKey($input: GenerateAPIKeyInput!) { generateAPIKey(input: $input) } diff --git a/graphql/documents/queries/filter.graphql b/graphql/documents/queries/filter.graphql index 2c022fde7..67fbaf6cf 100644 --- a/graphql/documents/queries/filter.graphql +++ b/graphql/documents/queries/filter.graphql @@ -1,4 +1,10 @@ -query FindSavedFilters($mode: FilterMode!) { +query FindSavedFilter($id: ID!) { + findSavedFilter(id: $id) { + ...SavedFilterData + } +} + +query FindSavedFilters($mode: FilterMode) { findSavedFilters(mode: $mode) { ...SavedFilterData } diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index b81168a9a..7229dce1d 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -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! diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 281f133c4..9a84f0cc6 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -413,6 +413,7 @@ type ConfigResult { dlna: ConfigDLNAResult! scraping: ConfigScrapingResult! defaults: ConfigDefaultSettingsResult! + ui: Map! } """Directory structure of a path""" diff --git a/graphql/schema/types/scalars.graphql b/graphql/schema/types/scalars.graphql index 439f0d561..f973887a5 100644 --- a/graphql/schema/types/scalars.graphql +++ b/graphql/schema/types/scalars.graphql @@ -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 \ No newline at end of file +scalar Timestamp + +# generic JSON object +scalar Map + +scalar Any \ No newline at end of file diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 906378ca5..7413c413b 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -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) +} diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index ad0a2c142..d0852ff13 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -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) diff --git a/internal/api/resolver_query_find_saved_filter.go b/internal/api/resolver_query_find_saved_filter.go index d79697701..a28ef2f59 100644 --- a/internal/api/resolver_query_find_saved_filter.go +++ b/internal/api/resolver_query_find_saved_filter.go @@ -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 diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 1dbfa1a62..71847608e 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -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() diff --git a/internal/manager/config/map.go b/internal/manager/config/map.go new file mode 100644 index 000000000..d4944fe5f --- /dev/null +++ b/internal/manager/config/map.go @@ -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 +} diff --git a/internal/manager/config/map_test.go b/internal/manager/config/map_test.go new file mode 100644 index 000000000..3c7da15b2 --- /dev/null +++ b/internal/manager/config/map_test.go @@ -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) + } + }) + } +} diff --git a/pkg/models/mocks/SavedFilterReaderWriter.go b/pkg/models/mocks/SavedFilterReaderWriter.go index 987fdd5fc..952497be2 100644 --- a/pkg/models/mocks/SavedFilterReaderWriter.go +++ b/pkg/models/mocks/SavedFilterReaderWriter.go @@ -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) diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index a200087ea..0635fd200 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -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) } diff --git a/pkg/models/saved_filter.go b/pkg/models/saved_filter.go index e455d92c4..e6cd2f8e0 100644 --- a/pkg/models/saved_filter.go +++ b/pkg/models/saved_filter.go @@ -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) } diff --git a/pkg/sqlite/saved_filter.go b/pkg/sqlite/saved_filter.go index 8630a14a7..6c507bee3 100644 --- a/pkg/sqlite/saved_filter.go +++ b/pkg/sqlite/saved_filter.go @@ -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 +} diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index aa28ecac4..7a3abdd2a 100755 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -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 ( - + diff --git a/ui/v2.5/src/components/Changelog/versions/v0160.md b/ui/v2.5/src/components/Changelog/versions/v0160.md index bb0ed9efd..c8616373b 100644 --- a/ui/v2.5/src/components/Changelog/versions/v0160.md +++ b/ui/v2.5/src/components/Changelog/versions/v0160.md @@ -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)) \ No newline at end of file +* Fix moved gallery zip files not being rescanned. ([#2611](https://github.com/stashapp/stash/pull/2611)) diff --git a/ui/v2.5/src/components/FrontPage/Control.tsx b/ui/v2.5/src/components/FrontPage/Control.tsx new file mode 100644 index 000000000..501bcafe4 --- /dev/null +++ b/ui/v2.5/src/components/FrontPage/Control.tsx @@ -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 = ({ mode, filter, header }) => { + function isTouchEnabled() { + return "ontouchstart" in window || navigator.maxTouchPoints > 0; + } + + const isTouch = isTouchEnabled(); + + switch (mode) { + case GQL.FilterMode.Scenes: + return ( + + ); + case GQL.FilterMode.Studios: + return ( + + ); + case GQL.FilterMode.Movies: + return ( + + ); + case GQL.FilterMode.Performers: + return ( + + ); + case GQL.FilterMode.Galleries: + return ( + + ); + case GQL.FilterMode.Images: + return ( + + ); + default: + return <>; + } +}; + +interface ISavedFilterResults { + savedFilterID: string; +} + +const SavedFilterResults: React.FC = ({ + 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 ; +}; + +interface ICustomFilterProps { + customFilter: ICustomFilter; +} + +const CustomFilterResults: React.FC = ({ + 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 ( + + ); +}; + +interface IProps { + content: FrontPageContent; +} + +export const Control: React.FC = ({ content }) => { + switch (content.__typename) { + case "SavedFilter": + return ( + + ); + case "CustomFilter": + return ; + default: + return <>; + } +}; diff --git a/ui/v2.5/src/components/FrontPage/FrontPage.tsx b/ui/v2.5/src/components/FrontPage/FrontPage.tsx new file mode 100644 index 000000000..cddebca9d --- /dev/null +++ b/ui/v2.5/src/components/FrontPage/FrontPage.tsx @@ -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 ; + } + + if (isEditing) { + return onUpdateConfig(content)} />; + } + + const ui = (configuration?.ui ?? {}) as IUIConfig; + + if (!ui.frontPageContent) { + const defaultContent = generateDefaultFrontPageContent(intl); + onUpdateConfig(defaultContent); + } + + const { frontPageContent } = ui; + + return ( +
+
+ {frontPageContent?.map((content: FrontPageContent, i) => ( + + ))} +
+
+ +
+
+ ); +}; + +export default FrontPage; diff --git a/ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx b/ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx new file mode 100644 index 000000000..4bbf6a7c0 --- /dev/null +++ b/ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx @@ -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) { + return `${intl.formatMessage({ id: FilterModeToMessageID[f.mode] })}: ${ + f.name + }`; +} + +const AddContentModal: React.FC = ({ + 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(); + + 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 ( + + + + + onTypeSelected(e.target.value)} + className="btn-secondary" + > + {options.map((c) => ( + + ))} + + + ); + } + + function maybeRenderPremadeFiltersSelect() { + if (contentType !== "front_page.types.premade_filter") return; + + return ( + + + + + setPremadeFilterIndex(parseInt(e.target.value))} + className="btn-secondary" + > + {premadeFilterOptions.map((c, i) => ( + + ))} + + + ); + } + + function maybeRenderSavedFiltersSelect() { + if (contentType !== "front_page.types.saved_filter") return; + return ( + + + + + setSavedFilter(e.target.value)} + className="btn-secondary" + > + {savedFilterOptions.map((c) => ( + + ))} + + + ); + } + + 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 ( + onClose()}> + + + + +
+ {renderTypeSelect()} + {maybeRenderSavedFiltersSelect()} + {maybeRenderPremadeFiltersSelect()} +
+
+ + + + +
+ ); +}; + +interface IFilterRowProps { + content: FrontPageContent; + allSavedFilters: Pick[]; + onDelete: () => void; +} + +const ContentRow: React.FC = (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 ( +
+
+
+

{title()}

+
+ +
+
+ ); +}; + +interface IFrontPageConfigProps { + onClose: (content?: FrontPageContent[]) => void; +} + +export const FrontPageConfig: React.FC = ({ + 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([]); + const [dragIndex, setDragIndex] = useState(); + + useEffect(() => { + if (!allFilters?.findSavedFilters) { + return; + } + + if (ui?.frontPageContent) { + setCurrentContent(ui.frontPageContent); + } + }, [allFilters, ui]); + + function onDragStart(event: React.DragEvent, index: number) { + event.dataTransfer.effectAllowed = "move"; + setDragIndex(index); + } + + function onDragOver(event: React.DragEvent, 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) { + 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 ; + } + + 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 && ( + + )} +
+
+ {currentContent.map((content, index) => ( +
onDragStart(e, index)} + onDragEnter={(e) => onDragOver(e, index)} + onDrop={() => onDrop()} + > + deleteSavedFilter(index)} + /> +
+ ))} +
+
+ +
+
+
+
+ + +
+
+ + ); +}; diff --git a/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx b/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx new file mode 100644 index 000000000..e6e58b10a --- /dev/null +++ b/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx @@ -0,0 +1,24 @@ +import React, { PropsWithChildren } from "react"; + +interface IProps { + className?: string; + header: String; + link: JSX.Element; +} + +export const RecommendationRow: React.FC> = ({ + className, + header, + link, + children, +}) => ( +
+
+
+

{header}

+
+ {link} +
+ {children} +
+); diff --git a/ui/v2.5/src/components/Recommendations/styles.scss b/ui/v2.5/src/components/FrontPage/styles.scss similarity index 72% rename from ui/v2.5/src/components/Recommendations/styles.scss rename to ui/v2.5/src/components/FrontPage/styles.scss index ab8fb2ab7..4091392e7 100644 --- a/ui/v2.5/src/components/Recommendations/styles.scss +++ b/ui/v2.5/src/components/FrontPage/styles.scss @@ -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%; } diff --git a/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx b/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx index 827e66603..d3ca6823e 100644 --- a/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx @@ -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 = ( 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 ( -
-
-
-

{props.header}

-
+ - {props.linkText} + -
- - {props.result.data?.findGalleries.galleries.map((gallery) => ( - - ))} + } + > + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findGalleries.galleries.map((g) => ( + + ))}
-
+
); }; diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index 73a61a4f4..c078cbb5b 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -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; } diff --git a/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx b/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx new file mode 100644 index 000000000..6d76f3e17 --- /dev/null +++ b/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx @@ -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 = ( + props: IProps +) => { + const result = useFindImages(props.filter); + const cardCount = result.data?.findImages.count; + + if (!result.loading && !cardCount) { + return null; + } + + return ( + + + + } + > + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findImages.images.map((i) => ( + + ))} +
+
+ ); +}; diff --git a/ui/v2.5/src/components/Movies/MovieRecommendationRow.tsx b/ui/v2.5/src/components/Movies/MovieRecommendationRow.tsx index 90b66e3d4..bcc6557ee 100644 --- a/ui/v2.5/src/components/Movies/MovieRecommendationRow.tsx +++ b/ui/v2.5/src/components/Movies/MovieRecommendationRow.tsx @@ -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 = ( - props: IProps -) => { - const cardCount = props.result.data?.findMovies.count; +export const MovieRecommendationRow: React.FC = (props: IProps) => { + const result = useFindMovies(props.filter); + const cardCount = result.data?.findMovies.count; + + if (!result.loading && !cardCount) { + return null; + } + return ( -
-
-
-

{props.header}

-
+ - {props.linkText} + -
- - {props.result.data?.findMovies.movies.map((p) => ( - - ))} + } + > + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findMovies.movies.map((m) => ( + + ))}
-
+
); }; diff --git a/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx b/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx index 21fa9db0f..8769591a2 100644 --- a/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx +++ b/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx @@ -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 = ( 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 ( -
-
-
-

{props.header}

-
+ - {props.linkText} + -
- - {props.result.data?.findPerformers.performers.map((p) => ( - - ))} + } + > + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findPerformers.performers.map((p) => ( + + ))}
-
+ ); }; diff --git a/ui/v2.5/src/components/Recommendations/Recommendations.tsx b/ui/v2.5/src/components/Recommendations/Recommendations.tsx deleted file mode 100644 index e9e0cb448..000000000 --- a/ui/v2.5/src/components/Recommendations/Recommendations.tsx +++ /dev/null @@ -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 ; - } else { - return ( -
- {!hasScenes && - !hasStudios && - !hasMovies && - !hasPerformers && - !hasGalleries ? ( -
- {intl.formatMessage(messages.emptyServer)} -
- ) : ( -
- {hasScenes && ( - - )} - - {hasStudios && ( - - )} - - {hasMovies && ( - - )} - - {hasPerformers && ( - - )} - - {hasGalleries && ( - - )} -
- )} -
- ); - } -}; - -export default Recommendations; diff --git a/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx b/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx index ecd713d5b..e3d7f34e1 100644 --- a/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx +++ b/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx @@ -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 = ( 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 ( -
-
-
-

{props.header}

-
+ - {props.linkText} + -
- - {props.result.data?.findScenes.scenes.map((scene, index) => ( - - ))} + } + > + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findScenes.scenes.map((scene, index) => ( + + ))}
-
+ ); }; diff --git a/ui/v2.5/src/components/Settings/context.tsx b/ui/v2.5/src/components/Settings/context.tsx index 84e45ef70..2104c71f7 100644 --- a/ui/v2.5/src/components/Settings/context.tsx +++ b/ui/v2.5/src/components/Settings/context.tsx @@ -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) => void; saveScraping: (input: Partial) => void; saveDLNA: (input: Partial) => void; + saveUI: (input: IUIConfig) => void; } export const SettingStateContext = React.createContext({ @@ -48,12 +52,14 @@ export const SettingStateContext = React.createContext({ 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(); 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 (
@@ -432,11 +494,13 @@ export const SettingsContext: React.FC = ({ children }) => { defaults, scraping, dlna, + ui, saveGeneral, saveInterface, saveDefaults, saveScraping, saveDLNA, + saveUI, }} > {maybeRenderLoadingIndicator()} diff --git a/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx b/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx index ea26e7fbe..aa5dbda1d 100644 --- a/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx +++ b/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx @@ -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 = ( 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 ( -
-
-
-

{props.header}

-
+ - {props.linkText} + -
- - {props.result.data?.findStudios.studios.map((studio) => ( - - ))} + } + > + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findStudios.studios.map((s) => ( + + ))}
-
+ ); }; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index ce6aa8e78..0f1512ef7 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -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 = () => diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts new file mode 100644 index 000000000..007d70e32 --- /dev/null +++ b/ui/v2.5/src/core/config.ts @@ -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"), + ]; +} diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 99565d469..51ead6d7e 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -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"; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index daba6cde8..7e7633397 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -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", diff --git a/ui/v2.5/src/utils/data.ts b/ui/v2.5/src/utils/data.ts index 7fc08d3f4..df50a2d93 100644 --- a/ui/v2.5/src/utils/data.ts +++ b/ui/v2.5/src/utils/data.ts @@ -1,7 +1,7 @@ export const filterData = (data?: (T | null | undefined)[] | null) => data ? (data.filter((item) => item) as T[]) : []; -interface ITypename { +export interface ITypename { __typename?: string; }