diff --git a/.dockerignore b/.dockerignore index 954624f3c04..4e8b99fa1d9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,7 @@ # GraphQL generated output pkg/models/generated_*.go -ui/v2.5/src/core/generated-*.tsx +ui/v2.5/src/core/generated-graphql.ts #### # Jetbrains diff --git a/Makefile b/Makefile index fc0b0217991..b4c8dfbeaf9 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,11 @@ GO_BUILD_TAGS += sqlite_stat4 sqlite_math_functions export CGO_ENABLED := 1 +# define COMPILER_IMAGE for cross-compilation docker container +ifndef COMPILER_IMAGE + COMPILER_IMAGE := stashapp/compiler:latest +endif + .PHONY: release release: pre-ui generate ui build-release @@ -378,3 +383,16 @@ docker-build: build-info .PHONY: docker-cuda-build docker-cuda-build: build-info docker build --build-arg GITHASH=$(GITHASH) --build-arg STASH_VERSION=$(STASH_VERSION) -t stash/cuda-build -f docker/build/x86_64/Dockerfile-CUDA . + +# start the build container - for cross compilation +# this is adapted from the github actions build.yml file +.PHONY: start-compiler-container +start-compiler-container: + docker run -d --name build --mount type=bind,source="$(PWD)",target=/stash,consistency=delegated $(EXTRA_CONTAINER_ARGS) -w /stash $(COMPILER_IMAGE) tail -f /dev/null + +# run the cross-compilation using +# docker exec -t build /bin/bash -c "make build-cc-" + +.PHONY: remove-compiler-container +remove-compiler-container: + docker rm -f -v build \ No newline at end of file diff --git a/README.md b/README.md index cdfa165fcdb..299e7105047 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,11 @@ For further information you can consult the [documentation](https://docs.stashap # Installing Stash - Windows | MacOS| Linux | Docker + Windows | macOS | Linux | Docker :---:|:---:|:---:|:---: -[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe)
[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe) | [Latest Release (Apple Silicon)](https://github.com/stashapp/stash/releases/latest/download/stash-macos-applesilicon)
[Latest Release (Intel)](https://github.com/stashapp/stash/releases/latest/download/stash-macos-intel)
[Development Preview (Universal)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-macos) | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux)
[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)
[More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md)
[Sample docker-compose.yml](docker/production/docker-compose.yml) +[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe)
[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe) | [Latest Release](https://github.com/stashapp/stash/releases/latest/download/Stash.app.zip)
[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/Stash.app.zip) | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux)
[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)
[More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md)
[Sample docker-compose.yml](docker/production/docker-compose.yml) + +Download links for other platforms and architectures are available on the [Releases page](https://github.com/stashapp/stash/releases). ## First Run diff --git a/docker/ci/x86_64/Dockerfile b/docker/ci/x86_64/Dockerfile index b97fc9c06b7..03365f510c4 100644 --- a/docker/ci/x86_64/Dockerfile +++ b/docker/ci/x86_64/Dockerfile @@ -11,8 +11,13 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \ FROM --platform=$TARGETPLATFORM alpine:latest AS app COPY --from=binary /stash /usr/bin/ + +# vips version 8.15.0-r0 breaks thumbnail generation on arm32v6 +# need to use 8.14.3-r0 from alpine 3.18 instead + RUN apk add --no-cache --virtual .build-deps gcc python3-dev musl-dev \ - && apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg vips-tools ruby tzdata \ + && apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg ruby tzdata \ + && apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.18/community vips=8.14.3-r0 vips-tools=8.14.3-r0 \ && pip install --user --break-system-packages mechanicalsoup cloudscraper bencoder.pyx \ && gem install faraday \ && apk del .build-deps diff --git a/docker/production/README.md b/docker/production/README.md index 9744e670887..a09066e7072 100644 --- a/docker/production/README.md +++ b/docker/production/README.md @@ -9,11 +9,11 @@ https://docs.docker.com/engine/install/ ### Get the docker-compose.yml file -Now you can either navigate to the [docker-compose.yml](https://raw.githubusercontent.com/stashapp/stash/master/docker/production/docker-compose.yml) in the repository, or if you have curl, you can make your Linux console do it for you: +Now you can either navigate to the [docker-compose.yml](https://raw.githubusercontent.com/stashapp/stash/develop/docker/production/docker-compose.yml) in the repository, or if you have curl, you can make your Linux console do it for you: ``` mkdir stashapp && cd stashapp -curl -o docker-compose.yml https://raw.githubusercontent.com/stashapp/stash/master/docker/production/docker-compose.yml +curl -o docker-compose.yml https://raw.githubusercontent.com/stashapp/stash/develop/docker/production/docker-compose.yml ``` Once you have that file where you want it, modify the settings as you please, and then run: diff --git a/go.mod b/go.mod index 2eda54b1e87..104aaba4798 100644 --- a/go.mod +++ b/go.mod @@ -49,12 +49,12 @@ require ( github.com/vektra/mockery/v2 v2.10.0 github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e github.com/zencoder/go-dash/v3 v3.0.2 - golang.org/x/crypto v0.14.0 + golang.org/x/crypto v0.17.0 golang.org/x/image v0.12.0 golang.org/x/net v0.17.0 - golang.org/x/sys v0.13.0 - golang.org/x/term v0.13.0 - golang.org/x/text v0.13.0 + golang.org/x/sys v0.15.0 + golang.org/x/term v0.15.0 + golang.org/x/text v0.14.0 gopkg.in/guregu/null.v4 v4.0.0 gopkg.in/yaml.v2 v2.4.0 ) diff --git a/go.sum b/go.sum index 6fc3b230c77..607b74a9cbd 100644 --- a/go.sum +++ b/go.sum @@ -614,8 +614,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -827,14 +827,14 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= -golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -846,8 +846,9 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/gqlgen.yml b/gqlgen.yml index f24c1fca8be..a564192576f 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -36,6 +36,8 @@ models: model: github.com/stashapp/stash/internal/api.Timestamp BoolMap: model: github.com/stashapp/stash/internal/api.BoolMap + PluginConfigMap: + model: github.com/stashapp/stash/internal/api.PluginConfigMap # define to force resolvers Image: model: github.com/stashapp/stash/pkg/models.Image diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 9c35d103f17..e60cdb6830e 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -69,6 +69,7 @@ type Query { findStudios( studio_filter: StudioFilterType filter: FindFilterType + ids: [ID!] ): FindStudiosResultType! "Find a movie by ID" @@ -83,12 +84,14 @@ type Query { findGalleries( gallery_filter: GalleryFilterType filter: FindFilterType + ids: [ID!] ): FindGalleriesResultType! findTag(id: ID!): Tag findTags( tag_filter: TagFilterType filter: FindFilterType + ids: [Int!] ): FindTagsResultType! "Retrieve random scene markers for the wall" @@ -201,11 +204,11 @@ type Query { allSceneMarkers: [SceneMarker!]! allImages: [Image!]! allGalleries: [Gallery!]! - allStudios: [Studio!]! allMovies: [Movie!]! - allTags: [Tag!]! allPerformers: [Performer!]! @deprecated(reason: "Use findPerformers instead") + allTags: [Tag!]! @deprecated(reason: "Use findTags instead") + allStudios: [Studio!]! @deprecated(reason: "Use findStudios instead") # Get everything with minimal metadata diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 8f439a98823..336385f290d 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -535,7 +535,7 @@ type ConfigResult { scraping: ConfigScrapingResult! defaults: ConfigDefaultSettingsResult! ui: Map! - plugins(include: [String!]): Map! + plugins(include: [ID!]): PluginConfigMap! } "Directory structure of a path" diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index fa646d345d2..44a748a1890 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -61,6 +61,19 @@ input ResolutionCriterionInput { modifier: CriterionModifier! } +enum OrientationEnum { + "Landscape" + LANDSCAPE + "Portrait" + PORTRAIT + "Square" + SQUARE +} + +input OrientationCriterionInput { + value: [OrientationEnum!]! +} + input PHashDuplicationCriterionInput { duplicated: Boolean "Currently unimplemented" @@ -212,6 +225,8 @@ input SceneFilterType { duplicated: PHashDuplicationCriterionInput "Filter by resolution" resolution: ResolutionCriterionInput + "Filter by orientation" + orientation: OrientationCriterionInput "Filter by frame rate" framerate: IntCriterionInput "Filter by video codec" @@ -316,6 +331,8 @@ input StudioFilterType { url: StringCriterionInput "Filter by studio aliases" aliases: StringCriterionInput + "Filter by subsidiary studio count" + child_count: IntCriterionInput "Filter by autotag ignore value" ignore_auto_tag: Boolean "Filter by creation time" @@ -465,6 +482,8 @@ input ImageFilterType { o_counter: IntCriterionInput "Filter by resolution" resolution: ResolutionCriterionInput + "Filter by orientation" + orientation: OrientationCriterionInput "Filter to only include images missing this property" is_missing: String "Filter to only include images with this studio" @@ -481,6 +500,8 @@ input ImageFilterType { performer_count: IntCriterionInput "Filter images that have performers that have been favorited" performer_favorite: Boolean + "Filter images by performer age at time of image" + performer_age: IntCriterionInput "Filter to only include images with these galleries" galleries: MultiCriterionInput "Filter by creation time" @@ -545,6 +566,7 @@ input MultiCriterionInput { input GenderCriterionInput { value: GenderEnum + value_list: [GenderEnum!] modifier: CriterionModifier! } diff --git a/graphql/schema/types/logging.graphql b/graphql/schema/types/logging.graphql index 4cfa2a64e32..4aef102667e 100644 --- a/graphql/schema/types/logging.graphql +++ b/graphql/schema/types/logging.graphql @@ -1,6 +1,3 @@ -"Log entries" -scalar Time - enum LogLevel { Trace Debug diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index d1cf3239353..8a6dcbbc068 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -1,5 +1,3 @@ -scalar Upload - input GenerateMetadataInput { covers: Boolean sprites: Boolean diff --git a/graphql/schema/types/scalars.graphql b/graphql/schema/types/scalars.graphql index 8f1d21551bf..5d8bf36c9b9 100644 --- a/graphql/schema/types/scalars.graphql +++ b/graphql/schema/types/scalars.graphql @@ -1,3 +1,6 @@ +"An RFC3339 timestamp" +scalar Time + """ Timestamp is a point in time. It is always output as RFC3339-compatible time points. It can be input as a RFC3339 string, or as "<4h" for "4 hours in the past" or ">5m" @@ -5,12 +8,18 @@ for "5 minutes in the future" """ scalar Timestamp -# generic JSON object +"A String -> Any map" scalar Map -# string, boolean map +"A String -> Boolean map" scalar BoolMap +"A plugin ID -> Map (String -> Any map) map" +scalar PluginConfigMap + scalar Any scalar Int64 + +"A multipart file upload" +scalar Upload diff --git a/internal/api/plugin_map.go b/internal/api/plugin_map.go new file mode 100644 index 00000000000..0e9f1e72d0d --- /dev/null +++ b/internal/api/plugin_map.go @@ -0,0 +1,37 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/99designs/gqlgen/graphql" +) + +func MarshalPluginConfigMap(val map[string]map[string]interface{}) graphql.Marshaler { + return graphql.WriterFunc(func(w io.Writer) { + err := json.NewEncoder(w).Encode(val) + if err != nil { + panic(err) + } + }) +} + +func UnmarshalPluginConfigMap(v interface{}) (map[string]map[string]interface{}, error) { + m, ok := v.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%T is not a plugin config map", v) + } + + result := make(map[string]map[string]interface{}) + for k, v := range m { + val, ok := v.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("key %s (%T) is not a map", k, v) + } + + result[k] = val + } + + return result, nil +} diff --git a/internal/api/resolver_model_config.go b/internal/api/resolver_model_config.go index a255699effb..d02583217f4 100644 --- a/internal/api/resolver_model_config.go +++ b/internal/api/resolver_model_config.go @@ -6,13 +6,13 @@ import ( "github.com/stashapp/stash/internal/manager/config" ) -func (r *configResultResolver) Plugins(ctx context.Context, obj *ConfigResult, include []string) (map[string]interface{}, error) { +func (r *configResultResolver) Plugins(ctx context.Context, obj *ConfigResult, include []string) (map[string]map[string]interface{}, error) { if len(include) == 0 { ret := config.GetInstance().GetAllPluginConfiguration() return ret, nil } - ret := make(map[string]interface{}) + ret := make(map[string]map[string]interface{}) for _, plugin := range include { c := config.GetInstance().GetPluginConfiguration(plugin) diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index 67f72262b67..b04d8dd90c5 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -560,9 +560,16 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput values = &v } + mgr := manager.GetInstance() + fileDeleter := &scene.FileDeleter{ + Deleter: file.NewDeleter(), + FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(), + Paths: mgr.Paths, + } + var ret *models.Scene if err := r.withTxn(ctx, func(ctx context.Context) error { - if err := r.Resolver.sceneService.Merge(ctx, srcIDs, destID, *values); err != nil { + if err := r.Resolver.sceneService.Merge(ctx, srcIDs, destID, *values, fileDeleter); err != nil { return err } diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index 21f75224a3c..c41efe9ff60 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -61,16 +61,10 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Studio - if err := studio.EnsureStudioNameUnique(ctx, 0, newStudio.Name, qb); err != nil { + if err := studio.ValidateCreate(ctx, newStudio, qb); err != nil { return err } - if len(input.Aliases) > 0 { - if err := studio.EnsureAliasesUnique(ctx, 0, input.Aliases, qb); err != nil { - return err - } - } - err = qb.Create(ctx, &newStudio) if err != nil { return err diff --git a/internal/api/resolver_query_find_gallery.go b/internal/api/resolver_query_find_gallery.go index 6474cc03ef1..724a48b1202 100644 --- a/internal/api/resolver_query_find_gallery.go +++ b/internal/api/resolver_query_find_gallery.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models.Gallery, err error) { @@ -23,9 +24,24 @@ func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models return ret, nil } -func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType) (ret *FindGalleriesResultType, err error) { +func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType, ids []string) (ret *FindGalleriesResultType, err error) { + idInts, err := stringslice.StringSliceToIntSlice(ids) + if err != nil { + return nil, err + } + if err := r.withReadTxn(ctx, func(ctx context.Context) error { - galleries, total, err := r.repository.Gallery.Query(ctx, galleryFilter, filter) + var galleries []*models.Gallery + var err error + var total int + + if len(idInts) > 0 { + galleries, err = r.repository.Gallery.FindMany(ctx, idInts) + total = len(galleries) + } else { + galleries, total, err = r.repository.Gallery.Query(ctx, galleryFilter, filter) + } + if err != nil { return err } diff --git a/internal/api/resolver_query_find_studio.go b/internal/api/resolver_query_find_studio.go index 51cac620859..84359295302 100644 --- a/internal/api/resolver_query_find_studio.go +++ b/internal/api/resolver_query_find_studio.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.Studio, err error) { @@ -24,9 +25,23 @@ func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models. return ret, nil } -func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType) (ret *FindStudiosResultType, err error) { +func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType, ids []string) (ret *FindStudiosResultType, err error) { + idInts, err := stringslice.StringSliceToIntSlice(ids) + if err != nil { + return nil, err + } + if err := r.withReadTxn(ctx, func(ctx context.Context) error { - studios, total, err := r.repository.Studio.Query(ctx, studioFilter, filter) + var studios []*models.Studio + var err error + var total int + + if len(idInts) > 0 { + studios, err = r.repository.Studio.FindMany(ctx, idInts) + total = len(studios) + } else { + studios, total, err = r.repository.Studio.Query(ctx, studioFilter, filter) + } if err != nil { return err } diff --git a/internal/api/resolver_query_find_tag.go b/internal/api/resolver_query_find_tag.go index fd4b04ad2c8..6ec93d21ac8 100644 --- a/internal/api/resolver_query_find_tag.go +++ b/internal/api/resolver_query_find_tag.go @@ -23,9 +23,19 @@ func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag return ret, nil } -func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType) (ret *FindTagsResultType, err error) { +func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType, ids []int) (ret *FindTagsResultType, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - tags, total, err := r.repository.Tag.Query(ctx, tagFilter, filter) + var tags []*models.Tag + var err error + var total int + + if len(ids) > 0 { + tags, err = r.repository.Tag.FindMany(ctx, ids) + total = len(tags) + } else { + tags, total, err = r.repository.Tag.Query(ctx, tagFilter, filter) + } + if err != nil { return err } diff --git a/internal/api/resolver_query_package.go b/internal/api/resolver_query_package.go index 021a53190ea..5a42221d476 100644 --- a/internal/api/resolver_query_package.go +++ b/internal/api/resolver_query_package.go @@ -3,6 +3,7 @@ package api import ( "context" "errors" + "fmt" "sort" "strings" @@ -26,6 +27,10 @@ func getPackageManager(typeArg PackageType) (*pkg.Manager, error) { return nil, ErrInvalidPackageType } + if pm == nil { + return nil, fmt.Errorf("%s package manager not initialized", typeArg) + } + return pm, nil } diff --git a/internal/identify/identify.go b/internal/identify/identify.go index 204d03129b1..144a58a48d7 100644 --- a/internal/identify/identify.go +++ b/internal/identify/identify.go @@ -392,7 +392,7 @@ func getScenePartial(scene *models.Scene, scraped *scraper.ScrapedScene, fieldOp switch getFieldStrategy(fieldOptions["url"]) { case FieldStrategyOverwrite: // only overwrite if not equal - if len(sliceutil.Exclude(scene.URLs.List(), scraped.URLs)) != 0 { + if len(sliceutil.Exclude(scraped.URLs, scene.URLs.List())) != 0 { partial.URLs = &models.UpdateStrings{ Values: scraped.URLs, Mode: models.RelationshipUpdateModeSet, diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index e0ce11c297d..c5c5d7afdef 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -260,6 +260,9 @@ const ( // File upload options MaxUploadSize = "max_upload_size" + + // Developer options + ExtraBlobsPaths = "developer_options.extra_blob_paths" ) // slice default values @@ -561,6 +564,12 @@ func (i *Config) GetBlobsPath() string { return i.getString(BlobsPath) } +// GetExtraBlobsPaths returns extra blobs paths. +// For developer/advanced use only. +func (i *Config) GetExtraBlobsPaths() []string { + return i.getStringSlice(ExtraBlobsPaths) +} + func (i *Config) GetBlobsStorage() BlobsStorageType { ret := BlobsStorageType(i.getString(BlobsStorage)) @@ -735,11 +744,11 @@ func (i *Config) GetPluginsPath() string { return i.getString(PluginsPath) } -func (i *Config) GetAllPluginConfiguration() map[string]interface{} { +func (i *Config) GetAllPluginConfiguration() map[string]map[string]interface{} { i.RLock() defer i.RUnlock() - ret := make(map[string]interface{}) + ret := make(map[string]map[string]interface{}) sub := i.viper(PluginsSetting).GetStringMap(PluginsSetting) if sub == nil { diff --git a/internal/manager/generator_interactive_heatmap_speed.go b/internal/manager/generator_interactive_heatmap_speed.go index 17f8c2a8a02..ac6ca53bdb9 100644 --- a/internal/manager/generator_interactive_heatmap_speed.go +++ b/internal/manager/generator_interactive_heatmap_speed.go @@ -44,9 +44,7 @@ type Action struct { // Pos is the place in percent to move to. Pos int `json:"pos"` - Slope float64 - Intensity int64 - Speed float64 + Speed float64 } type GradientTable []struct { @@ -136,8 +134,7 @@ func (funscript *Script) UpdateIntensityAndSpeed() { var t1, t2 int64 var p1, p2 int - var slope float64 - var intensity int64 + var intensity float64 for i := range funscript.Actions { if i == 0 { continue @@ -147,13 +144,10 @@ func (funscript *Script) UpdateIntensityAndSpeed() { p1 = funscript.Actions[i].Pos p2 = funscript.Actions[i-1].Pos - slope = math.Min(math.Max(1/(2*float64(t1-t2)/1000), 0), 20) - intensity = int64(slope * math.Abs((float64)(p1-p2))) - speed := math.Abs(float64(p1-p2)) / float64(t1-t2) * 1000 + speed := math.Abs(float64(p1 - p2)) + intensity = float64(speed/float64(t1-t2)) * 1000 - funscript.Actions[i].Slope = slope - funscript.Actions[i].Intensity = intensity - funscript.Actions[i].Speed = speed + funscript.Actions[i].Speed = intensity } } @@ -294,7 +288,7 @@ func (funscript Script) getGradientTable(numSegments int, sceneDurationMilli int } segments[segment].at = a.At segments[segment].count++ - segments[segment].intensity += int(a.Intensity) + segments[segment].intensity += int(a.Speed) segments[segment].yRange[0] = averageTop segments[segment].yRange[1] = averageBottom } @@ -303,7 +297,7 @@ func (funscript Script) getGradientTable(numSegments int, sceneDurationMilli int // Fill in gaps in segments for i := 0; i < numSegments; i++ { - segmentTS := int64(float64(i) / float64(numSegments)) + segmentTS := (maxts / int64(numSegments)) * int64(i) // Empty segment - fill it with the previous up to backfillThreshold ms if segments[i].count == 0 { @@ -340,12 +334,12 @@ func getSegmentColor(intensity float64) colorful.Color { colorBlack, _ := colorful.Hex("#0f001e") colorBackground, _ := colorful.Hex("#30404d") // Same as GridCard bg - var stepSize = 60.0 + var stepSize = 125.0 var f float64 var c colorful.Color switch { - case intensity <= 0.001: + case intensity <= 25: c = colorBackground case intensity <= 1*stepSize: f = (intensity - 0*stepSize) / stepSize diff --git a/internal/manager/init.go b/internal/manager/init.go index 87ea1681f9c..1dc8e301235 100644 --- a/internal/manager/init.go +++ b/internal/manager/init.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net/http" "os" "path/filepath" "strings" @@ -21,7 +20,6 @@ import ( "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models/paths" - "github.com/stashapp/stash/pkg/pkg" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scraper" @@ -102,9 +100,6 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) { scanSubs: &subscriptionManager{}, } - mgr.RefreshPluginSourceManager() - mgr.RefreshScraperSourceManager() - if !cfg.IsNewSystem() { logger.Infof("using config file: %s", cfg.GetConfigFile()) @@ -135,25 +130,6 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) { return mgr, nil } -func initialisePackageManager(localPath string, srcPathGetter pkg.SourcePathGetter) *pkg.Manager { - const timeout = 10 * time.Second - httpClient := &http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - }, - Timeout: timeout, - } - - return &pkg.Manager{ - Local: &pkg.Store{ - BaseDir: localPath, - ManifestFile: pkg.ManifestFile, - }, - PackagePathGetter: srcPathGetter, - Client: httpClient, - } -} - func formatDuration(t time.Duration) string { switch { case t >= time.Minute: // 1m23s or 2h45m12s @@ -208,7 +184,11 @@ func (s *Manager) postInit(ctx context.Context) error { s.PluginCache.RegisterSessionStore(s.SessionStore) s.RefreshPluginCache() + s.RefreshPluginSourceManager() + s.RefreshScraperCache() + s.RefreshScraperSourceManager() + s.RefreshStreamManager() s.RefreshDLNA() diff --git a/internal/manager/manager.go b/internal/manager/manager.go index d0942eb9be4..db88d45acd8 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "net/http" "os" "path/filepath" "runtime" + "time" "github.com/stashapp/stash/internal/dlna" "github.com/stashapp/stash/internal/log" @@ -73,11 +75,13 @@ func GetInstance() *Manager { func (s *Manager) SetBlobStoreOptions() { storageType := s.Config.GetBlobsStorage() blobsPath := s.Config.GetBlobsPath() + extraBlobsPaths := s.Config.GetExtraBlobsPaths() s.Database.SetBlobStoreOptions(sqlite.BlobStoreOptions{ - UseFilesystem: storageType == config.BlobStorageTypeFilesystem, - UseDatabase: storageType == config.BlobStorageTypeDatabase, - Path: blobsPath, + UseFilesystem: storageType == config.BlobStorageTypeFilesystem, + UseDatabase: storageType == config.BlobStorageTypeDatabase, + Path: blobsPath, + SupplementaryPaths: extraBlobsPaths, }) } @@ -145,12 +149,31 @@ func (s *Manager) RefreshDLNA() { } } +func createPackageManager(localPath string, srcPathGetter pkg.SourcePathGetter) *pkg.Manager { + const timeout = 10 * time.Second + httpClient := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + }, + Timeout: timeout, + } + + return &pkg.Manager{ + Local: &pkg.Store{ + BaseDir: localPath, + ManifestFile: pkg.ManifestFile, + }, + PackagePathGetter: srcPathGetter, + Client: httpClient, + } +} + func (s *Manager) RefreshScraperSourceManager() { - s.ScraperPackageManager = initialisePackageManager(s.Config.GetScrapersPath(), s.Config.GetScraperPackagePathGetter()) + s.ScraperPackageManager = createPackageManager(s.Config.GetScrapersPath(), s.Config.GetScraperPackagePathGetter()) } func (s *Manager) RefreshPluginSourceManager() { - s.PluginPackageManager = initialisePackageManager(s.Config.GetPluginsPath(), s.Config.GetPluginPackagePathGetter()) + s.PluginPackageManager = createPackageManager(s.Config.GetPluginsPath(), s.Config.GetPluginPackagePathGetter()) } func setSetupDefaults(input *SetupInput) { @@ -179,10 +202,6 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error { setSetupDefaults(&input) cfg := s.Config - if err := cfg.SetInitialConfig(); err != nil { - return fmt.Errorf("error setting initial configuration: %v", err) - } - // create the config directory if it does not exist // don't do anything if config is already set in the environment if !config.FileEnvSet() { @@ -207,6 +226,10 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error { s.Config.SetConfigFile(configFile) } + if err := cfg.SetInitialConfig(); err != nil { + return fmt.Errorf("error setting initial configuration: %v", err) + } + // create the generated directory if it does not exist if !cfg.HasOverride(config.Generated) { if exists, _ := fsutil.DirExists(input.GeneratedLocation); !exists { diff --git a/internal/manager/repository.go b/internal/manager/repository.go index fa0c865c683..4cec8b8fb85 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -11,7 +11,7 @@ import ( type SceneService interface { Create(ctx context.Context, input *models.Scene, fileIDs []models.FileID, coverImage []byte) (*models.Scene, error) AssignFile(ctx context.Context, sceneID int, fileID models.FileID) error - Merge(ctx context.Context, sourceIDs []int, destinationID int, values models.ScenePartial) error + Merge(ctx context.Context, sourceIDs []int, destinationID int, values models.ScenePartial, fileDeleter *scene.FileDeleter) error Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error } diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 9a7700ba7c2..298b58e279f 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -7,6 +7,7 @@ import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scraper/stashbox" "github.com/stashapp/stash/pkg/studio" ) @@ -155,6 +156,10 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m partial := p.ToPartial(t.box.Endpoint, excluded, existingStashIDs) + if err := performer.ValidateUpdate(ctx, t.performer.ID, partial, qb); err != nil { + return err + } + if _, err := qb.UpdatePartial(ctx, t.performer.ID, partial); err != nil { return err } @@ -185,6 +190,10 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m err = r.WithTxn(ctx, func(ctx context.Context) error { qb := r.Performer + if err := performer.ValidateCreate(ctx, *newPerformer, qb); err != nil { + return err + } + if err := qb.Create(ctx, newPerformer); err != nil { return err } @@ -346,6 +355,10 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode err = r.WithTxn(ctx, func(ctx context.Context) error { qb := r.Studio + if err := studio.ValidateCreate(ctx, *newStudio, qb); err != nil { + return err + } + if err := qb.Create(ctx, newStudio); err != nil { return err } diff --git a/pkg/file/folder.go b/pkg/file/folder.go index 02087dd4117..451bb1d936e 100644 --- a/pkg/file/folder.go +++ b/pkg/file/folder.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) @@ -46,9 +47,21 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat return folder, nil } -// TransferZipFolderHierarchy creates the folder hierarchy for zipFileID under newPath, and removes +func transferZipHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, files models.FileFinderUpdater, zipFileID models.FileID, oldPath string, newPath string) error { + if err := transferZipFolderHierarchy(ctx, folderStore, zipFileID, oldPath, newPath); err != nil { + return fmt.Errorf("moving folder hierarchy for file %s: %w", oldPath, err) + } + + if err := transferZipFileEntries(ctx, folderStore, files, zipFileID, oldPath, newPath); err != nil { + return fmt.Errorf("moving zip file contents for file %s: %w", oldPath, err) + } + + return nil +} + +// transferZipFolderHierarchy creates the folder hierarchy for zipFileID under newPath, and removes // ZipFileID from folders under oldPath. -func TransferZipFolderHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, zipFileID models.FileID, oldPath string, newPath string) error { +func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, zipFileID models.FileID, oldPath string, newPath string) error { zipFolders, err := folderStore.FindByZipFileID(ctx, zipFileID) if err != nil { return err @@ -74,12 +87,14 @@ func TransferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe } // add ZipFileID to new folder + logger.Debugf("adding zip file %s to folder %s", zipFileID, newFolder.Path) newFolder.ZipFileID = &zipFileID if err = folderStore.Update(ctx, newFolder); err != nil { return err } // remove ZipFileID from old folder + logger.Debugf("removing zip file %s from folder %s", zipFileID, oldFolder.Path) oldFolder.ZipFileID = nil if err = folderStore.Update(ctx, oldFolder); err != nil { return err @@ -88,3 +103,42 @@ func TransferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe return nil } + +func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCreator, files models.FileFinderUpdater, zipFileID models.FileID, oldPath, newPath string) error { + // move contained files if file is a zip file + zipFiles, err := files.FindByZipFileID(ctx, zipFileID) + if err != nil { + return fmt.Errorf("finding contained files in file %s: %w", oldPath, err) + } + for _, zf := range zipFiles { + zfBase := zf.Base() + oldZfPath := zfBase.Path + oldZfDir := filepath.Dir(oldZfPath) + + // sanity check - ignore files which aren't under oldPath + if !strings.HasPrefix(oldZfPath, oldPath) { + continue + } + + relZfDir, err := filepath.Rel(oldPath, oldZfDir) + if err != nil { + return fmt.Errorf("moving contained file %s: %w", zfBase.ID, err) + } + newZfDir := filepath.Join(newPath, relZfDir) + + // folder should have been created by transferZipFolderHierarchy + newZfFolder, err := GetOrCreateFolderHierarchy(ctx, folders, newZfDir) + if err != nil { + return fmt.Errorf("getting or creating folder hierarchy: %w", err) + } + + // update file parent folder + zfBase.ParentFolderID = newZfFolder.ID + logger.Debugf("moving %s to folder %s", zfBase.Path, newZfFolder.Path) + if err := files.Update(ctx, zf); err != nil { + return fmt.Errorf("updating file %s: %w", oldZfPath, err) + } + } + + return nil +} diff --git a/pkg/file/move.go b/pkg/file/move.go index a729fad7ce5..ba2a496bbc6 100644 --- a/pkg/file/move.go +++ b/pkg/file/move.go @@ -7,7 +7,6 @@ import ( "io/fs" "os" "path/filepath" - "strings" "time" "github.com/stashapp/stash/pkg/logger" @@ -88,44 +87,10 @@ func (m *Mover) Move(ctx context.Context, f models.File, folder *models.Folder, return fmt.Errorf("file %s already exists", newPath) } - if err := TransferZipFolderHierarchy(ctx, m.Folders, fBase.ID, oldPath, newPath); err != nil { + if err := transferZipHierarchy(ctx, m.Folders, m.Files, fBase.ID, oldPath, newPath); err != nil { return fmt.Errorf("moving folder hierarchy for file %s: %w", fBase.Path, err) } - // move contained files if file is a zip file - zipFiles, err := m.Files.FindByZipFileID(ctx, fBase.ID) - if err != nil { - return fmt.Errorf("finding contained files in file %s: %w", fBase.Path, err) - } - for _, zf := range zipFiles { - zfBase := zf.Base() - oldZfPath := zfBase.Path - oldZfDir := filepath.Dir(oldZfPath) - - // sanity check - ignore files which aren't under oldPath - if !strings.HasPrefix(oldZfPath, oldPath) { - continue - } - - relZfDir, err := filepath.Rel(oldPath, oldZfDir) - if err != nil { - return fmt.Errorf("moving contained file %s: %w", zfBase.ID, err) - } - newZfDir := filepath.Join(newPath, relZfDir) - - // folder should have been created by moveZipFolderHierarchy - newZfFolder, err := GetOrCreateFolderHierarchy(ctx, m.Folders, newZfDir) - if err != nil { - return fmt.Errorf("getting or creating folder hierarchy: %w", err) - } - - // update file parent folder - zfBase.ParentFolderID = newZfFolder.ID - if err := m.Files.Update(ctx, zf); err != nil { - return fmt.Errorf("updating file %s: %w", oldZfPath, err) - } - } - fBase.ParentFolderID = folder.ID fBase.Basename = basename fBase.UpdatedAt = time.Now() diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 731ae19b889..a9c20b518f6 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -653,6 +653,7 @@ func (s *scanJob) handleFile(ctx context.Context, f scanFile) error { } if ff == nil { + // returns a file only if it is actually new ff, err = s.onNewFile(ctx, f) return err } @@ -740,7 +741,10 @@ func (s *scanJob) onNewFile(ctx context.Context, f scanFile) (models.File, error } if renamed != nil { - return renamed, nil + // handle rename should have already handled the contents of the zip file + // so shouldn't need to scan it again + // return nil so it doesn't + return nil, nil } // if not renamed, queue file for creation @@ -901,8 +905,8 @@ func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.F } if s.isZipFile(fBase.Basename) { - if err := TransferZipFolderHierarchy(ctx, s.Repository.Folder, fBase.ID, otherBase.Path, fBase.Path); err != nil { - return fmt.Errorf("moving folder hierarchy for renamed zip file %q: %w", fBase.Path, err) + if err := transferZipHierarchy(ctx, s.Repository.Folder, s.Repository.File, fBase.ID, otherBase.Path, fBase.Path); err != nil { + return fmt.Errorf("moving zip hierarchy for renamed zip file %q: %w", fBase.Path, err) } } diff --git a/pkg/match/scraped.go b/pkg/match/scraped.go index 012cb749569..0b8a8d69615 100644 --- a/pkg/match/scraped.go +++ b/pkg/match/scraped.go @@ -44,22 +44,21 @@ func ScrapedPerformer(ctx context.Context, qb PerformerFinder, p *models.Scraped } performers, err := qb.FindByNames(ctx, []string{*p.Name}, true) - if err != nil { return err } - if performers == nil || len(performers) != 1 { - // try matching a single performer by exact alias + if len(performers) == 0 { + // if no names matched, try match an exact alias performers, err = performer.ByAlias(ctx, qb, *p.Name) if err != nil { return err } + } - if performers == nil || len(performers) != 1 { - // ignore - cannot match - return nil - } + if len(performers) != 1 { + // ignore - cannot match + return nil } id := strconv.Itoa(performers[0].ID) diff --git a/pkg/models/filter.go b/pkg/models/filter.go index e9ddf7ab366..1513b0bbea6 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -169,3 +169,7 @@ type PhashDistanceCriterionInput struct { Modifier CriterionModifier `json:"modifier"` Distance *int `json:"distance"` } + +type OrientationCriterionInput struct { + Value []OrientationEnum `json:"value"` +} diff --git a/pkg/models/image.go b/pkg/models/image.go index 8a8b5ba5047..8dca7399143 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -29,6 +29,8 @@ type ImageFilterType struct { OCounter *IntCriterionInput `json:"o_counter"` // Filter by resolution Resolution *ResolutionCriterionInput `json:"resolution"` + // Filter by landscape/portrait + Orientation *OrientationCriterionInput `json:"orientation"` // Filter to only include images missing this property IsMissing *string `json:"is_missing"` // Filter to only include images with this studio @@ -45,6 +47,8 @@ type ImageFilterType struct { PerformerCount *IntCriterionInput `json:"performer_count"` // Filter images that have performers that have been favorited PerformerFavorite *bool `json:"performer_favorite"` + // Filter images by performer age at time of image + PerformerAge *IntCriterionInput `json:"performer_age"` // Filter to only include images with these galleries Galleries *MultiCriterionInput `json:"galleries"` // Filter by created at diff --git a/pkg/models/orientation.go b/pkg/models/orientation.go new file mode 100644 index 00000000000..eeb9febd33e --- /dev/null +++ b/pkg/models/orientation.go @@ -0,0 +1,17 @@ +package models + +type OrientationEnum string + +const ( + OrientationLandscape OrientationEnum = "LANDSCAPE" + OrientationPortrait OrientationEnum = "PORTRAIT" + OrientationSquare OrientationEnum = "SQUARE" +) + +func (e OrientationEnum) IsValid() bool { + switch e { + case OrientationLandscape, OrientationPortrait, OrientationSquare: + return true + } + return false +} diff --git a/pkg/models/performer.go b/pkg/models/performer.go index f2bab92fd08..9449d611daa 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -56,8 +56,9 @@ func (e GenderEnum) MarshalGQL(w io.Writer) { } type GenderCriterionInput struct { - Value *GenderEnum `json:"value"` - Modifier CriterionModifier `json:"modifier"` + Value GenderEnum `json:"value"` + ValueList []GenderEnum `json:"value_list"` + Modifier CriterionModifier `json:"modifier"` } type CircumisedEnum string diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 748457a8440..d0be3016b54 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -39,6 +39,8 @@ type SceneFilterType struct { Duplicated *PHashDuplicationCriterionInput `json:"duplicated"` // Filter by resolution Resolution *ResolutionCriterionInput `json:"resolution"` + // Filter by orientation + Orientation *OrientationCriterionInput `json:"orientation"` // Filter by framerate Framerate *IntCriterionInput `json:"framerate"` // Filter by video codec diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 3b38cf40924..2a54077eef6 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -26,6 +26,8 @@ type StudioFilterType struct { URL *StringCriterionInput `json:"url"` // Filter by studio aliases Aliases *StringCriterionInput `json:"aliases"` + // Filter by subsidiary studio count + ChildCount *IntCriterionInput `json:"child_count"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` // Filter by created at diff --git a/pkg/plugin/config.go b/pkg/plugin/config.go index 497d6a87403..325ba56e8e3 100644 --- a/pkg/plugin/config.go +++ b/pkg/plugin/config.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "github.com/stashapp/stash/pkg/utils" @@ -206,7 +207,15 @@ func convertHooks(hooks []HookTriggerEnum) []string { func (c Config) getPluginSettings() []PluginSetting { ret := []PluginSetting{} - for k, o := range c.Settings { + var keys []string + for k := range c.Settings { + keys = append(keys, k) + } + + sort.Strings(keys) + + for _, k := range keys { + o := c.Settings[k] t := o.Type if t == "" { t = PluginSettingTypeEnumString @@ -248,6 +257,7 @@ func (c Config) toPlugin() *Plugin { ExternalCSS: c.UI.getExternalCSS(), Javascript: c.UI.getJavascriptFiles(c), CSS: c.UI.getCSSFiles(c), + CSP: c.UI.CSP, Assets: c.UI.Assets, }, Settings: c.getPluginSettings(), diff --git a/pkg/scene/merge.go b/pkg/scene/merge.go index 4d5a68c252f..4299331d198 100644 --- a/pkg/scene/merge.go +++ b/pkg/scene/merge.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" @@ -13,7 +14,13 @@ import ( "github.com/stashapp/stash/pkg/txn" ) -func (s *Service) Merge(ctx context.Context, sourceIDs []int, destinationID int, scenePartial models.ScenePartial) error { +func (s *Service) Merge( + ctx context.Context, + sourceIDs []int, + destinationID int, + scenePartial models.ScenePartial, + fileDeleter *FileDeleter, +) error { // ensure source ids are unique sourceIDs = sliceutil.AppendUniques(nil, sourceIDs) @@ -35,8 +42,6 @@ func (s *Service) Merge(ctx context.Context, sourceIDs []int, destinationID int, var fileIDs []models.FileID for _, src := range sources { - // TODO - delete generated files as needed - if err := src.LoadRelationships(ctx, s.Repository); err != nil { return fmt.Errorf("loading scene relationships from %d: %w", src.ID, err) } @@ -70,9 +75,11 @@ func (s *Service) Merge(ctx context.Context, sourceIDs []int, destinationID int, } // delete old scenes - for _, srcID := range sourceIDs { - if err := s.Repository.Destroy(ctx, srcID); err != nil { - return fmt.Errorf("deleting scene %d: %w", srcID, err) + for _, src := range sources { + const deleteGenerated = true + const deleteFile = false + if err := s.Destroy(ctx, src, fileDeleter, deleteGenerated, deleteFile); err != nil { + return fmt.Errorf("deleting scene %d: %w", src.ID, err) } } @@ -129,6 +136,12 @@ func (s *Service) mergeSceneMarkers(ctx context.Context, dest *models.Scene, src destExists, _ := fsutil.FileExists(e.dest) if srcExists && !destExists { + destDir := filepath.Dir(e.dest) + if err := fsutil.EnsureDir(destDir); err != nil { + logger.Errorf("Error creating generated marker folder %s: %v", destDir, err) + continue + } + if err := os.Rename(e.src, e.dest); err != nil { logger.Errorf("Error renaming generated marker file from %s to %s: %v", e.src, e.dest, err) } diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index c4f02b9823a..972153a50b1 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -24,6 +24,7 @@ import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper" "github.com/stashapp/stash/pkg/scraper/stashbox/graphql" + "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/utils" @@ -669,6 +670,12 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc } if len(p.Aliases) > 0 { + // #4437 - stash-box may return aliases that are equal to the performer name + // filter these out + p.Aliases = sliceutil.Filter(p.Aliases, func(s string) bool { + return !strings.EqualFold(s, p.Name) + }) + alias := strings.Join(p.Aliases, ", ") sp.Aliases = &alias } diff --git a/pkg/sqlite/blob.go b/pkg/sqlite/blob.go index 27ecd94ad1f..b933c4e8bef 100644 --- a/pkg/sqlite/blob.go +++ b/pkg/sqlite/blob.go @@ -31,6 +31,9 @@ type BlobStoreOptions struct { UseDatabase bool // Path is the filesystem path to use for storing blobs Path string + // SupplementaryPaths are alternative filesystem paths that will be used to find blobs + // No changes will be made to these filesystems + SupplementaryPaths []string } type BlobStore struct { @@ -39,11 +42,15 @@ type BlobStore struct { tableMgr *table fsStore *blob.FilesystemStore - options BlobStoreOptions + // supplementary stores + otherStores []blob.FilesystemReader + options BlobStoreOptions } func NewBlobStore(options BlobStoreOptions) *BlobStore { - return &BlobStore{ + fs := &file.OsFS{} + + ret := &BlobStore{ repository: repository{ tableName: blobTable, idColumn: blobChecksumColumn, @@ -51,9 +58,15 @@ func NewBlobStore(options BlobStoreOptions) *BlobStore { tableMgr: blobTableMgr, - fsStore: blob.NewFilesystemStore(options.Path, &file.OsFS{}), + fsStore: blob.NewFilesystemStore(options.Path, fs), options: options, } + + for _, otherPath := range options.SupplementaryPaths { + ret.otherStores = append(ret.otherStores, *blob.NewReadonlyFilesystemStore(otherPath, fs)) + } + + return ret } type blobRow struct { @@ -188,17 +201,36 @@ func (qb *BlobStore) readSQL(ctx context.Context, querySQL string, args ...inter // don't use the filesystem if not configured to do so if qb.options.UseFilesystem { - ret, err := qb.fsStore.Read(ctx, checksum) + ret, err := qb.readFromFilesystem(ctx, checksum) + if err != nil { + return nil, checksum, err + } + + return ret, checksum, nil + } + + return nil, checksum, &ChecksumBlobNotExistError{ + Checksum: checksum, + } +} + +func (qb *BlobStore) readFromFilesystem(ctx context.Context, checksum string) ([]byte, error) { + // try to read from primary store first, then supplementaries + fsStores := append([]blob.FilesystemReader{qb.fsStore.FilesystemReader}, qb.otherStores...) + + for _, fsStore := range fsStores { + ret, err := fsStore.Read(ctx, checksum) if err == nil { - return ret, checksum, nil + return ret, nil } if !errors.Is(err, fs.ErrNotExist) { - return nil, checksum, fmt.Errorf("reading from filesystem: %w", err) + return nil, fmt.Errorf("reading from filesystem: %w", err) } } - return nil, checksum, &ChecksumBlobNotExistError{ + // blob not found - should not happen + return nil, &ChecksumBlobNotExistError{ Checksum: checksum, } } @@ -228,14 +260,7 @@ func (qb *BlobStore) Read(ctx context.Context, checksum string) ([]byte, error) // don't use the filesystem if not configured to do so if qb.options.UseFilesystem { - ret, err := qb.fsStore.Read(ctx, checksum) - if err == nil { - return ret, nil - } - - if !errors.Is(err, fs.ErrNotExist) { - return nil, fmt.Errorf("reading from filesystem: %w", err) - } + return qb.readFromFilesystem(ctx, checksum) } // blob not found - should not happen diff --git a/pkg/sqlite/blob/fs.go b/pkg/sqlite/blob/fs.go index 9c85f926a19..34154f81886 100644 --- a/pkg/sqlite/blob/fs.go +++ b/pkg/sqlite/blob/fs.go @@ -19,19 +19,52 @@ const ( blobsDirLength int = 2 // thumbDirDepth * thumbDirLength must be smaller than the length of checksum ) -type FS interface { +type FSReader interface { + Open(name string) (fs.ReadDirFile, error) +} + +type FSWriter interface { Create(name string) (*os.File, error) MkdirAll(path string, perm fs.FileMode) error - Open(name string) (fs.ReadDirFile, error) + Remove(name string) error file.RenamerRemover } +type FS interface { + FSReader + FSWriter +} + +type FilesystemReader struct { + path string + fs FSReader +} + +func (s *FilesystemReader) checksumToPath(checksum string) string { + return filepath.Join(s.path, fsutil.GetIntraDir(checksum, blobsDirDepth, blobsDirLength), checksum) +} + +func (s *FilesystemReader) Read(ctx context.Context, checksum string) ([]byte, error) { + if s.path == "" { + return nil, fmt.Errorf("no path set") + } + + fn := s.checksumToPath(checksum) + f, err := s.fs.Open(fn) + if err != nil { + return nil, fmt.Errorf("opening file %q: %w", fn, err) + } + + defer f.Close() + + return io.ReadAll(f) +} + type FilesystemStore struct { + FilesystemReader deleter *file.Deleter - path string - fs FS } func NewFilesystemStore(path string, fs FS) *FilesystemStore { @@ -40,17 +73,24 @@ func NewFilesystemStore(path string, fs FS) *FilesystemStore { } return &FilesystemStore{ - deleter: deleter, - path: path, - fs: fs, + FilesystemReader: *NewReadonlyFilesystemStore(path, fs), + deleter: deleter, } } -func (s *FilesystemStore) checksumToPath(checksum string) string { - return filepath.Join(s.path, fsutil.GetIntraDir(checksum, blobsDirDepth, blobsDirLength), checksum) +func NewReadonlyFilesystemStore(path string, fs FSReader) *FilesystemReader { + return &FilesystemReader{ + path: path, + fs: fs, + } } func (s *FilesystemStore) Write(ctx context.Context, checksum string, data []byte) error { + fs, ok := s.fs.(FS) + if !ok { + return fmt.Errorf("internal error: fs is not an FS") + } + if s.path == "" { return fmt.Errorf("no path set") } @@ -58,12 +98,12 @@ func (s *FilesystemStore) Write(ctx context.Context, checksum string, data []byt fn := s.checksumToPath(checksum) // create the directory if it doesn't exist - if err := s.fs.MkdirAll(filepath.Dir(fn), 0755); err != nil { + if err := fs.MkdirAll(filepath.Dir(fn), 0755); err != nil { return fmt.Errorf("creating directory %q: %w", filepath.Dir(fn), err) } logger.Debugf("Writing blob file %s", fn) - out, err := s.fs.Create(fn) + out, err := fs.Create(fn) if err != nil { return fmt.Errorf("creating file %q: %w", fn, err) } @@ -77,22 +117,6 @@ func (s *FilesystemStore) Write(ctx context.Context, checksum string, data []byt return nil } -func (s *FilesystemStore) Read(ctx context.Context, checksum string) ([]byte, error) { - if s.path == "" { - return nil, fmt.Errorf("no path set") - } - - fn := s.checksumToPath(checksum) - f, err := s.fs.Open(fn) - if err != nil { - return nil, fmt.Errorf("opening file %q: %w", fn, err) - } - - defer f.Close() - - return io.ReadAll(f) -} - func (s *FilesystemStore) Delete(ctx context.Context, checksum string) error { if s.path == "" { return fmt.Errorf("no path set") diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go new file mode 100644 index 00000000000..5718947cbe8 --- /dev/null +++ b/pkg/sqlite/criterion_handlers.go @@ -0,0 +1,43 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +// shared criterion handlers go here + +func orientationCriterionHandler(orientation *models.OrientationCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if orientation != nil { + if addJoinFn != nil { + addJoinFn(f) + } + + var clauses []sqlClause + + for _, v := range orientation.Value { + // width mod height + mod := "" + switch v { + case models.OrientationPortrait: + mod = "<" + case models.OrientationLandscape: + mod = ">" + case models.OrientationSquare: + mod = "=" + } + + if mod != "" { + clauses = append(clauses, makeClause(fmt.Sprintf("%s %s %s", widthColumn, mod, heightColumn))) + } + } + + if len(clauses) > 0 { + f.whereClauses = append(f.whereClauses, orClauses(clauses...)) + } + } + } +} diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index 16a863ae793..352e6a41840 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -542,6 +542,9 @@ func getPathSearchClauseMany(pathColumn, basenameColumn, p string, addWildcards, func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { + if addJoinFn != nil { + addJoinFn(f) + } clause, args := getIntCriterionWhereClause(column, *c) f.addWhere(clause, args...) } @@ -551,6 +554,9 @@ func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn f func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { + if addJoinFn != nil { + addJoinFn(f) + } clause, args := getFloatCriterionWhereClause(column, *c) f.addWhere(clause, args...) } diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index bfc9e6dc6f2..0393d18eca2 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -709,6 +709,7 @@ func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageF query.handleCriterion(ctx, imageURLsCriterionHandler(imageFilter.URL)) query.handleCriterion(ctx, resolutionCriterionHandler(imageFilter.Resolution, "image_files.height", "image_files.width", qb.addImageFilesTable)) + query.handleCriterion(ctx, orientationCriterionHandler(imageFilter.Orientation, "image_files.height", "image_files.width", qb.addImageFilesTable)) query.handleCriterion(ctx, imageIsMissingCriterionHandler(qb, imageFilter.IsMissing)) query.handleCriterion(ctx, imageTagsCriterionHandler(qb, imageFilter.Tags)) @@ -719,6 +720,7 @@ func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageF query.handleCriterion(ctx, studioCriterionHandler(imageTable, imageFilter.Studios)) query.handleCriterion(ctx, imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags)) query.handleCriterion(ctx, imagePerformerFavoriteCriterionHandler(imageFilter.PerformerFavorite)) + query.handleCriterion(ctx, imagePerformerAgeCriterionHandler(imageFilter.PerformerAge)) query.handleCriterion(ctx, timestampCriterionHandler(imageFilter.CreatedAt, "images.created_at")) query.handleCriterion(ctx, timestampCriterionHandler(imageFilter.UpdatedAt, "images.updated_at")) @@ -1024,6 +1026,22 @@ GROUP BY performers_images.image_id HAVING SUM(performers.favorite) = 0)`, "nofa } } +func imagePerformerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerAge != nil { + f.addInnerJoin("performers_images", "", "images.id = performers_images.image_id") + f.addInnerJoin("performers", "", "performers_images.performer_id = performers.id") + + f.addWhere("images.date != '' AND performers.birthdate != ''") + f.addWhere("images.date IS NOT NULL AND performers.birthdate IS NOT NULL") + + ageCalc := "cast(strftime('%Y.%m%d', images.date) - strftime('%Y.%m%d', performers.birthdate) as int)" + whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) + f.addWhere(whereClause, args...) + } + } +} + func imagePerformerTagsCriterionHandler(qb *ImageStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { return &joinedPerformerTagsHandler{ criterion: tags, diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index a63529420d8..08192b0c535 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -600,7 +600,13 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if gender := filter.Gender; gender != nil { - f.addWhere(tableName+".gender = ?", gender.Value.String()) + genderCopy := *gender + if genderCopy.Value.IsValid() && len(genderCopy.ValueList) == 0 { + genderCopy.ValueList = []models.GenderEnum{genderCopy.Value} + } + + v := utils.StringerSliceToStringSlice(genderCopy.ValueList) + enumCriterionHandler(genderCopy.Modifier, v, tableName+".gender")(ctx, f) } })) diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index c32e76fdf5a..d003121bc9e 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -982,6 +982,7 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF query.handleCriterion(ctx, floatIntCriterionHandler(sceneFilter.Duration, "video_files.duration", qb.addVideoFilesTable)) query.handleCriterion(ctx, resolutionCriterionHandler(sceneFilter.Resolution, "video_files.height", "video_files.width", qb.addVideoFilesTable)) + query.handleCriterion(ctx, orientationCriterionHandler(sceneFilter.Orientation, "video_files.height", "video_files.width", qb.addVideoFilesTable)) query.handleCriterion(ctx, floatIntCriterionHandler(sceneFilter.Framerate, "ROUND(video_files.frame_rate)", qb.addVideoFilesTable)) query.handleCriterion(ctx, codecCriterionHandler(sceneFilter.VideoCodec, "video_files.video_codec", qb.addVideoFilesTable)) query.handleCriterion(ctx, codecCriterionHandler(sceneFilter.AudioCodec, "video_files.audio_codec", qb.addVideoFilesTable)) diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index e043be2a672..2e5b90195ca 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -539,7 +539,6 @@ func TestMain(m *testing.M) { // initialise empty config - needed by some migrations _ = config.InitializeEmpty() - ret := runTests(m) os.Exit(ret) } diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 15ccf2ba474..38c59edc66e 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -107,7 +107,7 @@ func getRandomSort(tableName string, direction string, seed uint64) string { } func getCountSort(primaryTable, joinTable, primaryFK, direction string) string { - return fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM %s WHERE %s = %s.id) %s", joinTable, primaryFK, primaryTable, getSortDirection(direction)) + return fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM %s AS sort WHERE sort.%s = %s.id) %s", joinTable, primaryFK, primaryTable, getSortDirection(direction)) } func getMultiSumSort(sum string, primaryTable, foreignTable1, joinTable1, foreignTable2, joinTable2, primaryFK, foreignFK1, foreignFK2, direction string) string { diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 51f65d97307..6df618ca1b1 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -517,6 +517,7 @@ func (qb *StudioStore) makeFilter(ctx context.Context, studioFilter *models.Stud query.handleCriterion(ctx, studioGalleryCountCriterionHandler(qb, studioFilter.GalleryCount)) query.handleCriterion(ctx, studioParentCriterionHandler(qb, studioFilter.Parents)) query.handleCriterion(ctx, studioAliasCriterionHandler(qb, studioFilter.Aliases)) + query.handleCriterion(ctx, studioChildCountCriterionHandler(qb, studioFilter.ChildCount)) query.handleCriterion(ctx, timestampCriterionHandler(studioFilter.CreatedAt, studioTable+".created_at")) query.handleCriterion(ctx, timestampCriterionHandler(studioFilter.UpdatedAt, studioTable+".updated_at")) @@ -649,6 +650,17 @@ func studioAliasCriterionHandler(qb *StudioStore, alias *models.StringCriterionI return h.handler(alias) } +func studioChildCountCriterionHandler(qb *StudioStore, childCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if childCount != nil { + f.addLeftJoin("studios", "children_count", "children_count.parent_id = studios.id") + clause, args := getIntCriterionWhereClause("count(distinct children_count.id)", *childCount) + + f.addHaving(clause, args...) + } + } +} + func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) string { var sort string var direction string @@ -668,6 +680,8 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) string { sortQuery += getCountSort(studioTable, imageTable, studioIDColumn, direction) case "galleries_count": sortQuery += getCountSort(studioTable, galleryTable, studioIDColumn, direction) + case "child_count": + sortQuery += getCountSort(studioTable, studioTable, studioParentIDColumn, direction) default: sortQuery += getSort(sort, direction, "studios") } diff --git a/pkg/studio/update.go b/pkg/studio/validate.go similarity index 80% rename from pkg/studio/update.go rename to pkg/studio/validate.go index 3125e674ea9..8a867635183 100644 --- a/pkg/studio/update.go +++ b/pkg/studio/validate.go @@ -9,6 +9,7 @@ import ( ) var ( + ErrNameMissing = errors.New("studio name must not be blank") ErrStudioOwnAncestor = errors.New("studio cannot be an ancestor of itself") ) @@ -70,6 +71,32 @@ func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb model return nil } +func ValidateCreate(ctx context.Context, studio models.Studio, qb models.StudioQueryer) error { + if err := validateName(ctx, 0, studio.Name, qb); err != nil { + return err + } + + if studio.Aliases.Loaded() && len(studio.Aliases.List()) > 0 { + if err := EnsureAliasesUnique(ctx, 0, studio.Aliases.List(), qb); err != nil { + return err + } + } + + return nil +} + +func validateName(ctx context.Context, studioID int, name string, qb models.StudioQueryer) error { + if name == "" { + return ErrNameMissing + } + + if err := EnsureStudioNameUnique(ctx, studioID, name, qb); err != nil { + return err + } + + return nil +} + type ValidateModifyReader interface { models.StudioGetter models.StudioQueryer @@ -110,7 +137,7 @@ func ValidateModify(ctx context.Context, s models.StudioPartial, qb ValidateModi } if s.Name.Set && s.Name.Value != existing.Name { - if err := EnsureStudioNameUnique(ctx, 0, s.Name.Value, qb); err != nil { + if err := validateName(ctx, s.ID, s.Name.Value, qb); err != nil { return err } } diff --git a/pkg/studio/validate_test.go b/pkg/studio/validate_test.go new file mode 100644 index 00000000000..6562dc5cad0 --- /dev/null +++ b/pkg/studio/validate_test.go @@ -0,0 +1,104 @@ +package studio + +import ( + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func nameFilter(n string) *models.StudioFilterType { + return &models.StudioFilterType{ + Name: &models.StringCriterionInput{ + Value: n, + Modifier: models.CriterionModifierEquals, + }, + } +} + +func TestValidateName(t *testing.T) { + db := mocks.NewDatabase() + + const ( + name1 = "name 1" + newName = "new name" + ) + + existing1 := models.Studio{ + ID: 1, + Name: name1, + } + + pp := 1 + findFilter := &models.FindFilterType{ + PerPage: &pp, + } + + db.Studio.On("Query", testCtx, nameFilter(name1), findFilter).Return([]*models.Studio{&existing1}, 1, nil) + db.Studio.On("Query", testCtx, mock.Anything, findFilter).Return(nil, 0, nil) + + tests := []struct { + tName string + name string + want error + }{ + {"missing name", "", ErrNameMissing}, + {"new name", newName, nil}, + {"existing name", name1, &NameExistsError{name1}}, + } + + for _, tt := range tests { + t.Run(tt.tName, func(t *testing.T) { + got := validateName(testCtx, 0, tt.name, db.Studio) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestValidateUpdateName(t *testing.T) { + db := mocks.NewDatabase() + + const ( + name1 = "name 1" + name2 = "name 2" + newName = "new name" + ) + + existing1 := models.Studio{ + ID: 1, + Name: name1, + } + existing2 := models.Studio{ + ID: 2, + Name: name2, + } + + pp := 1 + findFilter := &models.FindFilterType{ + PerPage: &pp, + } + + db.Studio.On("Query", testCtx, nameFilter(name1), findFilter).Return([]*models.Studio{&existing1}, 1, nil) + db.Studio.On("Query", testCtx, nameFilter(name2), findFilter).Return([]*models.Studio{&existing2}, 2, nil) + db.Studio.On("Query", testCtx, mock.Anything, findFilter).Return(nil, 0, nil) + + tests := []struct { + tName string + studio models.Studio + name string + want error + }{ + {"missing name", existing1, "", ErrNameMissing}, + {"same name", existing2, name2, nil}, + {"new name", existing1, newName, nil}, + } + + for _, tt := range tests { + t.Run(tt.tName, func(t *testing.T) { + got := validateName(testCtx, tt.studio.ID, tt.name, db.Studio) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/ui/login/login.html b/ui/login/login.html index 54dee83f71c..39882e0da13 100644 --- a/ui/login/login.html +++ b/ui/login/login.html @@ -6,6 +6,7 @@ Login + diff --git a/ui/v2.5/.eslintrc.json b/ui/v2.5/.eslintrc.json index b5951cdb5d8..ea45a4cd7cf 100644 --- a/ui/v2.5/.eslintrc.json +++ b/ui/v2.5/.eslintrc.json @@ -20,7 +20,7 @@ "version": "detect" } }, - "ignorePatterns": ["node_modules/", "src/core/generated-graphql.tsx"], + "ignorePatterns": ["node_modules/", "src/core/generated-graphql.ts"], "rules": { "@typescript-eslint/lines-between-class-members": "off", "@typescript-eslint/naming-convention": [ diff --git a/ui/v2.5/.gitignore b/ui/v2.5/.gitignore index baf52f4321a..40102e516ec 100755 --- a/ui/v2.5/.gitignore +++ b/ui/v2.5/.gitignore @@ -1,5 +1,5 @@ # generated -src/core/generated-*.tsx +src/core/generated-graphql.ts # dependencies /node_modules diff --git a/ui/v2.5/.prettierignore b/ui/v2.5/.prettierignore index aeb40cd1cc3..5ef8feb04a6 100644 --- a/ui/v2.5/.prettierignore +++ b/ui/v2.5/.prettierignore @@ -15,4 +15,4 @@ src/locales/**/*.json /build # generated -src/core/generated-graphql.tsx \ No newline at end of file +src/core/generated-graphql.ts diff --git a/ui/v2.5/codegen.ts b/ui/v2.5/codegen.ts new file mode 100644 index 00000000000..6ca49f6c909 --- /dev/null +++ b/ui/v2.5/codegen.ts @@ -0,0 +1,42 @@ +import type { CodegenConfig } from "@graphql-codegen/cli"; + +const config: CodegenConfig = { + schema: [ + "../../graphql/schema/**/*.graphql", + "graphql/client-schema.graphql", + ], + config: { + // makes conflicting fields override rather than error + onFieldTypeConflict: (_existing: unknown, other: unknown) => other, + }, + documents: "graphql/**/*.graphql", + generates: { + "src/core/generated-graphql.ts": { + plugins: [ + "time", + "typescript", + "typescript-operations", + "typescript-react-apollo", + ], + config: { + strictScalars: true, + scalars: { + Time: "string", + Timestamp: "string", + Map: "{ [key: string]: unknown }", + BoolMap: "{ [key: string]: boolean }", + PluginConfigMap: "{ [id: string]: { [key: string]: unknown } }", + Any: "unknown", + Int64: "number", + Upload: "File", + UIConfig: "src/core/config#IUIConfig", + SavedObjectFilter: "src/models/list-filter/types#SavedObjectFilter", + SavedUIOptions: "src/models/list-filter/types#SavedUIOptions", + }, + withRefetchFn: true, + }, + }, + }, +}; + +export default config; diff --git a/ui/v2.5/codegen.yml b/ui/v2.5/codegen.yml deleted file mode 100644 index 08b8c54fd5c..00000000000 --- a/ui/v2.5/codegen.yml +++ /dev/null @@ -1,12 +0,0 @@ -overwrite: true -schema: "../../graphql/schema/**/*.graphql" -documents: "../../graphql/documents/**/*.graphql" -generates: - src/core/generated-graphql.tsx: - plugins: - - time - - typescript - - typescript-operations - - typescript-react-apollo - config: - withRefetchFn: true diff --git a/ui/v2.5/graphql/client-schema.graphql b/ui/v2.5/graphql/client-schema.graphql new file mode 100644 index 00000000000..e16590172a0 --- /dev/null +++ b/ui/v2.5/graphql/client-schema.graphql @@ -0,0 +1,26 @@ +scalar UIConfig +scalar SavedObjectFilter +scalar SavedUIOptions + +extend type ConfigResult { + ui: UIConfig! +} + +extend type SavedFilter { + object_filter: SavedObjectFilter + ui_options: SavedUIOptions +} + +extend input SaveFilterInput { + object_filter: SavedObjectFilter + ui_options: SavedUIOptions +} + +extend input SetDefaultFilterInput { + object_filter: SavedObjectFilter + ui_options: SavedUIOptions +} + +extend type Mutation { + configureUI(input: Map!): UIConfig! +} diff --git a/graphql/documents/data/config.graphql b/ui/v2.5/graphql/data/config.graphql similarity index 100% rename from graphql/documents/data/config.graphql rename to ui/v2.5/graphql/data/config.graphql diff --git a/graphql/documents/data/file.graphql b/ui/v2.5/graphql/data/file.graphql similarity index 100% rename from graphql/documents/data/file.graphql rename to ui/v2.5/graphql/data/file.graphql diff --git a/graphql/documents/data/filter.graphql b/ui/v2.5/graphql/data/filter.graphql similarity index 100% rename from graphql/documents/data/filter.graphql rename to ui/v2.5/graphql/data/filter.graphql diff --git a/graphql/documents/data/gallery-chapter.graphql b/ui/v2.5/graphql/data/gallery-chapter.graphql similarity index 100% rename from graphql/documents/data/gallery-chapter.graphql rename to ui/v2.5/graphql/data/gallery-chapter.graphql diff --git a/graphql/documents/data/gallery-slim.graphql b/ui/v2.5/graphql/data/gallery-slim.graphql similarity index 100% rename from graphql/documents/data/gallery-slim.graphql rename to ui/v2.5/graphql/data/gallery-slim.graphql diff --git a/graphql/documents/data/gallery.graphql b/ui/v2.5/graphql/data/gallery.graphql similarity index 81% rename from graphql/documents/data/gallery.graphql rename to ui/v2.5/graphql/data/gallery.graphql index 5a97f77c512..6f25599b9bd 100644 --- a/graphql/documents/data/gallery.graphql +++ b/ui/v2.5/graphql/data/gallery.graphql @@ -38,3 +38,14 @@ fragment GalleryData on Gallery { ...SlimSceneData } } + +fragment SelectGalleryData on Gallery { + id + title + files { + path + } + folder { + path + } +} diff --git a/graphql/documents/data/image-slim.graphql b/ui/v2.5/graphql/data/image-slim.graphql similarity index 100% rename from graphql/documents/data/image-slim.graphql rename to ui/v2.5/graphql/data/image-slim.graphql diff --git a/graphql/documents/data/image.graphql b/ui/v2.5/graphql/data/image.graphql similarity index 100% rename from graphql/documents/data/image.graphql rename to ui/v2.5/graphql/data/image.graphql diff --git a/graphql/documents/data/job.graphql b/ui/v2.5/graphql/data/job.graphql similarity index 100% rename from graphql/documents/data/job.graphql rename to ui/v2.5/graphql/data/job.graphql diff --git a/graphql/documents/data/log.graphql b/ui/v2.5/graphql/data/log.graphql similarity index 100% rename from graphql/documents/data/log.graphql rename to ui/v2.5/graphql/data/log.graphql diff --git a/graphql/documents/data/movie-slim.graphql b/ui/v2.5/graphql/data/movie-slim.graphql similarity index 100% rename from graphql/documents/data/movie-slim.graphql rename to ui/v2.5/graphql/data/movie-slim.graphql diff --git a/graphql/documents/data/movie.graphql b/ui/v2.5/graphql/data/movie.graphql similarity index 100% rename from graphql/documents/data/movie.graphql rename to ui/v2.5/graphql/data/movie.graphql diff --git a/graphql/documents/data/package.graphql b/ui/v2.5/graphql/data/package.graphql similarity index 100% rename from graphql/documents/data/package.graphql rename to ui/v2.5/graphql/data/package.graphql diff --git a/graphql/documents/data/performer-slim.graphql b/ui/v2.5/graphql/data/performer-slim.graphql similarity index 100% rename from graphql/documents/data/performer-slim.graphql rename to ui/v2.5/graphql/data/performer-slim.graphql diff --git a/graphql/documents/data/performer.graphql b/ui/v2.5/graphql/data/performer.graphql similarity index 100% rename from graphql/documents/data/performer.graphql rename to ui/v2.5/graphql/data/performer.graphql diff --git a/graphql/documents/data/scene-marker.graphql b/ui/v2.5/graphql/data/scene-marker.graphql similarity index 100% rename from graphql/documents/data/scene-marker.graphql rename to ui/v2.5/graphql/data/scene-marker.graphql diff --git a/graphql/documents/data/scene-slim.graphql b/ui/v2.5/graphql/data/scene-slim.graphql similarity index 97% rename from graphql/documents/data/scene-slim.graphql rename to ui/v2.5/graphql/data/scene-slim.graphql index 09db76bb7a9..c24eb9752b7 100644 --- a/graphql/documents/data/scene-slim.graphql +++ b/ui/v2.5/graphql/data/scene-slim.graphql @@ -75,6 +75,7 @@ fragment SlimSceneData on Scene { performers { id name + disambiguation gender favorite image_path diff --git a/graphql/documents/data/scene.graphql b/ui/v2.5/graphql/data/scene.graphql similarity index 100% rename from graphql/documents/data/scene.graphql rename to ui/v2.5/graphql/data/scene.graphql diff --git a/graphql/documents/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql similarity index 100% rename from graphql/documents/data/scrapers.graphql rename to ui/v2.5/graphql/data/scrapers.graphql diff --git a/graphql/documents/data/studio-slim.graphql b/ui/v2.5/graphql/data/studio-slim.graphql similarity index 100% rename from graphql/documents/data/studio-slim.graphql rename to ui/v2.5/graphql/data/studio-slim.graphql diff --git a/graphql/documents/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql similarity index 82% rename from graphql/documents/data/studio.graphql rename to ui/v2.5/graphql/data/studio.graphql index 3badb9bf67e..f40b4a620fb 100644 --- a/graphql/documents/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -33,3 +33,16 @@ fragment StudioData on Studio { rating100 aliases } + +fragment SelectStudioData on Studio { + id + name + aliases + details + image_path + + parent_studio { + id + name + } +} diff --git a/graphql/documents/data/tag-slim.graphql b/ui/v2.5/graphql/data/tag-slim.graphql similarity index 100% rename from graphql/documents/data/tag-slim.graphql rename to ui/v2.5/graphql/data/tag-slim.graphql diff --git a/graphql/documents/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql similarity index 80% rename from graphql/documents/data/tag.graphql rename to ui/v2.5/graphql/data/tag.graphql index d5095fb3593..cfaa281374e 100644 --- a/graphql/documents/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -24,3 +24,16 @@ fragment TagData on Tag { ...SlimTagData } } + +fragment SelectTagData on Tag { + id + name + description + aliases + image_path + + parents { + id + name + } +} diff --git a/graphql/documents/mutations/config.graphql b/ui/v2.5/graphql/mutations/config.graphql similarity index 100% rename from graphql/documents/mutations/config.graphql rename to ui/v2.5/graphql/mutations/config.graphql diff --git a/graphql/documents/mutations/dlna.graphql b/ui/v2.5/graphql/mutations/dlna.graphql similarity index 100% rename from graphql/documents/mutations/dlna.graphql rename to ui/v2.5/graphql/mutations/dlna.graphql diff --git a/graphql/documents/mutations/file.graphql b/ui/v2.5/graphql/mutations/file.graphql similarity index 100% rename from graphql/documents/mutations/file.graphql rename to ui/v2.5/graphql/mutations/file.graphql diff --git a/graphql/documents/mutations/filter.graphql b/ui/v2.5/graphql/mutations/filter.graphql similarity index 100% rename from graphql/documents/mutations/filter.graphql rename to ui/v2.5/graphql/mutations/filter.graphql diff --git a/graphql/documents/mutations/gallery-chapter.graphql b/ui/v2.5/graphql/mutations/gallery-chapter.graphql similarity index 100% rename from graphql/documents/mutations/gallery-chapter.graphql rename to ui/v2.5/graphql/mutations/gallery-chapter.graphql diff --git a/graphql/documents/mutations/gallery.graphql b/ui/v2.5/graphql/mutations/gallery.graphql similarity index 100% rename from graphql/documents/mutations/gallery.graphql rename to ui/v2.5/graphql/mutations/gallery.graphql diff --git a/graphql/documents/mutations/image.graphql b/ui/v2.5/graphql/mutations/image.graphql similarity index 100% rename from graphql/documents/mutations/image.graphql rename to ui/v2.5/graphql/mutations/image.graphql diff --git a/graphql/documents/mutations/job.graphql b/ui/v2.5/graphql/mutations/job.graphql similarity index 100% rename from graphql/documents/mutations/job.graphql rename to ui/v2.5/graphql/mutations/job.graphql diff --git a/graphql/documents/mutations/metadata.graphql b/ui/v2.5/graphql/mutations/metadata.graphql similarity index 100% rename from graphql/documents/mutations/metadata.graphql rename to ui/v2.5/graphql/mutations/metadata.graphql diff --git a/graphql/documents/mutations/migration.graphql b/ui/v2.5/graphql/mutations/migration.graphql similarity index 100% rename from graphql/documents/mutations/migration.graphql rename to ui/v2.5/graphql/mutations/migration.graphql diff --git a/graphql/documents/mutations/movie.graphql b/ui/v2.5/graphql/mutations/movie.graphql similarity index 100% rename from graphql/documents/mutations/movie.graphql rename to ui/v2.5/graphql/mutations/movie.graphql diff --git a/graphql/documents/mutations/performer.graphql b/ui/v2.5/graphql/mutations/performer.graphql similarity index 100% rename from graphql/documents/mutations/performer.graphql rename to ui/v2.5/graphql/mutations/performer.graphql diff --git a/graphql/documents/mutations/plugins.graphql b/ui/v2.5/graphql/mutations/plugins.graphql similarity index 100% rename from graphql/documents/mutations/plugins.graphql rename to ui/v2.5/graphql/mutations/plugins.graphql diff --git a/graphql/documents/mutations/scene-marker.graphql b/ui/v2.5/graphql/mutations/scene-marker.graphql similarity index 100% rename from graphql/documents/mutations/scene-marker.graphql rename to ui/v2.5/graphql/mutations/scene-marker.graphql diff --git a/graphql/documents/mutations/scene.graphql b/ui/v2.5/graphql/mutations/scene.graphql similarity index 100% rename from graphql/documents/mutations/scene.graphql rename to ui/v2.5/graphql/mutations/scene.graphql diff --git a/graphql/documents/mutations/scrapers.graphql b/ui/v2.5/graphql/mutations/scrapers.graphql similarity index 100% rename from graphql/documents/mutations/scrapers.graphql rename to ui/v2.5/graphql/mutations/scrapers.graphql diff --git a/graphql/documents/mutations/stash-box.graphql b/ui/v2.5/graphql/mutations/stash-box.graphql similarity index 100% rename from graphql/documents/mutations/stash-box.graphql rename to ui/v2.5/graphql/mutations/stash-box.graphql diff --git a/graphql/documents/mutations/studio.graphql b/ui/v2.5/graphql/mutations/studio.graphql similarity index 100% rename from graphql/documents/mutations/studio.graphql rename to ui/v2.5/graphql/mutations/studio.graphql diff --git a/graphql/documents/mutations/tag.graphql b/ui/v2.5/graphql/mutations/tag.graphql similarity index 100% rename from graphql/documents/mutations/tag.graphql rename to ui/v2.5/graphql/mutations/tag.graphql diff --git a/graphql/documents/queries/dlna.graphql b/ui/v2.5/graphql/queries/dlna.graphql similarity index 100% rename from graphql/documents/queries/dlna.graphql rename to ui/v2.5/graphql/queries/dlna.graphql diff --git a/graphql/documents/queries/filter.graphql b/ui/v2.5/graphql/queries/filter.graphql similarity index 100% rename from graphql/documents/queries/filter.graphql rename to ui/v2.5/graphql/queries/filter.graphql diff --git a/graphql/documents/queries/gallery.graphql b/ui/v2.5/graphql/queries/gallery.graphql similarity index 53% rename from graphql/documents/queries/gallery.graphql rename to ui/v2.5/graphql/queries/gallery.graphql index 22eb7281d61..6c33b9910d9 100644 --- a/graphql/documents/queries/gallery.graphql +++ b/ui/v2.5/graphql/queries/gallery.graphql @@ -15,3 +15,16 @@ query FindGallery($id: ID!) { ...GalleryData } } + +query FindGalleriesForSelect( + $filter: FindFilterType + $gallery_filter: GalleryFilterType + $ids: [ID!] +) { + findGalleries(filter: $filter, gallery_filter: $gallery_filter, ids: $ids) { + count + galleries { + ...SelectGalleryData + } + } +} diff --git a/graphql/documents/queries/image.graphql b/ui/v2.5/graphql/queries/image.graphql similarity index 100% rename from graphql/documents/queries/image.graphql rename to ui/v2.5/graphql/queries/image.graphql diff --git a/graphql/documents/queries/job.graphql b/ui/v2.5/graphql/queries/job.graphql similarity index 100% rename from graphql/documents/queries/job.graphql rename to ui/v2.5/graphql/queries/job.graphql diff --git a/graphql/documents/queries/legacy.graphql b/ui/v2.5/graphql/queries/legacy.graphql similarity index 100% rename from graphql/documents/queries/legacy.graphql rename to ui/v2.5/graphql/queries/legacy.graphql diff --git a/graphql/documents/queries/misc.graphql b/ui/v2.5/graphql/queries/misc.graphql similarity index 81% rename from graphql/documents/queries/misc.graphql rename to ui/v2.5/graphql/queries/misc.graphql index 61354be534d..1730585c651 100644 --- a/graphql/documents/queries/misc.graphql +++ b/ui/v2.5/graphql/queries/misc.graphql @@ -6,14 +6,6 @@ query MarkerStrings($q: String, $sort: String) { } } -query AllStudiosForFilter { - allStudios { - id - name - aliases - } -} - query AllMoviesForFilter { allMovies { id @@ -21,14 +13,6 @@ query AllMoviesForFilter { } } -query AllTagsForFilter { - allTags { - id - name - aliases - } -} - query Stats { stats { scene_count diff --git a/graphql/documents/queries/movie.graphql b/ui/v2.5/graphql/queries/movie.graphql similarity index 100% rename from graphql/documents/queries/movie.graphql rename to ui/v2.5/graphql/queries/movie.graphql diff --git a/graphql/documents/queries/performer.graphql b/ui/v2.5/graphql/queries/performer.graphql similarity index 100% rename from graphql/documents/queries/performer.graphql rename to ui/v2.5/graphql/queries/performer.graphql diff --git a/graphql/documents/queries/plugins.graphql b/ui/v2.5/graphql/queries/plugins.graphql similarity index 100% rename from graphql/documents/queries/plugins.graphql rename to ui/v2.5/graphql/queries/plugins.graphql diff --git a/graphql/documents/queries/scene-marker.graphql b/ui/v2.5/graphql/queries/scene-marker.graphql similarity index 100% rename from graphql/documents/queries/scene-marker.graphql rename to ui/v2.5/graphql/queries/scene-marker.graphql diff --git a/graphql/documents/queries/scene.graphql b/ui/v2.5/graphql/queries/scene.graphql similarity index 100% rename from graphql/documents/queries/scene.graphql rename to ui/v2.5/graphql/queries/scene.graphql diff --git a/graphql/documents/queries/scrapers/scrapers.graphql b/ui/v2.5/graphql/queries/scrapers/scrapers.graphql similarity index 100% rename from graphql/documents/queries/scrapers/scrapers.graphql rename to ui/v2.5/graphql/queries/scrapers/scrapers.graphql diff --git a/graphql/documents/queries/settings/config.graphql b/ui/v2.5/graphql/queries/settings/config.graphql similarity index 100% rename from graphql/documents/queries/settings/config.graphql rename to ui/v2.5/graphql/queries/settings/config.graphql diff --git a/graphql/documents/queries/settings/metadata.graphql b/ui/v2.5/graphql/queries/settings/metadata.graphql similarity index 100% rename from graphql/documents/queries/settings/metadata.graphql rename to ui/v2.5/graphql/queries/settings/metadata.graphql diff --git a/graphql/documents/queries/studio.graphql b/ui/v2.5/graphql/queries/studio.graphql similarity index 53% rename from graphql/documents/queries/studio.graphql rename to ui/v2.5/graphql/queries/studio.graphql index 592e0ac2b31..8ed45dc6a51 100644 --- a/graphql/documents/queries/studio.graphql +++ b/ui/v2.5/graphql/queries/studio.graphql @@ -12,3 +12,16 @@ query FindStudio($id: ID!) { ...StudioData } } + +query FindStudiosForSelect( + $filter: FindFilterType + $studio_filter: StudioFilterType + $ids: [ID!] +) { + findStudios(filter: $filter, studio_filter: $studio_filter, ids: $ids) { + count + studios { + ...SelectStudioData + } + } +} diff --git a/graphql/documents/queries/tag.graphql b/ui/v2.5/graphql/queries/tag.graphql similarity index 52% rename from graphql/documents/queries/tag.graphql rename to ui/v2.5/graphql/queries/tag.graphql index bb69d4515fc..aec1ef8bdfe 100644 --- a/graphql/documents/queries/tag.graphql +++ b/ui/v2.5/graphql/queries/tag.graphql @@ -12,3 +12,16 @@ query FindTag($id: ID!) { ...TagData } } + +query FindTagsForSelect( + $filter: FindFilterType + $tag_filter: TagFilterType + $ids: [Int!] +) { + findTags(filter: $filter, tag_filter: $tag_filter, ids: $ids) { + count + tags { + ...SelectTagData + } + } +} diff --git a/graphql/documents/subscriptions.graphql b/ui/v2.5/graphql/subscriptions.graphql similarity index 100% rename from graphql/documents/subscriptions.graphql rename to ui/v2.5/graphql/subscriptions.graphql diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 31b49ad355d..eba8bd1dc17 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -14,12 +14,12 @@ "check": "tsc --noEmit", "format": "prettier --write . ../../graphql", "format-check": "prettier --check . ../../graphql", - "gqlgen": "gql-gen --config codegen.yml", + "gqlgen": "gql-gen --config codegen.ts", "extract": "NODE_ENV=development extract-messages -l=en,de -o src/locale -d en --flat false 'src/**/!(*.test).tsx'" }, "dependencies": { "@ant-design/react-slick": "^1.0.0", - "@apollo/client": "^3.7.17", + "@apollo/client": "^3.8.10", "@formatjs/intl-getcanonicallocales": "^2.0.5", "@formatjs/intl-locale": "^3.0.11", "@formatjs/intl-numberformat": "^8.3.3", @@ -31,7 +31,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@silvermine/videojs-airplay": "^1.2.0", "@silvermine/videojs-chromecast": "^1.4.1", - "apollo-upload-client": "^17.0.0", + "apollo-upload-client": "^18.0.1", "base64-blob": "^1.4.1", "bootstrap": "^4.6.2", "classnames": "^2.3.2", @@ -40,7 +40,7 @@ "formik": "^2.4.5", "graphql": "^16.8.1", "graphql-tag": "^2.12.6", - "graphql-ws": "^5.11.3", + "graphql-ws": "^5.14.3", "i18n-iso-countries": "^7.5.0", "intersection-observer": "^0.12.2", "localforage": "^1.10.0", @@ -78,12 +78,12 @@ }, "devDependencies": { "@babel/core": "^7.20.12", - "@graphql-codegen/cli": "^3.0.0", - "@graphql-codegen/time": "^4.0.0", - "@graphql-codegen/typescript": "^3.0.0", - "@graphql-codegen/typescript-operations": "^3.0.0", - "@graphql-codegen/typescript-react-apollo": "^3.3.7", - "@types/apollo-upload-client": "^17.0.2", + "@graphql-codegen/cli": "^5.0.0", + "@graphql-codegen/time": "^5.0.0", + "@graphql-codegen/typescript": "^4.0.1", + "@graphql-codegen/typescript-operations": "^4.0.1", + "@graphql-codegen/typescript-react-apollo": "^4.1.0", + "@types/apollo-upload-client": "^18.0.0", "@types/lodash-es": "^4.17.6", "@types/mousetrap": "^1.6.11", "@types/node": "^18.13.0", @@ -120,7 +120,7 @@ "terser": "^5.9.0", "ts-node": "^10.9.1", "typescript": "~4.8.4", - "vite": "^4.1.5", + "vite": "^4.5.2", "vite-plugin-compression": "^0.5.1", "vite-tsconfig-paths": "^4.0.5" } diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index ac98d8b74b9..0d738911ec0 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -36,7 +36,6 @@ import { ConfigurationProvider } from "./hooks/Config"; import { ManualProvider } from "./components/Help/context"; import { InteractiveProvider } from "./hooks/Interactive/context"; import { ReleaseNotesDialog } from "./components/Dialogs/ReleaseNotesDialog"; -import { IUIConfig } from "./core/config"; import { releaseNotes } from "./docs/en/ReleaseNotes"; import { getPlatformURL } from "./core/createClient"; import { lazyComponent } from "./utils/lazyComponent"; @@ -324,8 +323,7 @@ export const App: React.FC = () => { return; } - const lastNoteSeen = (config.data?.configuration.ui as IUIConfig) - ?.lastNoteSeen; + const lastNoteSeen = config.data?.configuration.ui.lastNoteSeen; const notes = releaseNotes.filter((n) => { return !lastNoteSeen || n.date > lastNoteSeen; }); diff --git a/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx b/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx index 71be7affed6..186d64e2e33 100644 --- a/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx +++ b/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx @@ -12,6 +12,7 @@ import { withoutTypename } from "src/utils/data"; import { GenerateOptions } from "../Settings/Tasks/GenerateOptions"; import { SettingSection } from "../Settings/SettingSection"; import { faCogs, faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { SettingsContext } from "../Settings/context"; interface ISceneGenerateDialog { selectedIds?: string[]; @@ -196,13 +197,15 @@ export const GenerateDialog: React.FC = ({ >
{selectionStatus} - - - + + + + +
); diff --git a/ui/v2.5/src/components/Dialogs/SubmitDraft.tsx b/ui/v2.5/src/components/Dialogs/SubmitDraft.tsx index 58740eb47d0..03d41063138 100644 --- a/ui/v2.5/src/components/Dialogs/SubmitDraft.tsx +++ b/ui/v2.5/src/components/Dialogs/SubmitDraft.tsx @@ -9,6 +9,7 @@ import { ModalComponent } from "src/components/Shared/Modal"; import { getStashboxBase } from "src/utils/stashbox"; import { FormattedMessage, useIntl } from "react-intl"; import { faPaperPlane } from "@fortawesome/free-solid-svg-icons"; +import { ExternalLink } from "../Shared/ExternalLink"; interface IProps { type: "scene" | "performer"; @@ -108,12 +109,12 @@ export const SubmitStashBoxDraft: React.FC = ({
- + - +
); diff --git a/ui/v2.5/src/components/FrontPage/FrontPage.tsx b/ui/v2.5/src/components/FrontPage/FrontPage.tsx index 7a106f330b4..d52f65db6f5 100644 --- a/ui/v2.5/src/components/FrontPage/FrontPage.tsx +++ b/ui/v2.5/src/components/FrontPage/FrontPage.tsx @@ -11,7 +11,6 @@ import { FrontPageContent, generateDefaultFrontPageContent, getFrontPageContent, - IUIConfig, } from "src/core/config"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; @@ -59,7 +58,7 @@ const FrontPage: React.FC = () => { return onUpdateConfig(content)} />; } - const ui = (configuration?.ui ?? {}) as IUIConfig; + const ui = configuration?.ui ?? {}; if (!ui.frontPageContent) { const defaultContent = generateDefaultFrontPageContent(intl); diff --git a/ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx b/ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx index eee1342ffeb..04975166e98 100644 --- a/ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx +++ b/ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx @@ -3,14 +3,9 @@ import { FormattedMessage, IntlShape, useIntl } from "react-intl"; import { useFindSavedFilters } from "src/core/StashService"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { Button, Form, Modal } from "react-bootstrap"; -import { - FilterMode, - FindSavedFiltersQuery, - SavedFilter, -} from "src/core/generated-graphql"; +import * as GQL from "src/core/generated-graphql"; import { ConfigurationContext } from "src/hooks/Config"; import { - IUIConfig, ISavedFilterRow, ICustomFilter, FrontPageContent, @@ -21,24 +16,25 @@ import { interface IAddSavedFilterModalProps { onClose: (content?: FrontPageContent) => void; existingSavedFilterIDs: string[]; - candidates: FindSavedFiltersQuery; + candidates: GQL.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", + [GQL.FilterMode.Galleries]: "galleries", + [GQL.FilterMode.Images]: "images", + [GQL.FilterMode.Movies]: "movies", + [GQL.FilterMode.Performers]: "performers", + [GQL.FilterMode.SceneMarkers]: "markers", + [GQL.FilterMode.Scenes]: "scenes", + [GQL.FilterMode.Studios]: "studios", + [GQL.FilterMode.Tags]: "tags", }; -function filterTitle(intl: IntlShape, f: Pick) { - return `${intl.formatMessage({ id: FilterModeToMessageID[f.mode] })}: ${ - f.name - }`; +type SavedFilter = Pick; + +function filterTitle(intl: IntlShape, f: SavedFilter) { + const typeMessage = intl.formatMessage({ id: FilterModeToMessageID[f.mode] }); + return `${typeMessage}: ${f.name}`; } const AddContentModal: React.FC = ({ @@ -98,7 +94,7 @@ const AddContentModal: React.FC = ({ .filter((f) => { // markers not currently supported return ( - f.mode !== FilterMode.SceneMarkers && + f.mode !== GQL.FilterMode.SceneMarkers && !existingSavedFilterIDs.includes(f.id) ); }) @@ -232,7 +228,7 @@ const AddContentModal: React.FC = ({ interface IFilterRowProps { content: FrontPageContent; - allSavedFilters: Pick[]; + allSavedFilters: SavedFilter[]; onDelete: () => void; } @@ -242,10 +238,9 @@ const ContentRow: React.FC = (props: IFilterRowProps) => { function title() { switch (props.content.__typename) { case "SavedFilter": + const savedFilterId = String(props.content.savedFilterId); const savedFilter = props.allSavedFilters.find( - (f) => - f.id === - (props.content as ISavedFilterRow).savedFilterId?.toString() + (f) => f.id === savedFilterId ); if (!savedFilter) return ""; return filterTitle(intl, savedFilter); @@ -287,7 +282,7 @@ export const FrontPageConfig: React.FC = ({ }) => { const { configuration, loading } = React.useContext(ConfigurationContext); - const ui = configuration?.ui as IUIConfig; + const ui = configuration?.ui; const { data: allFilters, loading: loading2 } = useFindSavedFilters(); @@ -302,7 +297,18 @@ export const FrontPageConfig: React.FC = ({ const frontPageContent = getFrontPageContent(ui); if (frontPageContent) { - setCurrentContent(frontPageContent); + setCurrentContent( + // filter out rows where the saved filter no longer exists + frontPageContent.filter((r) => { + if (r.__typename === "SavedFilter") { + const savedFilterId = String(r.savedFilterId); + return allFilters.findSavedFilters.some( + (f) => f.id === savedFilterId + ); + } + return true; + }) + ); } }, [allFilters, ui]); diff --git a/ui/v2.5/src/components/FrontPage/styles.scss b/ui/v2.5/src/components/FrontPage/styles.scss index e4049b5aa58..cb03087dde4 100644 --- a/ui/v2.5/src/components/FrontPage/styles.scss +++ b/ui/v2.5/src/components/FrontPage/styles.scss @@ -319,10 +319,6 @@ .slick-list .performer-card.card { width: 16rem; } - - .slick-list .performer-card-image { - height: 24rem; - } } /* Icons */ diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index 8dd49534195..0c7491dc6f5 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -1,8 +1,7 @@ import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap"; -import React from "react"; -import { Link } from "react-router-dom"; +import React, { useEffect, useState } from "react"; import * as GQL from "src/core/generated-graphql"; -import { GridCard } from "../Shared/GridCard"; +import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { SceneLink, TagLink } from "../Shared/TagLink"; @@ -10,13 +9,15 @@ import { TruncatedText } from "../Shared/TruncatedText"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import NavUtils from "src/utils/navigation"; -import { ConfigurationContext } from "src/hooks/Config"; import { RatingBanner } from "../Shared/RatingBanner"; import { faBox, faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons"; import { galleryTitle } from "src/core/galleries"; +import ScreenUtils from "src/utils/screen"; +import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; interface IProps { gallery: GQL.SlimGalleryDataFragment; + containerWidth?: number; selecting?: boolean; selected?: boolean | undefined; zoomIndex?: number; @@ -24,8 +25,37 @@ interface IProps { } export const GalleryCard: React.FC = (props) => { - const { configuration } = React.useContext(ConfigurationContext); - const showStudioAsText = configuration?.interface.showStudioAsText ?? false; + const [cardWidth, setCardWidth] = useState(); + + useEffect(() => { + if ( + !props.containerWidth || + props.zoomIndex === undefined || + ScreenUtils.isMobile() + ) + return; + + let zoomValue = props.zoomIndex; + let preferredCardWidth: number; + switch (zoomValue) { + case 0: + preferredCardWidth = 240; + break; + case 1: + preferredCardWidth = 340; + break; + case 2: + preferredCardWidth = 480; + break; + case 3: + preferredCardWidth = 640; + } + let fittedCardWidth = calculateCardWidth( + props.containerWidth, + preferredCardWidth! + ); + setCardWidth(fittedCardWidth); + }, [props, props.containerWidth, props.zoomIndex]); function maybeRenderScenePopoverButton() { if (props.gallery.scenes.length === 0) return; @@ -88,27 +118,6 @@ export const GalleryCard: React.FC = (props) => { ); } - function maybeRenderSceneStudioOverlay() { - if (!props.gallery.studio) return; - - return ( -
- - {showStudioAsText ? ( - props.gallery.studio.name - ) : ( - {props.gallery.studio.name} - )} - -
- ); - } - function maybeRenderOrganized() { if (props.gallery.organized) { return ( @@ -153,6 +162,7 @@ export const GalleryCard: React.FC = (props) => { = (props) => { } - overlays={maybeRenderSceneStudioOverlay()} + overlays={} details={
{props.gallery.date} diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 291aae16b16..e827f80a548 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -264,7 +264,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { isVisible={activeTabKey === "gallery-chapter-panel"} /> - + ; @@ -68,6 +66,8 @@ export const GalleryEditPanel: React.FC = ({ ); const [performers, setPerformers] = useState([]); + const [tags, setTags] = useState([]); + const [studio, setStudio] = useState(null); const isNew = gallery.id === undefined; const { configuration: stashConfig } = React.useContext(ConfigurationContext); @@ -146,9 +146,22 @@ export const GalleryEditPanel: React.FC = ({ ); } + function onSetTags(items: Tag[]) { + setTags(items); + formik.setFieldValue( + "tag_ids", + items.map((item) => item.id) + ); + } + + function onSetStudio(item: Studio | null) { + setStudio(item); + formik.setFieldValue("studio_id", item ? item.id : null); + } + useRatingKeybinds( isVisible, - stashConfig?.ui?.ratingSystemOptions?.type, + stashConfig?.ui.ratingSystemOptions?.type, setRating ); @@ -156,6 +169,14 @@ export const GalleryEditPanel: React.FC = ({ setPerformers(gallery.performers ?? []); }, [gallery.performers]); + useEffect(() => { + setTags(gallery.tags ?? []); + }, [gallery.tags]); + + useEffect(() => { + setStudio(gallery.studio ?? null); + }, [gallery.studio]); + useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { @@ -242,6 +263,8 @@ export const GalleryEditPanel: React.FC = ({ return ( { @@ -313,7 +336,11 @@ export const GalleryEditPanel: React.FC = ({ } if (galleryData.studio?.stored_id) { - formik.setFieldValue("studio_id", galleryData.studio.stored_id); + onSetStudio({ + id: galleryData.studio.stored_id, + name: galleryData.studio.name ?? "", + aliases: [], + }); } if (galleryData.performers?.length) { @@ -340,8 +367,15 @@ export const GalleryEditPanel: React.FC = ({ }); if (idTags.length > 0) { - const newIds = idTags.map((t) => t.stored_id); - formik.setFieldValue("tag_ids", newIds as string[]); + onSetTags( + idTags.map((p) => { + return { + id: p.stored_id!, + name: p.name ?? "", + aliases: [], + }; + }) + ); } } } @@ -411,13 +445,8 @@ export const GalleryEditPanel: React.FC = ({ const title = intl.formatMessage({ id: "studio" }); const control = ( - formik.setFieldValue( - "studio_id", - items.length > 0 ? items[0]?.id : null - ) - } - ids={formik.values.studio_id ? [formik.values.studio_id] : []} + onSelect={(items) => onSetStudio(items.length > 0 ? items[0] : null)} + values={studio ? [studio] : []} /> ); @@ -438,13 +467,8 @@ export const GalleryEditPanel: React.FC = ({ const control = ( - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ) - } - ids={formik.values.tag_ids} + onSelect={onSetTags} + values={tags} hoverPlacement="right" /> ); diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx index c9c80781695..1daa2f5e756 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx @@ -7,9 +7,9 @@ import { ScrapedStringListRow, ScrapedTextAreaRow, } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; -import clone from "lodash-es/clone"; import { ObjectListScrapeResult, + ObjectScrapeResult, ScrapeResult, } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { @@ -25,21 +25,23 @@ import { useCreateScrapedTag, } from "src/components/Shared/ScrapeDialog/createObjects"; import { uniq } from "lodash-es"; +import { Tag } from "src/components/Tags/TagSelect"; +import { Studio } from "src/components/Studios/StudioSelect"; interface IGalleryScrapeDialogProps { gallery: Partial; + galleryStudio: Studio | null; + galleryTags: Tag[]; galleryPerformers: Performer[]; scraped: GQL.ScrapedGallery; onClose: (scrapedGallery?: GQL.ScrapedGallery) => void; } -interface IHasStoredID { - stored_id?: string | null; -} - export const GalleryScrapeDialog: React.FC = ({ gallery, + galleryStudio, + galleryTags, galleryPerformers, scraped, onClose, @@ -65,51 +67,21 @@ export const GalleryScrapeDialog: React.FC = ({ const [photographer, setPhotographer] = useState>( new ScrapeResult(gallery.photographer, scraped.photographer) ); - const [studio, setStudio] = useState>( - new ScrapeResult(gallery.studio_id, scraped.studio?.stored_id) + const [studio, setStudio] = useState>( + new ObjectScrapeResult( + galleryStudio + ? { + stored_id: galleryStudio.id, + name: galleryStudio.name, + } + : undefined, + scraped.studio + ) ); const [newStudio, setNewStudio] = useState( scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined ); - function mapStoredIdObjects( - scrapedObjects?: IHasStoredID[] - ): string[] | undefined { - if (!scrapedObjects) { - return undefined; - } - const ret = scrapedObjects - .map((p) => p.stored_id) - .filter((p) => { - return p !== undefined && p !== null; - }) as string[]; - - if (ret.length === 0) { - return undefined; - } - - // sort by id numerically - ret.sort((a, b) => { - return parseInt(a, 10) - parseInt(b, 10); - }); - - return ret; - } - - function sortIdList(idList?: string[] | null) { - if (!idList) { - return; - } - - const ret = clone(idList); - // sort by id numerically - ret.sort((a, b) => { - return parseInt(a, 10) - parseInt(b, 10); - }); - - return ret; - } - const [performers, setPerformers] = useState< ObjectListScrapeResult >( @@ -127,10 +99,15 @@ export const GalleryScrapeDialog: React.FC = ({ scraped.performers?.filter((t) => !t.stored_id) ?? [] ); - const [tags, setTags] = useState>( - new ScrapeResult( - sortIdList(gallery.tag_ids), - mapStoredIdObjects(scraped.tags ?? undefined) + const [tags, setTags] = useState>( + new ObjectListScrapeResult( + sortStoredIdObjects( + galleryTags.map((t) => ({ + stored_id: t.id, + name: t.name, + })) + ), + sortStoredIdObjects(scraped.tags ?? undefined) ) ); const [newTags, setNewTags] = useState( @@ -191,19 +168,9 @@ export const GalleryScrapeDialog: React.FC = ({ urls: urls.getNewValue(), date: date.getNewValue(), photographer: photographer.getNewValue(), - studio: newStudioValue - ? { - stored_id: newStudioValue, - name: "", - } - : undefined, + studio: newStudioValue, performers: performers.getNewValue(), - tags: tags.getNewValue()?.map((m) => { - return { - stored_id: m, - name: "", - }; - }), + tags: tags.getNewValue(), details: details.getNewValue(), }; } diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index e332244ad80..6bc84208240 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -1,8 +1,7 @@ -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; -import { Table } from "react-bootstrap"; -import { Link, useHistory } from "react-router-dom"; +import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { @@ -18,7 +17,8 @@ import GalleryWallCard from "./GalleryWallCard"; import { EditGalleriesDialog } from "./EditGalleriesDialog"; import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog"; import { ExportDialog } from "../Shared/ExportDialog"; -import { galleryTitle } from "src/core/galleries"; +import { GalleryListTable } from "./GalleryListTable"; +import { useContainerDimensions } from "../Shared/GridCard/GridCard"; const GalleryItemList = makeItemList({ filterMode: GQL.FilterMode.Galleries, @@ -107,6 +107,9 @@ export const GalleryList: React.FC = ({ setIsExportDialogOpen(true); } + const componentRef = useRef(null); + const { width } = useContainerDimensions(componentRef); + function renderContent( result: GQL.FindGalleriesQueryResult, filter: ListFilterModel, @@ -134,10 +137,11 @@ export const GalleryList: React.FC = ({ if (filter.displayMode === DisplayMode.Grid) { return ( -
+
{result.data.findGalleries.galleries.map((gallery) => ( 0} @@ -152,40 +156,11 @@ export const GalleryList: React.FC = ({ } if (filter.displayMode === DisplayMode.List) { return ( - - - - - - - - - {result.data.findGalleries.galleries.map((gallery) => ( - - - - - ))} - -
{intl.formatMessage({ id: "actions.preview" })} - {intl.formatMessage({ id: "title" })} -
- - {gallery.cover ? ( - {gallery.title - ) : undefined} - - - - {galleryTitle(gallery)} ({gallery.image_count}{" "} - {gallery.image_count === 1 ? "image" : "images"}) - -
+ ); } if (filter.displayMode === DisplayMode.Wall) { diff --git a/ui/v2.5/src/components/Galleries/GalleryListTable.tsx b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx new file mode 100644 index 00000000000..f378cb25729 --- /dev/null +++ b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx @@ -0,0 +1,256 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import * as GQL from "src/core/generated-graphql"; +import NavUtils from "src/utils/navigation"; +import { useIntl } from "react-intl"; +import { objectTitle } from "src/core/files"; +import { galleryTitle } from "src/core/galleries"; +import { RatingSystem } from "../Shared/Rating/RatingSystem"; +import { useGalleryUpdate } from "src/core/StashService"; +import { IColumn, ListTable } from "../List/ListTable"; +import { useTableColumns } from "src/hooks/useTableColumns"; + +interface IGalleryListTableProps { + galleries: GQL.SlimGalleryDataFragment[]; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +} + +const TABLE_NAME = "galleries"; + +export const GalleryListTable: React.FC = ( + props: IGalleryListTableProps +) => { + const intl = useIntl(); + + const [updateGallery] = useGalleryUpdate(); + + function setRating(v: number | null, galleryId: string) { + if (galleryId) { + updateGallery({ + variables: { + input: { + id: galleryId, + rating100: v, + }, + }, + }); + } + } + + const CoverImageCell = (gallery: GQL.SlimGalleryDataFragment) => { + const title = galleryTitle(gallery); + + return ( + + {gallery.cover ? ( + {title} + ) : undefined} + + ); + }; + + const TitleCell = (gallery: GQL.SlimGalleryDataFragment) => { + const title = galleryTitle(gallery); + + return ( + + {title} + + ); + }; + + const DateCell = (gallery: GQL.SlimGalleryDataFragment) => ( + <>{gallery.date} + ); + + const RatingCell = (gallery: GQL.SlimGalleryDataFragment) => ( + setRating(value, gallery.id)} + /> + ); + + const ImagesCell = (gallery: GQL.SlimGalleryDataFragment) => { + return ( + + {gallery.image_count} + + ); + }; + + const TagCell = (gallery: GQL.SlimGalleryDataFragment) => ( +
    + {gallery.tags.map((tag) => ( +
  • + + {tag.name} + +
  • + ))} +
+ ); + + const PerformersCell = (gallery: GQL.SlimGalleryDataFragment) => ( +
    + {gallery.performers.map((performer) => ( +
  • + + {performer.name} + +
  • + ))} +
+ ); + + const StudioCell = (gallery: GQL.SlimGalleryDataFragment) => { + if (gallery.studio) { + return ( + + {gallery.studio.name} + + ); + } + }; + + const SceneCell = (gallery: GQL.SlimGalleryDataFragment) => ( +
    + {gallery.scenes.map((galleryScene) => ( +
  • + + {objectTitle(galleryScene)} + +
  • + ))} +
+ ); + + interface IColumnSpec { + value: string; + label: string; + defaultShow?: boolean; + mandatory?: boolean; + render?: ( + gallery: GQL.SlimGalleryDataFragment, + index: number + ) => React.ReactNode; + } + + const allColumns: IColumnSpec[] = [ + { + value: "cover_image", + label: intl.formatMessage({ id: "cover_image" }), + defaultShow: true, + render: CoverImageCell, + }, + { + value: "title", + label: intl.formatMessage({ id: "title" }), + defaultShow: true, + mandatory: true, + render: TitleCell, + }, + { + value: "date", + label: intl.formatMessage({ id: "date" }), + defaultShow: true, + render: DateCell, + }, + { + value: "rating", + label: intl.formatMessage({ id: "rating" }), + defaultShow: true, + render: RatingCell, + }, + { + value: "code", + label: intl.formatMessage({ id: "scene_code" }), + render: (s) => <>{s.code}, + }, + { + value: "images", + label: intl.formatMessage({ id: "images" }), + defaultShow: true, + render: ImagesCell, + }, + { + value: "tags", + label: intl.formatMessage({ id: "tags" }), + defaultShow: true, + render: TagCell, + }, + { + value: "performers", + label: intl.formatMessage({ id: "performers" }), + defaultShow: true, + render: PerformersCell, + }, + { + value: "studio", + label: intl.formatMessage({ id: "studio" }), + defaultShow: true, + render: StudioCell, + }, + { + value: "scenes", + label: intl.formatMessage({ id: "scenes" }), + defaultShow: true, + render: SceneCell, + }, + { + value: "photographer", + label: intl.formatMessage({ id: "photographer" }), + render: (s) => <>{s.photographer}, + }, + ]; + + const defaultColumns = allColumns + .filter((col) => col.defaultShow) + .map((col) => col.value); + + const { selectedColumns, saveColumns } = useTableColumns( + TABLE_NAME, + defaultColumns + ); + + const columnRenderFuncs: Record< + string, + (gallery: GQL.SlimGalleryDataFragment, index: number) => React.ReactNode + > = {}; + allColumns.forEach((col) => { + if (col.render) { + columnRenderFuncs[col.value] = col.render; + } + }); + + function renderCell( + column: IColumn, + gallery: GQL.SlimGalleryDataFragment, + index: number + ) { + const render = columnRenderFuncs[column.value]; + + if (render) return render(gallery, index); + } + + return ( + saveColumns(c)} + selectedIds={props.selectedIds} + onSelectChange={props.onSelectChange} + renderCell={renderCell} + /> + ); +}; diff --git a/ui/v2.5/src/components/Galleries/GallerySelect.tsx b/ui/v2.5/src/components/Galleries/GallerySelect.tsx new file mode 100644 index 00000000000..627daab3c37 --- /dev/null +++ b/ui/v2.5/src/components/Galleries/GallerySelect.tsx @@ -0,0 +1,230 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + OptionProps, + components as reactSelectComponents, + MultiValueGenericProps, + SingleValueProps, +} from "react-select"; +import cx from "classnames"; + +import * as GQL from "src/core/generated-graphql"; +import { + queryFindGalleries, + queryFindGalleriesByIDForSelect, +} from "src/core/StashService"; +import { ConfigurationContext } from "src/hooks/Config"; +import { useIntl } from "react-intl"; +import { defaultMaxOptionsShown } from "src/core/config"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { + FilterSelectComponent, + IFilterIDProps, + IFilterProps, + IFilterValueProps, + Option as SelectOption, +} from "../Shared/FilterSelect"; +import { useCompare } from "src/hooks/state"; +import { Placement } from "react-bootstrap/esm/Overlay"; +import { sortByRelevance } from "src/utils/query"; +import { galleryTitle } from "src/core/galleries"; +import { PatchComponent } from "src/pluginApi"; + +export type Gallery = Pick & { + files: Pick[]; + folder?: Pick | null; +}; +type Option = SelectOption; + +const _GallerySelect: React.FC< + IFilterProps & + IFilterValueProps & { + hoverPlacement?: Placement; + excludeIds?: string[]; + } +> = (props) => { + const { configuration } = React.useContext(ConfigurationContext); + const intl = useIntl(); + const maxOptionsShown = + configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; + + const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + + async function loadGalleries(input: string): Promise { + const filter = new ListFilterModel(GQL.FilterMode.Galleries); + filter.searchTerm = input; + filter.currentPage = 1; + filter.itemsPerPage = maxOptionsShown; + filter.sortBy = "title"; + filter.sortDirection = GQL.SortDirectionEnum.Asc; + const query = await queryFindGalleries(filter); + let ret = query.data.findGalleries.galleries.filter((gallery) => { + // HACK - we should probably exclude these in the backend query, but + // this will do in the short-term + return !exclude.includes(gallery.id.toString()); + }); + + return sortByRelevance(input, ret, galleryTitle, (g) => { + return g.files.map((f) => f.path).concat(g.folder?.path ?? []); + }).map((gallery) => ({ + value: gallery.id, + object: gallery, + })); + } + + const GalleryOption: React.FC> = ( + optionProps + ) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + const title = galleryTitle(object); + + // if title does not match the input value but the path does, show the path + const { inputValue } = optionProps.selectProps; + let matchedPath: string | undefined = ""; + if (!title.toLowerCase().includes(inputValue.toLowerCase())) { + matchedPath = object.files?.find((a) => + a.path.toLowerCase().includes(inputValue.toLowerCase()) + )?.path; + + if ( + !matchedPath && + object.folder?.path.toLowerCase().includes(inputValue.toLowerCase()) + ) { + matchedPath = object.folder?.path; + } + } + + thisOptionProps = { + ...optionProps, + children: ( + + {title} + {matchedPath && ( + {` (${matchedPath})`} + )} + + ), + }; + + return ; + }; + + const GalleryMultiValueLabel: React.FC< + MultiValueGenericProps + > = (optionProps) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + thisOptionProps = { + ...optionProps, + children: galleryTitle(object), + }; + + return ; + }; + + const GalleryValueLabel: React.FC> = ( + optionProps + ) => { + let thisOptionProps = optionProps; + + const { object } = optionProps.data; + + thisOptionProps = { + ...optionProps, + children: <>{galleryTitle(object)}, + }; + + return ; + }; + + return ( + + {...props} + className={cx( + "gallery-select", + { + "gallery-select-active": props.active, + }, + props.className + )} + loadOptions={loadGalleries} + components={{ + Option: GalleryOption, + MultiValueLabel: GalleryMultiValueLabel, + SingleValue: GalleryValueLabel, + }} + isMulti={props.isMulti ?? false} + placeholder={ + props.noSelectionString ?? + intl.formatMessage( + { id: "actions.select_entity" }, + { + entityType: intl.formatMessage({ + id: props.isMulti ? "galleries" : "gallery", + }), + } + ) + } + closeMenuOnSelect={!props.isMulti} + /> + ); +}; + +export const GallerySelect = PatchComponent("GallerySelect", _GallerySelect); + +const _GalleryIDSelect: React.FC> = ( + props +) => { + const { ids, onSelect: onSelectValues } = props; + + const [values, setValues] = useState([]); + const idsChanged = useCompare(ids); + + function onSelect(items: Gallery[]) { + setValues(items); + onSelectValues?.(items); + } + + async function loadObjectsByID(idsToLoad: string[]): Promise { + const galleryIDs = idsToLoad.map((id) => parseInt(id)); + const query = await queryFindGalleriesByIDForSelect(galleryIDs); + const { galleries: loadedGalleries } = query.data.findGalleries; + + return loadedGalleries; + } + + useEffect(() => { + if (!idsChanged) { + return; + } + + if (!ids || ids?.length === 0) { + setValues([]); + return; + } + + // load the values if we have ids and they haven't been loaded yet + const filteredValues = values.filter((v) => ids.includes(v.id.toString())); + if (filteredValues.length === ids.length) { + return; + } + + const load = async () => { + const items = await loadObjectsByID(ids); + setValues(items); + }; + + load(); + }, [ids, idsChanged, values]); + + return ; +}; + +export const GalleryIDSelect = PatchComponent( + "GalleryIDSelect", + _GalleryIDSelect +); diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index 5546e47de7c..8a9a10e0507 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -289,3 +289,9 @@ $galleryTabWidth: 450px; .col-form-label { padding-right: 2px; } + +.gallery-select-alias { + font-size: 0.8rem; + font-weight: bold; + white-space: pre; +} diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index 47f7eac7a79..12e8b9e0012 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -1,4 +1,4 @@ -import React, { MouseEvent, useMemo } from "react"; +import React, { MouseEvent, useEffect, useMemo, useState } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; @@ -7,7 +7,10 @@ import { GalleryLink, TagLink } from "src/components/Shared/TagLink"; import { HoverPopover } from "src/components/Shared/HoverPopover"; import { SweatDrops } from "src/components/Shared/SweatDrops"; import { PerformerPopoverButton } from "src/components/Shared/PerformerPopoverButton"; -import { GridCard } from "src/components/Shared/GridCard"; +import { + GridCard, + calculateCardWidth, +} from "src/components/Shared/GridCard/GridCard"; import { RatingBanner } from "src/components/Shared/RatingBanner"; import { faBox, @@ -17,9 +20,12 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { objectTitle } from "src/core/files"; import { TruncatedText } from "../Shared/TruncatedText"; +import ScreenUtils from "src/utils/screen"; +import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; interface IImageCardProps { image: GQL.SlimImageDataFragment; + containerWidth?: number; selecting?: boolean; selected?: boolean | undefined; zoomIndex: number; @@ -30,6 +36,38 @@ interface IImageCardProps { export const ImageCard: React.FC = ( props: IImageCardProps ) => { + const [cardWidth, setCardWidth] = useState(); + + useEffect(() => { + if ( + !props.containerWidth || + props.zoomIndex === undefined || + ScreenUtils.isMobile() + ) + return; + + let zoomValue = props.zoomIndex; + let preferredCardWidth: number; + switch (zoomValue) { + case 0: + preferredCardWidth = 240; + break; + case 1: + preferredCardWidth = 340; + break; + case 2: + preferredCardWidth = 480; + break; + case 3: + preferredCardWidth = 640; + } + let fittedCardWidth = calculateCardWidth( + props.containerWidth, + preferredCardWidth! + ); + setCardWidth(fittedCardWidth); + }, [props, props.containerWidth, props.zoomIndex]); + const file = useMemo( () => props.image.visual_files.length > 0 @@ -153,6 +191,7 @@ export const ImageCard: React.FC = ( = ( />
} + overlays={} popovers={maybeRenderPopoverButtonGroup()} selected={props.selected} selecting={props.selecting} diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index 74e692dcdac..83557739f58 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -234,7 +234,7 @@ const ImagePage: React.FC = ({ image }) => { > - + = ({ const { configuration } = React.useContext(ConfigurationContext); const [performers, setPerformers] = useState([]); + const [tags, setTags] = useState([]); + const [studio, setStudio] = useState(null); const schema = yup.object({ title: yup.string().ensure(), @@ -93,9 +96,22 @@ export const ImageEditPanel: React.FC = ({ ); } + function onSetTags(items: Tag[]) { + setTags(items); + formik.setFieldValue( + "tag_ids", + items.map((item) => item.id) + ); + } + + function onSetStudio(item: Studio | null) { + setStudio(item); + formik.setFieldValue("studio_id", item ? item.id : null); + } + useRatingKeybinds( true, - configuration?.ui?.ratingSystemOptions?.type, + configuration?.ui.ratingSystemOptions?.type, setRating ); @@ -103,6 +119,14 @@ export const ImageEditPanel: React.FC = ({ setPerformers(image.performers ?? []); }, [image.performers]); + useEffect(() => { + setTags(image.tags ?? []); + }, [image.tags]); + + useEffect(() => { + setStudio(image.studio ?? null); + }, [image.studio]); + useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { @@ -169,13 +193,8 @@ export const ImageEditPanel: React.FC = ({ const title = intl.formatMessage({ id: "studio" }); const control = ( - formik.setFieldValue( - "studio_id", - items.length > 0 ? items[0]?.id : null - ) - } - ids={formik.values.studio_id ? [formik.values.studio_id] : []} + onSelect={(items) => onSetStudio(items.length > 0 ? items[0] : null)} + values={studio ? [studio] : []} /> ); @@ -196,13 +215,8 @@ export const ImageEditPanel: React.FC = ({ const control = ( - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ) - } - ids={formik.values.tag_ids} + onSelect={onSetTags} + values={tags} hoverPlacement="right" /> ); diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 2b3b359a6ea..df244fc3e92 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -4,6 +4,7 @@ import React, { useMemo, MouseEvent, useContext, + useRef, } from "react"; import { FormattedNumber, useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; @@ -31,7 +32,7 @@ import { ExportDialog } from "../Shared/ExportDialog"; import { objectTitle } from "src/core/files"; import TextUtils from "src/utils/text"; import { ConfigurationContext } from "src/hooks/Config"; -import { IUIConfig } from "src/core/config"; +import { useContainerDimensions } from "../Shared/GridCard/GridCard"; interface IImageWallProps { images: GQL.SlimImageDataFragment[]; @@ -43,7 +44,7 @@ interface IImageWallProps { const ImageWall: React.FC = ({ images, handleImageOpen }) => { const { configuration } = useContext(ConfigurationContext); - const uiConfig = configuration?.ui as IUIConfig | undefined; + const uiConfig = configuration?.ui; let photos: { src: string; @@ -196,6 +197,9 @@ const ImageListImages: React.FC = ({ ev.preventDefault(); } + const componentRef = useRef(null); + const { width } = useContainerDimensions(componentRef); + function renderImageCard( index: number, image: GQL.SlimImageDataFragment, @@ -204,6 +208,7 @@ const ImageListImages: React.FC = ({ return ( 0} @@ -220,7 +225,7 @@ const ImageListImages: React.FC = ({ if (filter.displayMode === DisplayMode.Grid) { return ( -
+
{images.map((image, index) => renderImageCard(index, image, filter.zoomIndex) )} diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index 531af632cb3..d23b15bbfce 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -31,7 +31,6 @@ import { useCompare, usePrevious } from "src/hooks/state"; import { CriterionType } from "src/models/list-filter/types"; import { useToast } from "src/hooks/Toast"; import { useConfigureUI } from "src/core/StashService"; -import { IUIConfig } from "src/core/config"; import { FilterMode } from "src/core/generated-graphql"; import { useFocusOnce } from "src/utils/focus"; import Mousetrap from "mousetrap"; @@ -270,7 +269,7 @@ export const EditFilterDialog: React.FC = ({ [filter, criteria] ); - const ui = (configuration?.ui ?? {}) as IUIConfig; + const ui = configuration?.ui ?? {}; const [saveUI] = useConfigureUI(); const filteredOptions = useMemo(() => { diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index 13824e08b8b..c2fa322f87a 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Form } from "react-bootstrap"; import { FilterSelect, SelectObject } from "src/components/Shared/Select"; +import { galleryTitle } from "src/core/galleries"; import { Criterion } from "src/models/list-filter/criteria/criterion"; import { ILabeledId } from "src/models/list-filter/types"; @@ -22,16 +23,25 @@ export const LabeledIdFilter: React.FC = ({ inputType !== "scene_tags" && inputType !== "performer_tags" && inputType !== "tags" && - inputType !== "movies" + inputType !== "movies" && + inputType !== "galleries" ) { return null; } + function getLabel(i: SelectObject) { + if (inputType === "galleries") { + return galleryTitle(i); + } + + return i.name ?? i.title ?? ""; + } + function onSelectionChanged(items: SelectObject[]) { onValueChanged( items.map((i) => ({ id: i.id, - label: i.name ?? i.title ?? "", + label: getLabel(i), })) ); } diff --git a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx index 483cb7400a3..516e02d4b8e 100644 --- a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from "react"; import { PerformersCriterion } from "src/models/list-filter/criteria/performers"; import { useFindPerformersQuery } from "src/core/generated-graphql"; import { ObjectsFilter } from "./SelectableFilter"; +import { sortByRelevance } from "src/utils/query"; interface IPerformersFilter { criterion: PerformersCriterion; @@ -18,16 +19,19 @@ function usePerformerQuery(query: string) { }, }); - const results = useMemo( - () => - data?.findPerformers.performers.map((p) => { - return { - id: p.id, - label: p.name, - }; - }) ?? [], - [data] - ); + const results = useMemo(() => { + return sortByRelevance( + query, + data?.findPerformers.performers ?? [], + (p) => p.name, + (p) => p.alias_list + ).map((p) => { + return { + id: p.id, + label: p.name, + }; + }); + }, [data, query]); return { results, loading }; } diff --git a/ui/v2.5/src/components/List/Filters/PhashFilter.tsx b/ui/v2.5/src/components/List/Filters/PhashFilter.tsx index 988f813b9ab..68eda800280 100644 --- a/ui/v2.5/src/components/List/Filters/PhashFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/PhashFilter.tsx @@ -43,7 +43,7 @@ export const PhashFilter: React.FC = ({ className="btn-secondary" onChange={valueChanged} value={value ? value.value : ""} - placeholder={intl.formatMessage({ id: "phash" })} + placeholder={intl.formatMessage({ id: "media_info.phash" })} /> {criterion.modifier !== CriterionModifier.IsNull && diff --git a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx index cb10596ef48..a99fdde3a54 100644 --- a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from "react"; import { useFindStudiosQuery } from "src/core/generated-graphql"; import { HierarchicalObjectsFilter } from "./SelectableFilter"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; +import { sortByRelevance } from "src/utils/query"; interface IStudiosFilter { criterion: StudiosCriterion; @@ -18,16 +19,19 @@ function useStudioQuery(query: string) { }, }); - const results = useMemo( - () => - data?.findStudios.studios.map((p) => { - return { - id: p.id, - label: p.name, - }; - }) ?? [], - [data] - ); + const results = useMemo(() => { + return sortByRelevance( + query, + data?.findStudios.studios ?? [], + (s) => s.name, + (s) => s.aliases + ).map((p) => { + return { + id: p.id, + label: p.name, + }; + }); + }, [data, query]); return { results, loading }; } diff --git a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx index 7b3479a54fa..177357bf9fc 100644 --- a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from "react"; import { useFindTagsQuery } from "src/core/generated-graphql"; import { HierarchicalObjectsFilter } from "./SelectableFilter"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; +import { sortByRelevance } from "src/utils/query"; interface ITagsFilter { criterion: StudiosCriterion; @@ -18,16 +19,19 @@ function useTagQuery(query: string) { }, }); - const results = useMemo( - () => - data?.findTags.tags.map((p) => { - return { - id: p.id, - label: p.name, - }; - }) ?? [], - [data] - ); + const results = useMemo(() => { + return sortByRelevance( + query, + data?.findTags.tags ?? [], + (t) => t.name, + (t) => t.aliases + ).map((p) => { + return { + id: p.id, + label: p.name, + }; + }); + }, [data, query]); return { results, loading }; } diff --git a/ui/v2.5/src/components/List/ListTable.tsx b/ui/v2.5/src/components/List/ListTable.tsx new file mode 100644 index 00000000000..e583c54b8c8 --- /dev/null +++ b/ui/v2.5/src/components/List/ListTable.tsx @@ -0,0 +1,141 @@ +import React, { useMemo } from "react"; +import { Table, Form } from "react-bootstrap"; +import { CheckBoxSelect } from "../Shared/Select"; +import cx from "classnames"; + +export interface IColumn { + label: string; + value: string; + mandatory?: boolean; +} + +export const ColumnSelector: React.FC<{ + selected: string[]; + allColumns: IColumn[]; + setSelected: (selected: string[]) => void; +}> = ({ selected, allColumns, setSelected }) => { + const disableOptions = useMemo(() => { + return allColumns.map((col) => { + return { + ...col, + isDisabled: col.mandatory, + }; + }); + }, [allColumns]); + + const selectedColumns = useMemo(() => { + return disableOptions.filter((col) => selected.includes(col.value)); + }, [selected, disableOptions]); + + return ( + { + setSelected(v.map((col) => col.value)); + }} + /> + ); +}; + +interface IListTableProps { + className?: string; + items: T[]; + columns: string[]; + setColumns: (columns: string[]) => void; + allColumns: IColumn[]; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + renderCell: (column: IColumn, item: T, index: number) => React.ReactNode; +} + +export const ListTable = ( + props: IListTableProps +) => { + const { + className, + items, + columns, + setColumns, + allColumns, + selectedIds, + onSelectChange, + renderCell, + } = props; + + const visibleColumns = useMemo(() => { + return allColumns.filter( + (col) => col.mandatory || columns.includes(col.value) + ); + }, [columns, allColumns]); + + const renderObjectRow = (item: T, index: number) => { + let shiftKey = false; + + return ( + + + + + + {visibleColumns.map((column) => ( + + {renderCell(column, item, index)} + + ))} + + ); + }; + + const columnHeaders = useMemo(() => { + return visibleColumns.map((column) => ( + + {column.label} + + )); + }, [visibleColumns]); + + return ( +
+ + + + + + {columnHeaders} + + + + + + {items.map(renderObjectRow)} +
+
+ +
+
+
+ ); +}; diff --git a/ui/v2.5/src/components/List/SavedFilterList.tsx b/ui/v2.5/src/components/List/SavedFilterList.tsx index 7d0b1233a9b..c6e7cfa70d4 100644 --- a/ui/v2.5/src/components/List/SavedFilterList.tsx +++ b/ui/v2.5/src/components/List/SavedFilterList.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useState } from "react"; import { Button, ButtonGroup, @@ -39,7 +39,6 @@ export const SavedFilterList: React.FC = ({ const intl = useIntl(); const { data, error, loading, refetch } = useFindSavedFilters(filter.mode); - const oldError = useRef(error); const [filterName, setFilterName] = useState(""); const [saving, setSaving] = useState(false); @@ -56,14 +55,6 @@ export const SavedFilterList: React.FC = ({ const savedFilters = data?.findSavedFilters ?? []; - useEffect(() => { - if (error && error !== oldError.current) { - Toast.error(error); - } - - oldError.current = error; - }, [error, Toast, oldError]); - async function onSaveFilter(name: string, id?: string) { const filterCopy = filter.clone(); @@ -76,8 +67,8 @@ export const SavedFilterList: React.FC = ({ mode: filter.mode, name, find_filter: filterCopy.makeFindFilter(), - object_filter: filterCopy.makeSavedFindFilter(), - ui_options: filterCopy.makeUIOptions(), + object_filter: filterCopy.makeSavedFilter(), + ui_options: filterCopy.makeSavedUIOptions(), }, }, }); @@ -146,8 +137,8 @@ export const SavedFilterList: React.FC = ({ input: { mode: filter.mode, find_filter: filterCopy.makeFindFilter(), - object_filter: filterCopy.makeSavedFindFilter(), - ui_options: filterCopy.makeUIOptions(), + object_filter: filterCopy.makeSavedFilter(), + ui_options: filterCopy.makeSavedUIOptions(), }, }, }); @@ -285,6 +276,8 @@ export const SavedFilterList: React.FC = ({ } function renderSavedFilters() { + if (error) return
{error.message}
; + if (loading || saving) { return (
@@ -311,20 +304,22 @@ export const SavedFilterList: React.FC = ({ function maybeRenderSetDefaultButton() { if (persistState === PersistanceLevel.ALL) { return ( - +
+ +
); } } return ( -
+ <> {maybeRenderDeleteAlert()} {maybeRenderOverwriteAlert()} @@ -359,6 +354,6 @@ export const SavedFilterList: React.FC = ({ {renderSavedFilters()} {maybeRenderSetDefaultButton()} -
+ ); }; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 1c6a390f411..f67b5b8b33d 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -65,8 +65,14 @@ input[type="range"].zoom-slider { .saved-filter-list-menu { width: 300px; + &.dropdown-menu.show { + display: flex; + flex-direction: column; + } + .set-as-default-button { float: right; + margin-right: 0.5rem; } .LoadingIndicator { @@ -94,12 +100,17 @@ input[type="range"].zoom-slider { align-items: center; display: inline; overflow-x: hidden; + padding-left: 1.25rem; padding-right: 0.25rem; text-overflow: ellipsis; } .btn-group { margin-left: auto; + + .btn { + border-radius: 0; + } } .delete-button { @@ -359,3 +370,156 @@ input[type="range"].zoom-slider { .tilted { transform: rotate(45deg); } + +.table-list { + display: grid; + grid-template-columns: repeat(1, minmax(0, 1fr)); + margin-bottom: 1rem; + margin-left: 0; + margin-right: 0; + max-height: 78dvh; + min-width: min-content; + overflow-x: auto; + position: relative; + + table { + margin: 0; + + thead { + background-color: #202b33; + position: sticky; + top: 0; + z-index: 100; + } + + td:first-child { + padding: 0; + } + + label { + margin: 0; + padding: 0.5rem; + } + } + + .column-select { + margin: 0; + padding: 7px; + } + + .select-col { + width: 20px; + } + + .comma-list { + list-style: none; + margin: 0; + padding: 4px 2px; + + li { + display: inline; + } + + li::after { + content: ", "; + } + + li:last-child::after { + content: ""; + } + } + + .comma-list.overflowable { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 190px; + } + + .comma-list.overflowable:hover { + background: #28343c; + border: 1px solid #414c53; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.28); + display: block; + height: auto; + margin-left: -0.4rem; + margin-top: -0.9rem; + max-width: 40rem; + overflow: hidden; + padding: 0.1rem 0.5rem; + position: absolute; + top: auto; + white-space: normal; + width: max-content; + z-index: 100; + } + + .comma-list.overflowable li .ellips-data:hover { + max-width: fit-content; + } + + td { + color: hsla(0, 0%, 100%, 0.6); + font-weight: 500; + position: relative; + text-align: left; + white-space: nowrap; + + .ellips-data { + display: block; + max-width: 190px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .star-rating-number { + display: none; + } + + a { + font-weight: 600; + white-space: nowrap; + } + } + + td.select-col { + text-align: center; + } + + .table thead th { + border: none; + white-space: nowrap; + } + + tr { + border-collapse: collapse; + } + + .date-head { + width: 97px; + } +} + +.table-list tbody tr:hover { + background-color: #2d3942; +} + +.table-list a { + color: $text-color; +} + +.table-list .table-striped td, +.table-list .table-striped th { + font-size: 1rem; + vertical-align: middle; + + h5, + h6 { + font-size: 1rem; + } + + &:first-child { + border-left: none; + } +} diff --git a/ui/v2.5/src/components/Movies/MovieCard.tsx b/ui/v2.5/src/components/Movies/MovieCard.tsx index e86856f2ec8..d517359265e 100644 --- a/ui/v2.5/src/components/Movies/MovieCard.tsx +++ b/ui/v2.5/src/components/Movies/MovieCard.tsx @@ -1,7 +1,7 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; -import { GridCard } from "../Shared/GridCard"; +import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { SceneLink } from "../Shared/TagLink"; @@ -9,9 +9,11 @@ import { TruncatedText } from "../Shared/TruncatedText"; import { FormattedMessage } from "react-intl"; import { RatingBanner } from "../Shared/RatingBanner"; import { faPlayCircle } from "@fortawesome/free-solid-svg-icons"; +import ScreenUtils from "src/utils/screen"; interface IProps { movie: GQL.MovieDataFragment; + containerWidth?: number; sceneIndex?: number; selecting?: boolean; selected?: boolean; @@ -19,6 +21,19 @@ interface IProps { } export const MovieCard: React.FC = (props: IProps) => { + const [cardWidth, setCardWidth] = useState(); + + useEffect(() => { + if (!props.containerWidth || ScreenUtils.isMobile()) return; + + let preferredCardWidth = 250; + let fittedCardWidth = calculateCardWidth( + props.containerWidth, + preferredCardWidth! + ); + setCardWidth(fittedCardWidth); + }, [props, props.containerWidth]); + function maybeRenderSceneNumber() { if (!props.sceneIndex) return; @@ -71,6 +86,7 @@ export const MovieCard: React.FC = (props: IProps) => { ; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +} + +export const MovieCardGrid: React.FC = ({ + movies, + selectedIds, + onSelectChange, +}) => { + const componentRef = useRef(null); + const { width } = useContainerDimensions(componentRef); + return ( +
+ {movies.map((p) => ( + 0} + selected={selectedIds.has(p.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(p.id, selected, shiftKey) + } + /> + ))} +
+ ); +}; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index 299f47ea38b..32690a3071e 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -33,11 +33,11 @@ import TextUtils from "src/utils/text"; import { Icon } from "src/components/Shared/Icon"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { ConfigurationContext } from "src/hooks/Config"; -import { IUIConfig } from "src/core/config"; import { DetailImage } from "src/components/Shared/DetailImage"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; +import { ExternalLink } from "src/components/Shared/ExternalLink"; interface IProps { movie: GQL.MovieDataFragment; @@ -54,7 +54,7 @@ const MoviePage: React.FC = ({ movie }) => { // Configuration settings const { configuration } = React.useContext(ConfigurationContext); - const uiConfig = configuration?.ui as IUIConfig | undefined; + const uiConfig = configuration?.ui; const enableBackgroundImage = uiConfig?.enableMovieBackgroundImage ?? false; const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; const showAllDetails = uiConfig?.showAllDetails ?? true; @@ -129,7 +129,7 @@ const MoviePage: React.FC = ({ movie }) => { useRatingKeybinds( true, - configuration?.ui?.ratingSystemOptions?.type, + configuration?.ui.ratingSystemOptions?.type, setRating ); @@ -274,15 +274,13 @@ const MoviePage: React.FC = ({ movie }) => { const renderClickableIcons = () => ( {movie.url && ( - )} @@ -362,18 +360,22 @@ const MoviePage: React.FC = ({ movie }) => { function maybeRenderHeaderBackgroundImage() { let image = movie.front_image_path; if (enableBackgroundImage && !isEditing && image) { - return ( -
- - - {`${movie.name} - -
- ); + const imageURL = new URL(image); + let isDefaultImage = imageURL.searchParams.get("default"); + if (!isDefaultImage) { + return ( +
+ + + {`${movie.name} + +
+ ); + } } } diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx index d0bc485e55d..b9d0d7ef7aa 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx @@ -3,6 +3,7 @@ import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; import { DetailItem } from "src/components/Shared/DetailItem"; +import { Link } from "react-router-dom"; interface IMovieDetailsPanel { movie: GQL.MovieDataFragment; @@ -34,9 +35,9 @@ export const MovieDetailsPanel: React.FC = ({ id="studio" value={ movie.studio?.id ? ( - + {movie.studio?.name} - + ) : ( "" ) diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx index b393d29e368..ed8102d85d0 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx @@ -8,7 +8,6 @@ import { useListMovieScrapers, } from "src/core/StashService"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { StudioSelect } from "src/components/Shared/Select"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { URLField } from "src/components/Shared/URLField"; import { useToast } from "src/hooks/Toast"; @@ -22,6 +21,7 @@ import isEqual from "lodash-es/isEqual"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupDateString, yupFormikValidate } from "src/utils/yup"; +import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; interface IMovieEditPanel { movie: Partial; @@ -55,6 +55,8 @@ export const MovieEditPanel: React.FC = ({ const Scrapers = useListMovieScrapers(); const [scrapedMovie, setScrapedMovie] = useState(); + const [studio, setStudio] = useState(null); + const schema = yup.object({ name: yup.string().required(), aliases: yup.string().ensure(), @@ -88,6 +90,15 @@ export const MovieEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + function onSetStudio(item: Studio | null) { + setStudio(item); + formik.setFieldValue("studio_id", item ? item.id : null); + } + + useEffect(() => { + setStudio(movie.studio ?? null); + }, [movie.studio]); + // set up hotkeys useEffect(() => { // Mousetrap.bind("u", (e) => { @@ -129,7 +140,11 @@ export const MovieEditPanel: React.FC = ({ } if (state.studio && state.studio.stored_id) { - formik.setFieldValue("studio_id", state.studio.stored_id); + onSetStudio({ + id: state.studio.stored_id, + name: state.studio.name ?? "", + aliases: [], + }); } if (state.director) { @@ -324,13 +339,8 @@ export const MovieEditPanel: React.FC = ({ const title = intl.formatMessage({ id: "studio" }); const control = ( - formik.setFieldValue( - "studio_id", - items.length > 0 ? items[0]?.id : null - ) - } - ids={formik.values.studio_id ? [formik.values.studio_id] : []} + onSelect={(items) => onSetStudio(items.length > 0 ? items[0] : null)} + values={studio ? [studio] : []} /> ); diff --git a/ui/v2.5/src/components/Movies/MovieList.tsx b/ui/v2.5/src/components/Movies/MovieList.tsx index c5b6c8dedf6..55b34a783f2 100644 --- a/ui/v2.5/src/components/Movies/MovieList.tsx +++ b/ui/v2.5/src/components/Movies/MovieList.tsx @@ -18,7 +18,7 @@ import { } from "../List/ItemList"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; -import { MovieCard } from "./MovieCard"; +import { MovieCardGrid } from "./MovieCardGrid"; import { EditMoviesDialog } from "./EditMoviesDialog"; const MovieItemList = makeItemList({ @@ -130,19 +130,11 @@ export const MovieList: React.FC = ({ filterHook, alterQuery }) => { if (filter.displayMode === DisplayMode.Grid) { return ( -
- {result.data.findMovies.movies.map((p) => ( - 0} - selected={selectedIds.has(p.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(p.id, selected, shiftKey) - } - /> - ))} -
+ ); } if (filter.displayMode === DisplayMode.List) { diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index 8eaf784d7a0..2df34bbd8a3 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -26,6 +26,7 @@ import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import * as FormUtils from "src/utils/form"; +import { CountrySelect } from "../Shared/CountrySelect"; interface IListOperationProps { selected: GQL.SlimPerformerDataFragment[]; @@ -222,6 +223,7 @@ export const EditPerformersDialog: React.FC = ( function render() { return ( = ( {renderTextField("death_date", updateInput.death_date, (v) => setUpdateField({ death_date: v }) )} - {renderTextField("country", updateInput.country, (v) => - setUpdateField({ country: v }) - )} + + + + + + setUpdateField({ country: v })} + showFlag + /> + + {renderTextField("ethnicity", updateInput.ethnicity, (v) => setUpdateField({ ethnicity: v }) )} diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index a143dd57e7c..50da0bcb530 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -1,10 +1,10 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; -import { GridCard } from "../Shared/GridCard"; +import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; import { CountryFlag } from "../Shared/CountryFlag"; import { SweatDrops } from "../Shared/SweatDrops"; import { HoverPopover } from "../Shared/HoverPopover"; @@ -22,6 +22,7 @@ import { RatingBanner } from "../Shared/RatingBanner"; import cx from "classnames"; import { usePerformerUpdate } from "src/core/StashService"; import { ILabeledId } from "src/models/list-filter/types"; +import ScreenUtils from "src/utils/screen"; export interface IPerformerCardExtraCriteria { scenes?: Criterion[]; @@ -33,6 +34,7 @@ export interface IPerformerCardExtraCriteria { interface IPerformerCardProps { performer: GQL.PerformerDataFragment; + containerWidth?: number; ageFromDate?: string; selecting?: boolean; selected?: boolean; @@ -42,6 +44,7 @@ interface IPerformerCardProps { export const PerformerCard: React.FC = ({ performer, + containerWidth, ageFromDate, selecting, selected, @@ -66,6 +69,18 @@ export const PerformerCard: React.FC = ({ ); const [updatePerformer] = usePerformerUpdate(); + const [cardWidth, setCardWidth] = useState(); + + useEffect(() => { + if (!containerWidth || ScreenUtils.isMobile()) return; + + let preferredCardWidth = 300; + let fittedCardWidth = calculateCardWidth( + containerWidth, + preferredCardWidth! + ); + setCardWidth(fittedCardWidth); + }, [containerWidth]); function renderFavoriteIcon() { return ( @@ -251,6 +266,7 @@ export const PerformerCard: React.FC = ({ } diff --git a/ui/v2.5/src/components/Performers/PerformerCardGrid.tsx b/ui/v2.5/src/components/Performers/PerformerCardGrid.tsx new file mode 100644 index 00000000000..6fb59198f89 --- /dev/null +++ b/ui/v2.5/src/components/Performers/PerformerCardGrid.tsx @@ -0,0 +1,39 @@ +import React, { useRef } from "react"; +import * as GQL from "src/core/generated-graphql"; +import { IPerformerCardExtraCriteria, PerformerCard } from "./PerformerCard"; +import { useContainerDimensions } from "../Shared/GridCard/GridCard"; + +interface IPerformerCardGrid { + performers: GQL.PerformerDataFragment[]; + selectedIds: Set; + zoomIndex: number; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + extraCriteria?: IPerformerCardExtraCriteria; +} + +export const PerformerCardGrid: React.FC = ({ + performers, + selectedIds, + onSelectChange, + extraCriteria, +}) => { + const componentRef = useRef(null); + const { width } = useContainerDimensions(componentRef); + return ( +
+ {performers.map((p) => ( + 0} + selected={selectedIds.has(p.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(p.id, selected, shiftKey) + } + extraCriteria={extraCriteria} + /> + ))} +
+ ); +}; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 6de18fef3a4..e390ab5741a 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Tabs, Tab, Col, Row } from "react-bootstrap"; import { useIntl } from "react-intl"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; @@ -40,15 +40,15 @@ import { faLink, } from "@fortawesome/free-solid-svg-icons"; import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons"; -import { IUIConfig } from "src/core/config"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; +import { ExternalLink } from "src/components/Shared/ExternalLink"; interface IProps { performer: GQL.PerformerDataFragment; - tabKey: TabKey; + tabKey?: TabKey; } interface IPerformerParams { @@ -66,8 +66,6 @@ const validTabs = [ ] as const; type TabKey = (typeof validTabs)[number]; -const defaultTab: TabKey = "default"; - function isTabKey(tab: string): tab is TabKey { return validTabs.includes(tab as TabKey); } @@ -79,7 +77,7 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { // Configuration settings const { configuration } = React.useContext(ConfigurationContext); - const uiConfig = configuration?.ui as IUIConfig | undefined; + const uiConfig = configuration?.ui; const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; const enableBackgroundImage = uiConfig?.enablePerformerBackgroundImage ?? false; @@ -133,20 +131,23 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { return ret; }, [performer]); - if (tabKey === defaultTab) { - tabKey = populatedDefaultTab; - } + const setTabKey = useCallback( + (newTabKey: string | null) => { + if (!newTabKey) newTabKey = populatedDefaultTab; + if (newTabKey === tabKey) return; - function setTabKey(newTabKey: string | null) { - if (!newTabKey || newTabKey === defaultTab) newTabKey = populatedDefaultTab; - if (newTabKey === tabKey) return; + if (isTabKey(newTabKey)) { + history.replace(`/performers/${performer.id}/${newTabKey}`); + } + }, + [populatedDefaultTab, tabKey, history, performer.id] + ); - if (newTabKey === populatedDefaultTab) { - history.replace(`/performers/${performer.id}`); - } else if (isTabKey(newTabKey)) { - history.replace(`/performers/${performer.id}/${newTabKey}`); + useEffect(() => { + if (!tabKey) { + setTabKey(populatedDefaultTab); } - } + }, [setTabKey, populatedDefaultTab, tabKey]); async function onAutoTag() { try { @@ -159,7 +160,7 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { useRatingKeybinds( true, - configuration?.ui?.ratingSystemOptions?.type, + configuration?.ui.ratingSystemOptions?.type, setRating ); @@ -336,18 +337,22 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { function maybeRenderHeaderBackgroundImage() { if (enableBackgroundImage && !isEditing && activeImage) { - return ( -
- - - {`${performer.name} - -
- ); + const activeImageURL = new URL(activeImage); + let isDefaultImage = activeImageURL.searchParams.get("default"); + if (!isDefaultImage) { + return ( +
+ + + {`${performer.name} + +
+ ); + } } } @@ -489,57 +494,50 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { {performer.url && ( - )} {(urls ?? []).map((url, index) => ( - ))} {performer.twitter && ( - )} {performer.instagram && ( - )} @@ -624,11 +622,7 @@ const PerformerLoader: React.FC> = ({ if (!data?.findPerformer) return ; - if (!tab) { - return ; - } - - if (!isTabKey(tab)) { + if (tab && !isTabKey(tab)) { return ( > = ({ ); } - return ; + return ( + + ); }; export default PerformerLoader; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 7fae0ded733..2a330a6f1f9 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -3,10 +3,16 @@ import { useIntl } from "react-intl"; import { TagLink } from "src/components/Shared/TagLink"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; -import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units"; import { DetailItem } from "src/components/Shared/DetailItem"; import { CountryFlag } from "src/components/Shared/CountryFlag"; import { StashIDPill } from "src/components/Shared/StashID"; +import { + FormatAge, + FormatCircumcised, + FormatHeight, + FormatPenisLength, + FormatWeight, +} from "../PerformerList"; interface IPerformerDetails { performer: GQL.PerformerDataFragment; @@ -51,123 +57,6 @@ export const PerformerDetailsPanel: React.FC = ({ ); } - const formatHeight = (height?: number | null) => { - if (!height) { - return ""; - } - - const [feet, inches] = cmToImperial(height); - - return ( - - - {intl.formatNumber(height, { - style: "unit", - unit: "centimeter", - unitDisplay: "short", - })} - - - {intl.formatNumber(feet, { - style: "unit", - unit: "foot", - unitDisplay: "narrow", - })} - {intl.formatNumber(inches, { - style: "unit", - unit: "inch", - unitDisplay: "narrow", - })} - - - ); - }; - - const formatAge = (birthdate?: string | null, deathdate?: string | null) => { - if (!birthdate) { - return ""; - } - - const age = TextUtils.age(birthdate, deathdate); - - return ( - - {age} - ({birthdate}) - - ); - }; - - const formatWeight = (weight?: number | null) => { - if (!weight) { - return ""; - } - - const lbs = kgToLbs(weight); - - return ( - - - {intl.formatNumber(weight, { - style: "unit", - unit: "kilogram", - unitDisplay: "short", - })} - - - {intl.formatNumber(lbs, { - style: "unit", - unit: "pound", - unitDisplay: "short", - })} - - - ); - }; - - const formatPenisLength = (penis_length?: number | null) => { - if (!penis_length) { - return ""; - } - - const inches = cmToInches(penis_length); - - return ( - - - {intl.formatNumber(penis_length, { - style: "unit", - unit: "centimeter", - unitDisplay: "short", - maximumFractionDigits: 2, - })} - - - {intl.formatNumber(inches, { - style: "unit", - unit: "inch", - unitDisplay: "narrow", - maximumFractionDigits: 2, - })} - - - ); - }; - - const formatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => { - if (!circumcised) { - return ""; - } - - return ( - - {intl.formatMessage({ - id: "circumcised_types." + performer.circumcised, - })} - - ); - }; - function maybeRenderExtraDetails() { if (!collapsed) { /* Remove extra urls provided in details since they will be present by perfomr name */ @@ -224,7 +113,7 @@ export const PerformerDetailsPanel: React.FC = ({ value={ !fullWidth ? TextUtils.age(performer.birthdate, performer.death_date) - : formatAge(performer.birthdate, performer.death_date) + : FormatAge(performer.birthdate, performer.death_date) } title={ !fullWidth @@ -266,22 +155,22 @@ export const PerformerDetailsPanel: React.FC = ({ /> = ({ // Network state const [isLoading, setIsLoading] = useState(false); + const [tags, setTags] = useState([]); + const Scrapers = useListPerformerScrapers(); const [queryableScrapers, setQueryableScrapers] = useState([]); @@ -161,6 +163,18 @@ export const PerformerEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + function onSetTags(items: Tag[]) { + setTags(items); + formik.setFieldValue( + "tag_ids", + items.map((item) => item.id) + ); + } + + useEffect(() => { + setTags(performer.tags ?? []); + }, [performer.tags]); + function translateScrapedGender(scrapedGender?: string) { if (!scrapedGender) { return; @@ -300,8 +314,15 @@ export const PerformerEditPanel: React.FC = ({ } if (state.tags) { // map tags to their ids and filter out those not found - const newTagIds = state.tags.map((t) => t.stored_id).filter((t) => t); - formik.setFieldValue("tag_ids", newTagIds); + onSetTags( + state.tags.map((p) => { + return { + id: p.stored_id!, + name: p.name ?? "", + aliases: [], + }; + }) + ); setNewTags(state.tags.filter((t) => !t.stored_id)); } @@ -725,13 +746,8 @@ export const PerformerEditPanel: React.FC = ({ - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ) - } - ids={formik.values.tag_ids} + onSelect={onSetTags} + values={tags} /> {renderNewTags()} diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index cdfc37f09d3..257079bce9b 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -19,9 +19,12 @@ import { DisplayMode } from "src/models/list-filter/types"; import { PerformerTagger } from "../Tagger/performers/PerformerTagger"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; -import { IPerformerCardExtraCriteria, PerformerCard } from "./PerformerCard"; +import { IPerformerCardExtraCriteria } from "./PerformerCard"; import { PerformerListTable } from "./PerformerListTable"; import { EditPerformersDialog } from "./EditPerformersDialog"; +import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units"; +import TextUtils from "src/utils/text"; +import { PerformerCardGrid } from "./PerformerCardGrid"; const PerformerItemList = makeItemList({ filterMode: GQL.FilterMode.Performers, @@ -34,6 +37,129 @@ const PerformerItemList = makeItemList({ }, }); +export const FormatHeight = (height?: number | null) => { + const intl = useIntl(); + if (!height) { + return ""; + } + + const [feet, inches] = cmToImperial(height); + + return ( + + + {intl.formatNumber(height, { + style: "unit", + unit: "centimeter", + unitDisplay: "short", + })} + + + {intl.formatNumber(feet, { + style: "unit", + unit: "foot", + unitDisplay: "narrow", + })} + {intl.formatNumber(inches, { + style: "unit", + unit: "inch", + unitDisplay: "narrow", + })} + + + ); +}; + +export const FormatAge = ( + birthdate?: string | null, + deathdate?: string | null +) => { + if (!birthdate) { + return ""; + } + const age = TextUtils.age(birthdate, deathdate); + + return ( + + {age} + ({birthdate}) + + ); +}; + +export const FormatWeight = (weight?: number | null) => { + const intl = useIntl(); + if (!weight) { + return ""; + } + + const lbs = kgToLbs(weight); + + return ( + + + {intl.formatNumber(weight, { + style: "unit", + unit: "kilogram", + unitDisplay: "short", + })} + + + {intl.formatNumber(lbs, { + style: "unit", + unit: "pound", + unitDisplay: "short", + })} + + + ); +}; + +export const FormatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => { + const intl = useIntl(); + if (!circumcised) { + return ""; + } + + return ( + + {intl.formatMessage({ + id: "circumcised_types." + circumcised, + })} + + ); +}; + +export const FormatPenisLength = (penis_length?: number | null) => { + const intl = useIntl(); + if (!penis_length) { + return ""; + } + + const inches = cmToInches(penis_length); + + return ( + + + {intl.formatNumber(penis_length, { + style: "unit", + unit: "centimeter", + unitDisplay: "short", + maximumFractionDigits: 2, + })} + + + {intl.formatNumber(inches, { + style: "unit", + unit: "inch", + unitDisplay: "narrow", + maximumFractionDigits: 2, + })} + + + ); +}; + interface IPerformerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; persistState?: PersistanceLevel; @@ -138,26 +264,21 @@ export const PerformerList: React.FC = ({ if (filter.displayMode === DisplayMode.Grid) { return ( -
- {result.data.findPerformers.performers.map((p) => ( - 0} - selected={selectedIds.has(p.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(p.id, selected, shiftKey) - } - extraCriteria={extraCriteria} - /> - ))} -
+ ); } if (filter.displayMode === DisplayMode.List) { return ( ); } diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx index 7b9e7615467..183cfbf3a42 100644 --- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx +++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx @@ -2,129 +2,399 @@ import React from "react"; import { useIntl } from "react-intl"; -import { Button, Table } from "react-bootstrap"; +import { Button } from "react-bootstrap"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import NavUtils from "src/utils/navigation"; import { faHeart } from "@fortawesome/free-solid-svg-icons"; -import { cmToImperial } from "src/utils/units"; +import { usePerformerUpdate } from "src/core/StashService"; +import { useTableColumns } from "src/hooks/useTableColumns"; +import { RatingSystem } from "../Shared/Rating/RatingSystem"; +import cx from "classnames"; +import { + FormatCircumcised, + FormatHeight, + FormatPenisLength, + FormatWeight, +} from "./PerformerList"; +import TextUtils from "src/utils/text"; +import { getCountryByISO } from "src/utils/country"; +import { IColumn, ListTable } from "../List/ListTable"; interface IPerformerListTableProps { performers: GQL.PerformerDataFragment[]; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } +const TABLE_NAME = "performers"; + export const PerformerListTable: React.FC = ( props: IPerformerListTableProps ) => { const intl = useIntl(); - const formatHeight = (height?: number | null) => { - if (!height) { - return ""; + const [updatePerformer] = usePerformerUpdate(); + + function setRating(v: number | null, performerId: string) { + if (performerId) { + updatePerformer({ + variables: { + input: { + id: performerId, + rating100: v, + }, + }, + }); + } + } + + function setFavorite(v: boolean, performerId: string) { + if (performerId) { + updatePerformer({ + variables: { + input: { + id: performerId, + favorite: v, + }, + }, + }); } + } - const [feet, inches] = cmToImperial(height); + const ImageCell = (performer: GQL.PerformerDataFragment) => ( + + {performer.name + + ); + + const NameCell = (performer: GQL.PerformerDataFragment) => ( + +
+ {performer.name} + {performer.disambiguation && ( + + {` (${performer.disambiguation})`} + + )} +
+ + ); + const AliasesCell = (performer: GQL.PerformerDataFragment) => { + let aliases = performer.alias_list ? performer.alias_list.join(", ") : ""; return ( - - - {intl.formatNumber(height, { - style: "unit", - unit: "centimeter", - unitDisplay: "short", - })} - - - {intl.formatNumber(feet, { - style: "unit", - unit: "foot", - unitDisplay: "narrow", - })} - {intl.formatNumber(inches, { - style: "unit", - unit: "inch", - unitDisplay: "narrow", - })} - + + {aliases} ); }; - const renderPerformerRow = (performer: GQL.PerformerDataFragment) => ( - - - - {performer.name - - - - -
- {performer.name} - {performer.disambiguation && ( - - {` (${performer.disambiguation})`} - - )} -
- - - {performer.alias_list ? performer.alias_list.join(", ") : ""} - - {performer.favorite && ( - - )} - - - -
{performer.scene_count}
- - - - -
{performer.image_count}
- - - - -
{performer.gallery_count}
- - - -
{performer.o_counter}
- - {performer.birthdate} - {!!performer.height_cm && formatHeight(performer.height_cm)} - + const GenderCell = (performer: GQL.PerformerDataFragment) => ( + <> + {performer.gender + ? intl.formatMessage({ id: "gender_types." + performer.gender }) + : ""} + + ); + + const RatingCell = (performer: GQL.PerformerDataFragment) => ( + setRating(value, performer.id)} + /> + ); + + const AgeCell = (performer: GQL.PerformerDataFragment) => ( + + {performer.birthdate + ? TextUtils.age(performer.birthdate, performer.death_date) + : ""} + + ); + + const DeathdateCell = (performer: GQL.PerformerDataFragment) => ( + <>{performer.death_date} + ); + + const FavoriteCell = (performer: GQL.PerformerDataFragment) => ( + + ); + + const CountryCell = (performer: GQL.PerformerDataFragment) => { + const { locale } = useIntl(); + return ( + + {getCountryByISO(performer.country, locale)} + + ); + }; + + const EthnicityCell = (performer: GQL.PerformerDataFragment) => ( + <>{performer.ethnicity} + ); + + const MeasurementsCell = (performer: GQL.PerformerDataFragment) => ( + {performer.measurements} + ); + + const FakeTitsCell = (performer: GQL.PerformerDataFragment) => ( + <>{performer.fake_tits} + ); + + const PenisLengthCell = (performer: GQL.PerformerDataFragment) => ( + <>{FormatPenisLength(performer.penis_length)} ); + const CircumcisedCell = (performer: GQL.PerformerDataFragment) => ( + <>{FormatCircumcised(performer.circumcised)} + ); + + const HairColorCell = (performer: GQL.PerformerDataFragment) => ( + {performer.hair_color} + ); + + const EyeColorCell = (performer: GQL.PerformerDataFragment) => ( + <>{performer.eye_color} + ); + + const HeightCell = (performer: GQL.PerformerDataFragment) => ( + <>{FormatHeight(performer.height_cm)} + ); + + const WeightCell = (performer: GQL.PerformerDataFragment) => ( + <>{FormatWeight(performer.weight)} + ); + + const CareerLengthCell = (performer: GQL.PerformerDataFragment) => ( + {performer.career_length} + ); + + const SceneCountCell = (performer: GQL.PerformerDataFragment) => ( + + {performer.scene_count} + + ); + + const GalleryCountCell = (performer: GQL.PerformerDataFragment) => ( + + {performer.gallery_count} + + ); + + const ImageCountCell = (performer: GQL.PerformerDataFragment) => ( + + {performer.image_count} + + ); + + const OCounterCell = (performer: GQL.PerformerDataFragment) => ( + <>{performer.o_counter} + ); + + interface IColumnSpec { + value: string; + label: string; + defaultShow?: boolean; + mandatory?: boolean; + render?: ( + scene: GQL.PerformerDataFragment, + index: number + ) => React.ReactNode; + } + + const allColumns: IColumnSpec[] = [ + { + value: "image", + label: intl.formatMessage({ id: "image" }), + defaultShow: true, + render: ImageCell, + }, + { + value: "name", + label: intl.formatMessage({ id: "name" }), + mandatory: true, + defaultShow: true, + render: NameCell, + }, + { + value: "aliases", + label: intl.formatMessage({ id: "aliases" }), + defaultShow: true, + render: AliasesCell, + }, + { + value: "gender", + label: intl.formatMessage({ id: "gender" }), + defaultShow: true, + render: GenderCell, + }, + { + value: "rating", + label: intl.formatMessage({ id: "rating" }), + defaultShow: true, + render: RatingCell, + }, + { + value: "age", + label: intl.formatMessage({ id: "age" }), + defaultShow: true, + render: AgeCell, + }, + { + value: "death_date", + label: intl.formatMessage({ id: "death_date" }), + render: DeathdateCell, + }, + { + value: "favourite", + label: intl.formatMessage({ id: "favourite" }), + defaultShow: true, + render: FavoriteCell, + }, + { + value: "country", + label: intl.formatMessage({ id: "country" }), + defaultShow: true, + render: CountryCell, + }, + { + value: "ethnicity", + label: intl.formatMessage({ id: "ethnicity" }), + defaultShow: true, + render: EthnicityCell, + }, + { + value: "hair_color", + label: intl.formatMessage({ id: "hair_color" }), + render: HairColorCell, + }, + { + value: "eye_color", + label: intl.formatMessage({ id: "eye_color" }), + render: EyeColorCell, + }, + { + value: "height_cm", + label: intl.formatMessage({ id: "height_cm" }), + render: HeightCell, + }, + { + value: "weight_kg", + label: intl.formatMessage({ id: "weight_kg" }), + render: WeightCell, + }, + { + value: "penis_length_cm", + label: intl.formatMessage({ id: "penis_length_cm" }), + render: PenisLengthCell, + }, + { + value: "circumcised", + label: intl.formatMessage({ id: "circumcised" }), + render: CircumcisedCell, + }, + { + value: "measurements", + label: intl.formatMessage({ id: "measurements" }), + render: MeasurementsCell, + }, + { + value: "fake_tits", + label: intl.formatMessage({ id: "fake_tits" }), + render: FakeTitsCell, + }, + { + value: "career_length", + label: intl.formatMessage({ id: "career_length" }), + defaultShow: true, + render: CareerLengthCell, + }, + { + value: "scene_count", + label: intl.formatMessage({ id: "scene_count" }), + defaultShow: true, + render: SceneCountCell, + }, + { + value: "gallery_count", + label: intl.formatMessage({ id: "gallery_count" }), + defaultShow: true, + render: GalleryCountCell, + }, + { + value: "image_count", + label: intl.formatMessage({ id: "image_count" }), + defaultShow: true, + render: ImageCountCell, + }, + { + value: "o_counter", + label: intl.formatMessage({ id: "o_counter" }), + defaultShow: true, + render: OCounterCell, + }, + ]; + + const defaultColumns = allColumns + .filter((col) => col.defaultShow) + .map((col) => col.value); + + const { selectedColumns, saveColumns } = useTableColumns( + TABLE_NAME, + defaultColumns + ); + + const columnRenderFuncs: Record< + string, + (scene: GQL.PerformerDataFragment, index: number) => React.ReactNode + > = {}; + allColumns.forEach((col) => { + if (col.render) { + columnRenderFuncs[col.value] = col.render; + } + }); + + function renderCell( + column: IColumn, + performer: GQL.PerformerDataFragment, + index: number + ) { + const render = columnRenderFuncs[column.value]; + + if (render) return render(performer, index); + } + return ( -
- - - - - - - - - - - - - - - {props.performers.map(renderPerformerRow)} -
- {intl.formatMessage({ id: "name" })}{intl.formatMessage({ id: "aliases" })}{intl.formatMessage({ id: "favourite" })}{intl.formatMessage({ id: "scene_count" })}{intl.formatMessage({ id: "image_count" })}{intl.formatMessage({ id: "gallery_count" })}{intl.formatMessage({ id: "o_counter" })}{intl.formatMessage({ id: "birthdate" })}{intl.formatMessage({ id: "height" })}
-
+ saveColumns(c)} + selectedIds={props.selectedIds} + onSelectChange={props.onSelectChange} + renderCell={renderCell} + /> ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerSelect.tsx b/ui/v2.5/src/components/Performers/PerformerSelect.tsx index fdebeae93a5..af3d611d4a8 100644 --- a/ui/v2.5/src/components/Performers/PerformerSelect.tsx +++ b/ui/v2.5/src/components/Performers/PerformerSelect.tsx @@ -15,7 +15,7 @@ import { } from "src/core/StashService"; import { ConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; -import { defaultMaxOptionsShown, IUIConfig } from "src/core/config"; +import { defaultMaxOptionsShown } from "src/core/config"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FilterSelectComponent, @@ -25,6 +25,9 @@ import { Option as SelectOption, } from "../Shared/FilterSelect"; import { useCompare } from "src/hooks/state"; +import { Link } from "react-router-dom"; +import { sortByRelevance } from "src/utils/query"; +import { PatchComponent } from "src/pluginApi"; export type SelectObject = { id: string; @@ -38,7 +41,7 @@ export type Performer = Pick< >; type Option = SelectOption; -export const PerformerSelect: React.FC< +const _PerformerSelect: React.FC< IFilterProps & IFilterValueProps > = (props) => { const [createPerformer] = usePerformerCreate(); @@ -46,7 +49,7 @@ export const PerformerSelect: React.FC< const { configuration } = React.useContext(ConfigurationContext); const intl = useIntl(); const maxOptionsShown = - (configuration?.ui as IUIConfig).maxOptionsShown ?? defaultMaxOptionsShown; + configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; const defaultCreatable = !configuration?.interface.disableDropdownCreate.performer ?? true; @@ -58,7 +61,12 @@ export const PerformerSelect: React.FC< filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; const query = await queryFindPerformersForSelect(filter); - return query.data.findPerformers.performers.map((performer) => ({ + return sortByRelevance( + input, + query.data.findPerformers.performers, + (p) => p.name, + (p) => p.alias_list + ).map((performer) => ({ value: performer.id, object: performer, })); @@ -85,11 +93,10 @@ export const PerformerSelect: React.FC< thisOptionProps = { ...optionProps, children: ( - - + - + {name} {object.disambiguation && ( {` (${object.disambiguation})`} @@ -119,7 +126,14 @@ export const PerformerSelect: React.FC< thisOptionProps = { ...optionProps, - children: object.name, + children: ( + <> + {object.name} + {object.disambiguation && ( + {` (${object.disambiguation})`} + )} + + ), }; return ; @@ -223,9 +237,14 @@ export const PerformerSelect: React.FC< ); }; -export const PerformerIDSelect: React.FC< - IFilterProps & IFilterIDProps -> = (props) => { +export const PerformerSelect = PatchComponent( + "PerformerSelect", + _PerformerSelect +); + +const _PerformerIDSelect: React.FC> = ( + props +) => { const { ids, onSelect: onSelectValues } = props; const [values, setValues] = useState([]); @@ -270,3 +289,8 @@ export const PerformerIDSelect: React.FC< return ; }; + +export const PerformerIDSelect = PatchComponent( + "PerformerIDSelect", + _PerformerIDSelect +); diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 6ca07e5bec7..ee81339a661 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -27,22 +27,9 @@ color: #ff7373; } - .link { - color: rgb(191, 204, 214); - } - .instagram { color: pink; } - - .icon-link { - padding: 0; - - a { - display: inline-block; - padding: $btn-padding-y $btn-padding-x; - } - } } .rating-number .form-control { @@ -76,7 +63,7 @@ } &-image { - height: 30rem; + aspect-ratio: 2/3; min-width: 11.25rem; object-fit: cover; object-position: top; @@ -206,11 +193,22 @@ } } +.favourite-data .favorite { + color: #ff7373; +} + +.performer-table .height-imperial, +.performer-table .weight-imperial, +.performer-table .penis-length-imperial, .performer-disambiguation { color: $text-muted; font-size: 0.875em; } +.performer-table .age-data span { + border-bottom: 1px dotted #f5f8fa; +} + .performer-result .performer-details > span { &::after { content: " • "; @@ -222,8 +220,14 @@ } .performer-select { + .performer-disambiguation { + color: initial; + white-space: pre; + } + .alias { font-weight: bold; + white-space: pre; } .performer-select-image { @@ -232,3 +236,9 @@ max-width: 50px; } } + +.edit-performers-dialog .modal-body { + max-height: calc(100vh - 12rem); + overflow-y: auto; + padding-right: 1.5rem; +} diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index b7df466c08e..e8bd76382dd 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -38,13 +38,13 @@ import { import { SceneInteractiveStatus } from "src/hooks/Interactive/status"; import { languageMap } from "src/utils/caption"; import { VIDEO_PLAYER_ID } from "./util"; -import { IUIConfig } from "src/core/config"; // @ts-ignore import airplay from "@silvermine/videojs-airplay"; // @ts-ignore import chromecast from "@silvermine/videojs-chromecast"; import abLoopPlugin from "videojs-abloop"; +import ScreenUtils from "src/utils/screen"; // register videojs plugins airplay(videojs); @@ -222,7 +222,7 @@ export const ScenePlayer: React.FC = ({ }) => { const { configuration } = useContext(ConfigurationContext); const interfaceConfig = configuration?.interface; - const uiConfig = configuration?.ui as IUIConfig | undefined; + const uiConfig = configuration?.ui; const videoRef = useRef(null); const [_player, setPlayer] = useState(); const sceneId = useRef(); @@ -284,7 +284,7 @@ export const ScenePlayer: React.FC = ({ } const onResize = () => { - const show = window.innerHeight >= 450 && window.innerWidth >= 576; + const show = window.innerHeight >= 450 && !ScreenUtils.isMobile(); setShowScrubber(show); }; onResize(); @@ -491,7 +491,6 @@ export const ScenePlayer: React.FC = ({ if (!player) return; function onplay(this: VideoJsPlayer) { - this.persistVolume().enabled = true; if (scene.interactive && interactiveReady.current) { interactiveClient.play(this.currentTime()); } @@ -554,7 +553,9 @@ export const ScenePlayer: React.FC = ({ enterOnRotate: true, exitOnRotate: true, lockOnRotate: true, - lockToLandscapeOnEnter: isLandscape, + lockToLandscapeOnEnter: uiConfig?.disableMobileMediaAutoRotateEnabled + ? false + : isLandscape, }, touchControls: { disabled: true, @@ -680,6 +681,7 @@ export const ScenePlayer: React.FC = ({ autoplay, interfaceConfig?.autostartVideo, uiConfig?.alwaysStartFromBeginning, + uiConfig?.disableMobileMediaAutoRotateEnabled, _initialTimestamp, ]); @@ -767,13 +769,7 @@ export const ScenePlayer: React.FC = ({ return; } - player.play()?.catch(() => { - // Browser probably blocking non-muted autoplay, so mute and try again - player.persistVolume().enabled = false; - player.muted(true); - - player.play(); - }); + player.play(); auto.current = false; }, [getPlayer, scene, ready, interactiveClient, currentScript]); diff --git a/ui/v2.5/src/components/ScenePlayer/source-selector.ts b/ui/v2.5/src/components/ScenePlayer/source-selector.ts index c94e2fbae27..3a8337b1369 100644 --- a/ui/v2.5/src/components/ScenePlayer/source-selector.ts +++ b/ui/v2.5/src/components/ScenePlayer/source-selector.ts @@ -145,7 +145,11 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") { if (!error) return; // Only try next source if media was unsupported - if (error.code !== MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) return; + if ( + error.code !== MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED && + error.code !== MediaError.MEDIA_ERR_DECODE + ) + return; const currentSource = player.currentSource() as ISource; console.log(`Source '${currentSource.label}' is unsupported`); diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index fc0591264dd..37c814bbedd 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap"; import { Link, useHistory } from "react-router-dom"; import cx from "classnames"; @@ -18,7 +18,7 @@ import TextUtils from "src/utils/text"; import { SceneQueue } from "src/models/sceneQueue"; import { ConfigurationContext } from "src/hooks/Config"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; -import { GridCard } from "../Shared/GridCard"; +import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; import { RatingBanner } from "../Shared/RatingBanner"; import { FormattedNumber } from "react-intl"; import { @@ -32,6 +32,8 @@ import { import { objectPath, objectTitle } from "src/core/files"; import { PreviewScrubber } from "./PreviewScrubber"; import { PatchComponent } from "src/pluginApi"; +import ScreenUtils from "src/utils/screen"; +import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; interface IScenePreviewProps { isPortrait: boolean; @@ -95,6 +97,8 @@ export const ScenePreview: React.FC = ({ interface ISceneCardProps { scene: GQL.SlimSceneDataFragment; + containerWidth?: number; + previewHeight?: number; index?: number; queue?: SceneQueue; compact?: boolean; @@ -325,44 +329,7 @@ const SceneCardDetails = PatchComponent( const SceneCardOverlays = PatchComponent( "SceneCard.Overlays", (props: ISceneCardProps) => { - const { configuration } = React.useContext(ConfigurationContext); - - function renderStudioThumbnail() { - const studioImage = props.scene.studio?.image_path; - const studioName = props.scene.studio?.name; - - if (configuration?.interface.showStudioAsText || !studioImage) { - return studioName; - } - - const studioImageURL = new URL(studioImage); - if (studioImageURL.searchParams.get("default") === "true") { - return studioName; - } - - return ( - {studioName} - ); - } - - function maybeRenderSceneStudioOverlay() { - if (!props.scene.studio) return; - - return ( -
- - {renderStudioThumbnail()} - -
- ); - } - - return <>{maybeRenderSceneStudioOverlay()}; + return ; } ); @@ -461,6 +428,7 @@ export const SceneCard = PatchComponent( "SceneCard", (props: ISceneCardProps) => { const { configuration } = React.useContext(ConfigurationContext); + const [cardWidth, setCardWidth] = useState(); const file = useMemo( () => (props.scene.files.length > 0 ? props.scene.files[0] : undefined), @@ -483,6 +451,36 @@ export const SceneCard = PatchComponent( return ""; } + useEffect(() => { + if ( + !props.containerWidth || + props.zoomIndex === undefined || + ScreenUtils.isMobile() + ) + return; + + let zoomValue = props.zoomIndex; + let preferredCardWidth: number; + switch (zoomValue) { + case 0: + preferredCardWidth = 240; + break; + case 1: + preferredCardWidth = 340; // this value is intentionally higher than 320 + break; + case 2: + preferredCardWidth = 480; + break; + case 3: + preferredCardWidth = 640; + } + let fittedCardWidth = calculateCardWidth( + props.containerWidth, + preferredCardWidth! + ); + setCardWidth(fittedCardWidth); + }, [props, props.containerWidth, props.zoomIndex]); + const cont = configuration?.interface.continuePlaylistDefault ?? false; const sceneLink = props.queue @@ -497,6 +495,7 @@ export const SceneCard = PatchComponent( className={`scene-card ${zoomIndex()} ${filelessClass()}`} url={sceneLink} title={objectTitle(props.scene)} + width={cardWidth} linkClassName="scene-card-link" thumbnailSectionClassName="video-section" resumeTime={props.scene.resume_time ?? undefined} diff --git a/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx b/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx index 6852bf07625..26d3ce72824 100644 --- a/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx @@ -1,7 +1,8 @@ -import React from "react"; +import React, { useRef } from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneQueue } from "src/models/sceneQueue"; import { SceneCard } from "./SceneCard"; +import { useContainerDimensions } from "../Shared/GridCard/GridCard"; interface ISceneCardsGrid { scenes: GQL.SlimSceneDataFragment[]; @@ -18,11 +19,14 @@ export const SceneCardsGrid: React.FC = ({ zoomIndex, onSelectChange, }) => { + const componentRef = useRef(null); + const { width } = useContainerDimensions(componentRef); return ( -
+
{scenes.map((scene, index) => ( = ({ streamURL.hash = `Intent;action=android.intent.action.VIEW;scheme=${scheme};type=video/mp4;S.title=${encodeURI( title )};end`; - streamURL.protocol = "intent"; - url = streamURL.toString(); + + // #4401 - not allowed to set the protocol from a "special" protocol to a non-special protocol + url = streamURL + .toString() + .replace(new RegExp(`^${streamURL.protocol}`), "intent:"); } else if (isAppleDevice) { streamURL.host = "x-callback-url"; streamURL.port = ""; streamURL.pathname = "stream"; streamURL.search = `url=${encodeURIComponent(stream)}`; - streamURL.protocol = "vlc-x-callback"; - url = streamURL.toString(); + + // #4401 - not allowed to set the protocol from a "special" protocol to a non-special protocol + url = streamURL + .toString() + .replace(new RegExp(`^${streamURL.protocol}`), "vlc-x-callback:"); } return ( diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx index 01d857b167a..6aba56a3772 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx @@ -15,7 +15,7 @@ import { objectTitle } from "src/core/files"; import { QueuedScene } from "src/models/sceneQueue"; export interface IPlaylistViewer { - scenes?: QueuedScene[]; + scenes: QueuedScene[]; currentID?: string; start?: number; continue?: boolean; @@ -47,7 +47,7 @@ export const QueueViewer: React.FC = ({ const [lessLoading, setLessLoading] = useState(false); const [moreLoading, setMoreLoading] = useState(false); - const currentIndex = scenes?.findIndex((s) => s.id === currentID) ?? 0; + const currentIndex = scenes.findIndex((s) => s.id === currentID); useEffect(() => { setLessLoading(false); @@ -94,10 +94,17 @@ export const QueueViewer: React.FC = ({ src={scene.paths.screenshot ?? ""} />
-
- - {objectTitle(scene)} +
+ {objectTitle(scene)} + {scene?.studio?.name} + + {scene?.performers + ?.map(function (performer) { + return performer.name; + }) + .join(", ")} + {scene?.date}
@@ -130,7 +137,7 @@ export const QueueViewer: React.FC = ({ ) : ( "" )} - {currentIndex < (scenes ?? []).length - 1 || hasMoreScenes ? ( + {currentIndex < scenes.length - 1 || hasMoreScenes ? (
) : undefined} -
    {(scenes ?? []).map(renderPlaylistEntry)}
+
    {scenes.map(renderPlaylistEntry)}
{hasMoreScenes ? (
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 01b752d8734..5aa83d5456a 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -19,12 +19,7 @@ import { mutateReloadScrapers, queryScrapeSceneQueryFragment, } from "src/core/StashService"; -import { - TagSelect, - StudioSelect, - GallerySelect, - MovieSelect, -} from "src/components/Shared/Select"; +import { MovieSelect } from "src/components/Shared/Select"; import { Icon } from "src/components/Shared/Icon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { ImageInput } from "src/components/Shared/ImageInput"; @@ -52,6 +47,9 @@ import { PerformerSelect, } from "src/components/Performers/PerformerSelect"; import { formikUtils } from "src/utils/form"; +import { Tag, TagSelect } from "src/components/Tags/TagSelect"; +import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; +import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -76,10 +74,10 @@ export const SceneEditPanel: React.FC = ({ const intl = useIntl(); const Toast = useToast(); - const [galleries, setGalleries] = useState<{ id: string; title: string }[]>( - [] - ); + const [galleries, setGalleries] = useState([]); const [performers, setPerformers] = useState([]); + const [tags, setTags] = useState([]); + const [studio, setStudio] = useState(null); const Scrapers = useListSceneScrapers(); const [fragmentScrapers, setFragmentScrapers] = useState([]); @@ -96,6 +94,8 @@ export const SceneEditPanel: React.FC = ({ scene.galleries?.map((g) => ({ id: g.id, title: galleryTitle(g), + files: g.files, + folder: g.folder, })) ?? [] ); }, [scene.galleries]); @@ -104,6 +104,14 @@ export const SceneEditPanel: React.FC = ({ setPerformers(scene.performers ?? []); }, [scene.performers]); + useEffect(() => { + setTags(scene.tags ?? []); + }, [scene.tags]); + + useEffect(() => { + setStudio(scene.studio ?? null); + }, [scene.studio]); + const { configuration: stashConfig } = React.useContext(ConfigurationContext); // Network state @@ -181,12 +189,7 @@ export const SceneEditPanel: React.FC = ({ formik.setFieldValue("rating100", v); } - interface IGallerySelectValue { - id: string; - title: string; - } - - function onSetGalleries(items: IGallerySelectValue[]) { + function onSetGalleries(items: Gallery[]) { setGalleries(items); formik.setFieldValue( "gallery_ids", @@ -202,9 +205,22 @@ export const SceneEditPanel: React.FC = ({ ); } + function onSetTags(items: Tag[]) { + setTags(items); + formik.setFieldValue( + "tag_ids", + items.map((item) => item.id) + ); + } + + function onSetStudio(item: Studio | null) { + setStudio(item); + formik.setFieldValue("studio_id", item ? item.id : null); + } + useRatingKeybinds( isVisible, - stashConfig?.ui?.ratingSystemOptions?.type, + stashConfig?.ui.ratingSystemOptions?.type, setRating ); @@ -381,6 +397,8 @@ export const SceneEditPanel: React.FC = ({ return ( = ({ } if (updatedScene.studio && updatedScene.studio.stored_id) { - formik.setFieldValue("studio_id", updatedScene.studio.stored_id); + onSetStudio({ + id: updatedScene.studio.stored_id, + name: updatedScene.studio.name ?? "", + aliases: [], + }); } if (updatedScene.performers && updatedScene.performers.length > 0) { @@ -578,8 +600,15 @@ export const SceneEditPanel: React.FC = ({ }); if (idTags.length > 0) { - const newIds = idTags.map((p) => p.stored_id); - formik.setFieldValue("tag_ids", newIds as string[]); + onSetTags( + idTags.map((p) => { + return { + id: p.stored_id!, + name: p.name ?? "", + aliases: [], + }; + }) + ); } } @@ -692,7 +721,7 @@ export const SceneEditPanel: React.FC = ({ const title = intl.formatMessage({ id: "galleries" }); const control = ( onSetGalleries(items)} isMulti /> @@ -705,13 +734,8 @@ export const SceneEditPanel: React.FC = ({ const title = intl.formatMessage({ id: "studio" }); const control = ( - formik.setFieldValue( - "studio_id", - items.length > 0 ? items[0]?.id : null - ) - } - ids={formik.values.studio_id ? [formik.values.studio_id] : []} + onSelect={(items) => onSetStudio(items.length > 0 ? items[0] : null)} + values={studio ? [studio] : []} /> ); @@ -748,13 +772,8 @@ export const SceneEditPanel: React.FC = ({ const control = ( - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ) - } - ids={formik.values.tag_ids} + onSelect={onSetTags} + values={tags} hoverPlacement="right" /> ); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index 6da4c735ad6..a601b298ca4 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -85,7 +85,7 @@ const FileInfoPanel: React.FC = ( url={NavUtils.makeScenesPHashMatchUrl(phash?.value)} target="_self" truncate - trusted + internal /> = ({ const [sceneMarkerDestroy] = useSceneMarkerDestroy(); const Toast = useToast(); + const [primaryTag, setPrimaryTag] = useState(); + const [tags, setTags] = useState([]); + const isNew = marker === undefined; const schema = yup.object({ @@ -68,6 +68,34 @@ export const SceneMarkerForm: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + function onSetPrimaryTag(item: Tag) { + setPrimaryTag(item); + formik.setFieldValue("primary_tag_id", item.id); + } + + function onSetTags(items: Tag[]) { + setTags(items); + formik.setFieldValue( + "tag_ids", + items.map((item) => item.id) + ); + } + + useEffect(() => { + setPrimaryTag( + marker?.primary_tag ? { ...marker.primary_tag, aliases: [] } : undefined + ); + }, [marker?.primary_tag]); + + useEffect(() => { + setTags( + marker?.tags.map((t) => ({ + ...t, + aliases: [], + })) ?? [] + ); + }, [marker?.tags]); + async function onSave(input: InputValues) { try { if (isNew) { @@ -105,11 +133,6 @@ export const SceneMarkerForm: React.FC = ({ } } - async function onSetPrimaryTagID(tags: SelectObject[]) { - await formik.setFieldValue("primary_tag_id", tags[0]?.id); - await formik.setFieldTouched("primary_tag_id", true); - } - const splitProps = { labelProps: { column: true, @@ -145,14 +168,12 @@ export const SceneMarkerForm: React.FC = ({ } function renderPrimaryTagField() { - const primaryTagId = formik.values.primary_tag_id; - const title = intl.formatMessage({ id: "primary_tag" }); const control = ( <> onSetPrimaryTag(t[0])} + values={primaryTag ? [primaryTag] : []} hoverPlacement="right" /> {formik.touched.primary_tag_id && ( @@ -189,13 +210,8 @@ export const SceneMarkerForm: React.FC = ({ const control = ( - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ) - } - ids={formik.values.tag_ids} + onSelect={onSetTags} + values={tags} hoverPlacement="right" /> ); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneQueryModal.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneQueryModal.tsx index dd9e7592a25..a11f85028bd 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneQueryModal.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneQueryModal.tsx @@ -44,11 +44,7 @@ const SceneSearchResultDetails: React.FC = ({ {scene.tags?.map((tag) => ( - + {tag.name} ))} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index a0cc2257af6..e70f1b6102f 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -14,6 +14,7 @@ import { Performer } from "src/components/Performers/PerformerSelect"; import { IHasStoredID, sortStoredIdObjects } from "src/utils/data"; import { ObjectListScrapeResult, + ObjectScrapeResult, ScrapeResult, } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { @@ -28,10 +29,14 @@ import { useCreateScrapedStudio, useCreateScrapedTag, } from "src/components/Shared/ScrapeDialog/createObjects"; +import { Tag } from "src/components/Tags/TagSelect"; +import { Studio } from "src/components/Studios/StudioSelect"; interface ISceneScrapeDialogProps { scene: Partial; + sceneStudio: Studio | null; scenePerformers: Performer[]; + sceneTags: Tag[]; scraped: GQL.ScrapedScene; endpoint?: string; @@ -40,7 +45,9 @@ interface ISceneScrapeDialogProps { export const SceneScrapeDialog: React.FC = ({ scene, + sceneStudio, scenePerformers, + sceneTags, scraped, onClose, endpoint, @@ -67,8 +74,16 @@ export const SceneScrapeDialog: React.FC = ({ const [director, setDirector] = useState>( new ScrapeResult(scene.director, scraped.director) ); - const [studio, setStudio] = useState>( - new ScrapeResult(scene.studio_id, scraped.studio?.stored_id) + const [studio, setStudio] = useState>( + new ObjectScrapeResult( + sceneStudio + ? { + stored_id: sceneStudio.id, + name: sceneStudio.name, + } + : undefined, + scraped.studio?.stored_id ? scraped.studio : undefined + ) ); const [newStudio, setNewStudio] = useState( scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined @@ -146,10 +161,15 @@ export const SceneScrapeDialog: React.FC = ({ scraped.movies?.filter((t) => !t.stored_id) ?? [] ); - const [tags, setTags] = useState>( - new ScrapeResult( - sortIdList(scene.tag_ids), - mapStoredIdObjects(scraped.tags ?? undefined) + const [tags, setTags] = useState>( + new ObjectListScrapeResult( + sortStoredIdObjects( + sceneTags.map((t) => ({ + stored_id: t.id, + name: t.name, + })) + ), + sortStoredIdObjects(scraped.tags ?? undefined) ) ); const [newTags, setNewTags] = useState( @@ -227,12 +247,7 @@ export const SceneScrapeDialog: React.FC = ({ urls: urls.getNewValue(), date: date.getNewValue(), director: director.getNewValue(), - studio: newStudioValue - ? { - stored_id: newStudioValue, - name: "", - } - : undefined, + studio: newStudioValue, performers: performers.getNewValue(), movies: movies.getNewValue()?.map((m) => { return { @@ -240,12 +255,7 @@ export const SceneScrapeDialog: React.FC = ({ name: "", }; }), - tags: tags.getNewValue()?.map((m) => { - return { - stored_id: m, - name: "", - }; - }), + tags: tags.getNewValue(), details: details.getNewValue(), image: image.getNewValue(), remote_site_id: stashID.getNewValue(), diff --git a/ui/v2.5/src/components/Scenes/SceneListTable.tsx b/ui/v2.5/src/components/Scenes/SceneListTable.tsx index 1af99476f18..2e2c969fbb2 100644 --- a/ui/v2.5/src/components/Scenes/SceneListTable.tsx +++ b/ui/v2.5/src/components/Scenes/SceneListTable.tsx @@ -1,13 +1,16 @@ import React from "react"; -import { Table, Form } from "react-bootstrap"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; -import { FormattedMessage } from "react-intl"; +import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import SceneQueue from "src/models/sceneQueue"; +import { RatingSystem } from "../Shared/Rating/RatingSystem"; +import { useSceneUpdate } from "src/core/StashService"; +import { IColumn, ListTable } from "../List/ListTable"; +import { useTableColumns } from "src/hooks/useTableColumns"; interface ISceneListTableProps { scenes: GQL.SlimSceneDataFragment[]; @@ -16,140 +19,395 @@ interface ISceneListTableProps { onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } +const TABLE_NAME = "scenes"; + export const SceneListTable: React.FC = ( props: ISceneListTableProps ) => { - const renderTags = (tags: Partial[]) => - tags.map((tag) => ( - -
{tag.name}
- - )); + const intl = useIntl(); - const renderPerformers = (performers: Partial[]) => - performers.map((performer) => ( - -
{performer.name}
- - )); - - const renderMovies = (scene: GQL.SlimSceneDataFragment) => - scene.movies.map((sceneMovie) => ( - -
{sceneMovie.movie.name}
- - )); + const [updateScene] = useSceneUpdate(); + + function setRating(v: number | null, sceneId: string) { + if (sceneId) { + updateScene({ + variables: { + input: { + id: sceneId, + rating100: v, + }, + }, + }); + } + } + + const CoverImageCell = (scene: GQL.SlimSceneDataFragment, index: number) => { + const title = objectTitle(scene); + const sceneLink = props.queue + ? props.queue.makeLink(scene.id, { sceneIndex: index }) + : `/scenes/${scene.id}`; - const renderGalleries = (scene: GQL.SlimSceneDataFragment) => - scene.galleries.map((gallery) => ( - -
{galleryTitle(gallery)}
+ return ( + + {title} - )); + ); + }; - const renderSceneRow = (scene: GQL.SlimSceneDataFragment, index: number) => { + const TitleCell = (scene: GQL.SlimSceneDataFragment, index: number) => { + const title = objectTitle(scene); const sceneLink = props.queue ? props.queue.makeLink(scene.id, { sceneIndex: index }) : `/scenes/${scene.id}`; - let shiftKey = false; + return ( + + {title} + + ); + }; + const DateCell = (scene: GQL.SlimSceneDataFragment) => <>{scene.date}; + + const RatingCell = (scene: GQL.SlimSceneDataFragment) => ( + setRating(value, scene.id)} + /> + ); + + const DurationCell = (scene: GQL.SlimSceneDataFragment) => { const file = scene.files.length > 0 ? scene.files[0] : undefined; + return file?.duration && TextUtils.secondsToTimestamp(file.duration); + }; - const title = objectTitle(scene); - return ( - - - - - - - {title} + const TagCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.tags.map((tag) => ( +
  • + + {tag.name} - - - -
    {title}
    +
  • + ))} +
+ ); + + const PerformersCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.performers.map((performer) => ( +
  • + + {performer.name} - - {scene.rating100 ? scene.rating100 : ""} - {file?.duration && TextUtils.secondsToTimestamp(file.duration)} - {renderTags(scene.tags)} - {renderPerformers(scene.performers)} - - {scene.studio && ( - -
    {scene.studio.name}
    - - )} - - {renderMovies(scene)} - {renderGalleries(scene)} - - ); +
  • + ))} +
+ ); + + const StudioCell = (scene: GQL.SlimSceneDataFragment) => { + if (scene.studio) { + return ( + + {scene.studio.name} + + ); + } }; + const MovieCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.movies.map((sceneMovie) => ( +
  • + + {sceneMovie.movie.name} + +
  • + ))} +
+ ); + + const GalleriesCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.galleries.map((gallery) => ( +
  • + + {galleryTitle(gallery)} + +
  • + ))} +
+ ); + + const PlayCountCell = (scene: GQL.SlimSceneDataFragment) => ( + + ); + + const PlayDurationCell = (scene: GQL.SlimSceneDataFragment) => ( + <>{TextUtils.secondsToTimestamp(scene.play_duration ?? 0)} + ); + + const ResolutionCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.files.map((file) => ( +
  • + {TextUtils.resolution(file?.width, file?.height)} +
  • + ))} +
+ ); + + function renderFileSize(file: { size: number | undefined }) { + const { size, unit } = TextUtils.fileSize(file.size); + + return ( + + ); + } + + const FileSizeCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.files.map((file) => ( +
  • {renderFileSize(file)}
  • + ))} +
+ ); + + const FrameRateCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.files.map((file) => ( +
  • + + + +
  • + ))} +
+ ); + + const BitRateCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.files.map((file) => ( +
  • + + + +
  • + ))} +
+ ); + + const AudioCodecCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.files.map((file) => ( +
  • + {file.audio_codec} +
  • + ))} +
+ ); + + const VideoCodecCell = (scene: GQL.SlimSceneDataFragment) => ( +
    + {scene.files.map((file) => ( +
  • + {file.video_codec} +
  • + ))} +
+ ); + + interface IColumnSpec { + value: string; + label: string; + defaultShow?: boolean; + mandatory?: boolean; + render?: ( + scene: GQL.SlimSceneDataFragment, + index: number + ) => React.ReactNode; + } + + const allColumns: IColumnSpec[] = [ + { + value: "cover_image", + label: intl.formatMessage({ id: "cover_image" }), + defaultShow: true, + render: CoverImageCell, + }, + { + value: "title", + label: intl.formatMessage({ id: "title" }), + defaultShow: true, + mandatory: true, + render: TitleCell, + }, + { + value: "date", + label: intl.formatMessage({ id: "date" }), + defaultShow: true, + render: DateCell, + }, + { + value: "rating", + label: intl.formatMessage({ id: "rating" }), + defaultShow: true, + render: RatingCell, + }, + { + value: "scene_code", + label: intl.formatMessage({ id: "scene_code" }), + render: (s) => <>{s.code}, + }, + { + value: "duration", + label: intl.formatMessage({ id: "duration" }), + defaultShow: true, + render: DurationCell, + }, + { + value: "studio", + label: intl.formatMessage({ id: "studio" }), + defaultShow: true, + render: StudioCell, + }, + { + value: "performers", + label: intl.formatMessage({ id: "performers" }), + defaultShow: true, + render: PerformersCell, + }, + { + value: "tags", + label: intl.formatMessage({ id: "tags" }), + defaultShow: true, + render: TagCell, + }, + { + value: "movies", + label: intl.formatMessage({ id: "movies" }), + defaultShow: true, + render: MovieCell, + }, + { + value: "galleries", + label: intl.formatMessage({ id: "galleries" }), + defaultShow: true, + render: GalleriesCell, + }, + { + value: "play_count", + label: intl.formatMessage({ id: "play_count" }), + render: PlayCountCell, + }, + { + value: "play_duration", + label: intl.formatMessage({ id: "play_duration" }), + render: PlayDurationCell, + }, + { + value: "o_counter", + label: intl.formatMessage({ id: "o_counter" }), + render: (s) => <>{s.o_counter}, + }, + { + value: "resolution", + label: intl.formatMessage({ id: "resolution" }), + render: ResolutionCell, + }, + { + value: "filesize", + label: intl.formatMessage({ id: "filesize" }), + render: FileSizeCell, + }, + { + value: "framerate", + label: intl.formatMessage({ id: "framerate" }), + render: FrameRateCell, + }, + { + value: "bitrate", + label: intl.formatMessage({ id: "bitrate" }), + render: BitRateCell, + }, + { + value: "video_codec", + label: intl.formatMessage({ id: "video_codec" }), + render: VideoCodecCell, + }, + { + value: "audio_codec", + label: intl.formatMessage({ id: "audio_codec" }), + render: AudioCodecCell, + }, + ]; + + const defaultColumns = allColumns + .filter((col) => col.defaultShow) + .map((col) => col.value); + + const { selectedColumns, saveColumns } = useTableColumns( + TABLE_NAME, + defaultColumns + ); + + const columnRenderFuncs: Record< + string, + (scene: GQL.SlimSceneDataFragment, index: number) => React.ReactNode + > = {}; + allColumns.forEach((col) => { + if (col.render) { + columnRenderFuncs[col.value] = col.render; + } + }); + + function renderCell( + column: IColumn, + scene: GQL.SlimSceneDataFragment, + index: number + ) { + const render = columnRenderFuncs[column.value]; + + if (render) return render(scene, index); + } + return ( -
- - - - - - - - - - - - - - {props.scenes.map(renderSceneRow)} -
- - - - - - - - - - - - - - - - - -
-
+ saveColumns(c)} + selectedIds={props.selectedIds} + onSelectChange={props.onSelectChange} + renderCell={renderCell} + /> ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index e7e9a8a4d60..84a028526d4 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -1,5 +1,5 @@ import { Form, Col, Row, Button, FormControl } from "react-bootstrap"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; @@ -20,7 +20,6 @@ import { ScrapedTextAreaRow, } from "../Shared/ScrapeDialog/ScrapeDialog"; import { clone, uniq } from "lodash-es"; -import { galleryTitle } from "src/core/galleries"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { ModalComponent } from "../Shared/Modal"; import { IHasStoredID, sortStoredIdObjects } from "src/utils/data"; @@ -87,8 +86,17 @@ const SceneMergeDetails: React.FC = ({ new ScrapeResult(dest.play_duration) ); - const [studio, setStudio] = useState>( - new ScrapeResult(dest.studio?.id) + function idToStoredID(o: { id: string; name: string }) { + return { + stored_id: o.id, + name: o.name, + }; + } + + const [studio, setStudio] = useState>( + new ScrapeResult( + dest.studio ? idToStoredID(dest.studio) : undefined + ) ); function sortIdList(idList?: string[] | null) { @@ -105,14 +113,7 @@ const SceneMergeDetails: React.FC = ({ return ret; } - function idToStoredID(o: { id: string; name: string }) { - return { - stored_id: o.id, - name: o.name, - }; - } - - function uniqIDStoredIDs(objs: IHasStoredID[]) { + function uniqIDStoredIDs(objs: T[]) { return objs.filter((o, i) => { return objs.findIndex((oo) => oo.stored_id === o.stored_id) === i; }); @@ -130,8 +131,10 @@ const SceneMergeDetails: React.FC = ({ new ScrapeResult(sortIdList(dest.movies.map((p) => p.movie.id))) ); - const [tags, setTags] = useState>( - new ScrapeResult(sortIdList(dest.tags.map((t) => t.id))) + const [tags, setTags] = useState>( + new ObjectListScrapeResult( + sortStoredIdObjects(dest.tags.map(idToStoredID)) + ) ); const [details, setDetails] = useState>( @@ -195,10 +198,18 @@ const SceneMergeDetails: React.FC = ({ setDate( new ScrapeResult(dest.date, sources.find((s) => s.date)?.date, !dest.date) ); + + const foundStudio = sources.find((s) => s.studio)?.studio; + setStudio( - new ScrapeResult( - dest.studio?.id, - sources.find((s) => s.studio)?.studio?.id, + new ScrapeResult( + dest.studio ? idToStoredID(dest.studio) : undefined, + foundStudio + ? { + stored_id: foundStudio.id, + name: foundStudio.name, + } + : undefined, !dest.studio ) ); @@ -210,9 +221,9 @@ const SceneMergeDetails: React.FC = ({ ) ); setTags( - new ScrapeResult( - dest.tags.map((p) => p.id), - uniq(all.map((s) => s.tags.map((p) => p.id)).flat()) + new ObjectListScrapeResult( + sortStoredIdObjects(dest.tags.map(idToStoredID)), + uniqIDStoredIDs(all.map((s) => s.tags.map(idToStoredID)).flat()) ) ); setDetails( @@ -290,34 +301,6 @@ const SceneMergeDetails: React.FC = ({ loadImages(); }, [sources, dest]); - const convertGalleries = useCallback( - (ids?: string[]) => { - const all = [dest, ...sources]; - return ids - ?.map((g) => - all - .map((s) => s.galleries) - .flat() - .find((gg) => g === gg.id) - ) - .map((g) => { - return { - id: g!.id, - title: galleryTitle(g!), - }; - }); - }, - [dest, sources] - ); - - const originalGalleries = useMemo(() => { - return convertGalleries(galleries.originalValue); - }, [galleries, convertGalleries]); - - const newGalleries = useMemo(() => { - return convertGalleries(galleries.newValue); - }, [galleries, convertGalleries]); - // ensure this is updated if fields are changed const hasValues = useMemo(() => { return hasScrapedValues([ @@ -480,17 +463,19 @@ const SceneMergeDetails: React.FC = ({ renderOriginalField={() => ( {}} - disabled + isMulti + isDisabled /> )} renderNewField={() => ( {}} - disabled + isMulti + isDisabled /> )} onChange={(value) => setGalleries(value)} @@ -579,7 +564,7 @@ const SceneMergeDetails: React.FC = ({ play_count: playCount.getNewValue(), play_duration: playDuration.getNewValue(), gallery_ids: galleries.getNewValue(), - studio_id: studio.getNewValue(), + studio_id: studio.getNewValue()?.stored_id, performer_ids: performers.getNewValue()?.map((p) => p.stored_id!), movies: movies.getNewValue()?.map((m) => { // find the equivalent movie in the original scenes @@ -592,7 +577,7 @@ const SceneMergeDetails: React.FC = ({ scene_index: found!.scene_index, }; }), - tag_ids: tags.getNewValue(), + tag_ids: tags.getNewValue()?.map((t) => t.stored_id!), details: details.getNewValue(), organized: organized.getNewValue(), stash_ids: stashIDs.getNewValue(), diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index ce1d6a3ccc9..3b65e20c44f 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -1,5 +1,6 @@ .card-popovers { display: flex; + flex-wrap: wrap; justify-content: center; margin-bottom: 10px; @@ -28,6 +29,10 @@ margin: 5px; } +.performer-tag-container .performer-disambiguation { + color: initial; +} + .performer-tag.image, .movie-tag.image { background-position: center; @@ -82,6 +87,10 @@ textarea.scene-description { } } +.justify-content-center .studio-card .studio-card-image { + width: 100%; +} + .studio-card { padding: 0.5rem; @@ -127,33 +136,6 @@ textarea.scene-description { left: 0.7rem; } -.scene-studio-overlay { - display: block; - font-weight: 900; - height: 10%; - max-width: 40%; - opacity: 0.75; - position: absolute; - right: 0.7rem; - top: 0.7rem; - z-index: 8; - - .image-thumbnail { - height: 50px; - object-fit: contain; - width: 100%; - } - - a { - color: $text-color; - display: inline-block; - letter-spacing: -0.03rem; - text-align: right; - text-decoration: none; - text-shadow: 0 0 3px #000; - } -} - .extra-scene-info { display: none; } @@ -164,16 +146,15 @@ textarea.scene-description { text-transform: uppercase; } -.scene-card, -.gallery-card { - a { - color: $text-color; - text-decoration: none; +.scene-card { + &-preview { + aspect-ratio: 16/9; } +} - .scene-specs-overlay, - .rating-banner, - .scene-studio-overlay { +.scene-card, +.gallery-card { + .scene-specs-overlay { transition: opacity 0.5s; } @@ -212,19 +193,11 @@ textarea.scene-description { &:hover, &:active { - .scene-specs-overlay, - .rating-banner, - .scene-studio-overlay { + .scene-specs-overlay { opacity: 0; transition: opacity 0.5s; } - .scene-studio-overlay:hover, - .scene-studio-overlay:active { - opacity: 0.75; - transition: opacity 0.5s; - } - .scene-card-check { opacity: 0.75; transition: opacity 0.5s; @@ -281,6 +254,12 @@ textarea.scene-description { } } +/* stylelint-disable selector-class-pattern */ +.table .cover_image-head, +.table .cover_image-data { + text-align: center; +} + input[type="range"].filter-slider { height: 100%; margin: 0; @@ -540,6 +519,34 @@ input[type="range"].blue-slider { padding-right: 0.25rem; } +@media (min-width: 1200px) { + #queue-viewer { + .queue-scene-details { + width: 245px; + } + + .queue-scene-title, + .queue-scene-studio, + .queue-scene-performers, + .queue-scene-date { + margin-right: auto; + min-width: 245px; + overflow: hidden; + position: relative; + transform: translateX(0); + transition: 2s; + white-space: nowrap; + } + + .queue-scene-title:hover, + .queue-scene-studio:hover, + .queue-scene-performers:hover, + .queue-scene-date:hover { + transform: translateX(calc(245px - 100%)); + } + } +} + #queue-viewer { .queue-controls { align-items: center; @@ -550,15 +557,50 @@ input[type="range"].blue-slider { justify-content: space-between; position: sticky; top: 0; + z-index: 100; + } + + .queue-scene-details { + display: grid; + overflow: hidden; + position: relative; + } + + .queue-scene-title { + font-size: 1.2rem; + + @media (max-width: 576px) { + font-size: 1rem; + } + } + + .queue-scene-studio { + color: #d3d0d0; + font-weight: 600; + } + + .queue-scene-performers, + .queue-scene-date { + color: #d3d0d0; + font-size: 0.9rem; + font-weight: 400; + + @media (max-width: 576px) { + font-size: 0.8rem; + } } .thumbnail-container { - height: 50px; + height: 80px; margin-bottom: 5px; margin-right: 0.75rem; margin-top: 5px; - min-width: 100px; - width: 100px; + min-width: 142px; + width: 142px; + } + + ol { + padding-left: 20px; } img { @@ -620,26 +662,6 @@ input[type="range"].blue-slider { } } -.scene-table { - table, - tr, - td, - label, - input { - height: 100%; - } - - td:first-child { - padding: 0; - } - - label { - display: block; - margin: 0; - padding: 0.5rem; - } -} - .scrape-dialog .rating-number.disabled { padding-left: 0.5em; } diff --git a/ui/v2.5/src/components/Settings/Inputs.tsx b/ui/v2.5/src/components/Settings/Inputs.tsx index dd7afc2f967..32a2a3cd226 100644 --- a/ui/v2.5/src/components/Settings/Inputs.tsx +++ b/ui/v2.5/src/components/Settings/Inputs.tsx @@ -5,9 +5,11 @@ import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "../Shared/Icon"; import { StringListInput } from "../Shared/StringListInput"; import { PatchComponent } from "src/pluginApi"; +import { useSettings, useSettingsOptional } from "./context"; interface ISetting { id?: string; + advanced?: boolean; className?: string; heading?: React.ReactNode; headingID?: string; @@ -32,8 +34,12 @@ export const Setting: React.FC> = PatchComponent( tooltipID, onClick, disabled, + advanced, } = props; + // these components can be used in the setup wizard, where advanced mode is not available + const { advancedMode } = useSettingsOptional(); + const intl = useIntl(); function renderHeading() { @@ -61,6 +67,8 @@ export const Setting: React.FC> = PatchComponent( : undefined; const disabledClassName = disabled ? "disabled" : ""; + if (advanced && !advancedMode) return null; + return (
> = ({ value, children, onChange, + advanced, }) => { return ( - + (props: ISettingModal) => { type="submit" variant="primary" onClick={() => close(currentValue)} - disabled={!currentValue || (validate && !validate(currentValue))} + disabled={ + currentValue === undefined || + (validate && !validate(currentValue)) + } > @@ -346,8 +363,12 @@ export const ModalSetting = (props: IModalSetting) => { buttonTextID, modalProps, disabled, + advanced, } = props; const [showModal, setShowModal] = useState(false); + const { advancedMode } = useSettings(); + + if (advanced && !advancedMode) return null; return ( <> diff --git a/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx b/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx index 088b5f8bfa0..4d35aef0537 100644 --- a/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx +++ b/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx @@ -161,6 +161,7 @@ export const AvailableScraperPackages: React.FC = () => { addSource={addSource} editSource={editSource} deleteSource={deleteSource} + allowSelectAll />
diff --git a/ui/v2.5/src/components/Settings/SettingSection.tsx b/ui/v2.5/src/components/Settings/SettingSection.tsx index 41d365088a6..ad2c9ebae8d 100644 --- a/ui/v2.5/src/components/Settings/SettingSection.tsx +++ b/ui/v2.5/src/components/Settings/SettingSection.tsx @@ -1,11 +1,13 @@ import React, { PropsWithChildren } from "react"; import { Card } from "react-bootstrap"; import { useIntl } from "react-intl"; +import { useSettings } from "./context"; interface ISettingGroup { id?: string; headingID?: string; subHeadingID?: string; + advanced?: boolean; } export const SettingSection: React.FC> = ({ @@ -13,8 +15,12 @@ export const SettingSection: React.FC> = ({ children, headingID, subHeadingID, + advanced, }) => { const intl = useIntl(); + const { advancedMode } = useSettings(); + + if (advanced && !advancedMode) return null; return (
diff --git a/ui/v2.5/src/components/Settings/Settings.tsx b/ui/v2.5/src/components/Settings/Settings.tsx index fb1b95d2a17..182de71c745 100644 --- a/ui/v2.5/src/components/Settings/Settings.tsx +++ b/ui/v2.5/src/components/Settings/Settings.tsx @@ -1,6 +1,7 @@ import React from "react"; -import { Tab, Nav, Row, Col } from "react-bootstrap"; -import { useHistory, useLocation } from "react-router-dom"; +import { Tab, Nav, Row, Col, Form } from "react-bootstrap"; +import { Redirect } from "react-router-dom"; +import { LinkContainer } from "react-router-bootstrap"; import { FormattedMessage } from "react-intl"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; @@ -13,88 +14,152 @@ import { SettingsPluginsPanel } from "./SettingsPluginsPanel"; import { SettingsScrapingPanel } from "./SettingsScrapingPanel"; import { SettingsToolsPanel } from "./SettingsToolsPanel"; import { SettingsServicesPanel } from "./SettingsServicesPanel"; -import { SettingsContext } from "./context"; +import { SettingsContext, useSettings } from "./context"; import { SettingsLibraryPanel } from "./SettingsLibraryPanel"; import { SettingsSecurityPanel } from "./SettingsSecurityPanel"; import Changelog from "../Changelog/Changelog"; -export const Settings: React.FC = () => { - const location = useLocation(); - const history = useHistory(); - const defaultTab = new URLSearchParams(location.search).get("tab") ?? "tasks"; +const validTabs = [ + "tasks", + "library", + "interface", + "security", + "metadata-providers", + "services", + "system", + "plugins", + "logs", + "tools", + "changelog", + "about", +] as const; +type TabKey = (typeof validTabs)[number]; + +const defaultTab: TabKey = "tasks"; + +function isTabKey(tab: string | null): tab is TabKey { + return validTabs.includes(tab as TabKey); +} + +const SettingTabs: React.FC = () => { + const tab = new URLSearchParams(location.search).get("tab"); - const onSelect = (val: string) => history.push(`?tab=${val}`); + const { advancedMode, setAdvancedMode } = useSettings(); const titleProps = useTitleProps({ id: "settings" }); + + if (!isTabKey(tab)) { + return ( + + ); + } + return ( - tab && onSelect(tab)} - > + @@ -105,50 +170,56 @@ export const Settings: React.FC = () => { md={{ offset: 3 }} xl={{ offset: 2 }} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); }; +export const Settings: React.FC = () => { + return ( + + + + ); +}; + export default Settings; diff --git a/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx b/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx index 13d8edde745..9d9922330fd 100644 --- a/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Button } from "react-bootstrap"; import { useIntl } from "react-intl"; import { useLatestVersion } from "src/core/StashService"; +import { ExternalLink } from "../Shared/ExternalLink"; import { ConstantSetting, SettingGroup } from "./Inputs"; import { SettingSection } from "./SettingSection"; @@ -115,13 +116,9 @@ export const SettingsAboutPanel: React.FC = () => { { id: "config.about.stash_home" }, { url: ( - + GitHub - + ), } )} @@ -131,13 +128,9 @@ export const SettingsAboutPanel: React.FC = () => { { id: "config.about.stash_wiki" }, { url: ( - + Documentation - + ), } )} @@ -147,13 +140,9 @@ export const SettingsAboutPanel: React.FC = () => { { id: "config.about.stash_discord" }, { url: ( - + Discord - + ), } )} @@ -163,13 +152,9 @@ export const SettingsAboutPanel: React.FC = () => { { id: "config.about.stash_open_collective" }, { url: ( - + Open Collective - + ), } )} diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 84fc317dc38..07f8868a3c8 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -240,6 +240,7 @@ export const SettingsInterfacePanel: React.FC = () => { /> { checked={ui.enableChromecast ?? undefined} onChange={(v) => saveUI({ enableChromecast: v })} /> + saveUI({ disableMobileMediaAutoRotateEnabled: v })} + /> { const intl = useIntl(); @@ -76,13 +77,9 @@ export const SettingsLibraryPanel: React.FC = () => { {intl.formatMessage({ id: "config.general.excluded_video_patterns_desc", })} - + - + } value={general.excludes ?? undefined} @@ -98,13 +95,9 @@ export const SettingsLibraryPanel: React.FC = () => { {intl.formatMessage({ id: "config.general.excluded_image_gallery_patterns_desc", })} - + - + } value={general.imageExcludes ?? undefined} diff --git a/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx b/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx index 849476aa870..102d236f1ed 100644 --- a/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx @@ -26,6 +26,7 @@ import { AvailablePluginPackages, InstalledPluginPackages, } from "./PluginPackageManager"; +import { ExternalLink } from "../Shared/ExternalLink"; interface IPluginSettingProps { pluginID: string; @@ -97,15 +98,12 @@ export const SettingsPluginsPanel: React.FC = () => { function renderLink(url?: string) { if (url) { return ( - ); } diff --git a/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx b/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx index 9aef6942bb3..304b83d75c1 100644 --- a/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx @@ -23,6 +23,7 @@ import { AvailableScraperPackages, InstalledScraperPackages, } from "./ScraperPackageManager"; +import { ExternalLink } from "../Shared/ExternalLink"; interface IURLList { urls: string[]; @@ -42,16 +43,7 @@ const URLList: React.FC = ({ urls }) => { const sanitised = TextUtils.sanitiseURL(url); const siteURL = linkSite(sanitised!); - return ( - - {sanitised} - - ); + return {sanitised}; } } diff --git a/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx b/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx index af226fc57ae..63137b94a78 100644 --- a/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx @@ -137,7 +137,7 @@ export const SettingsServicesPanel: React.FC = () => { } } - function renderDeadline(until?: string) { + function renderDeadline(until?: string | null) { if (until) { const deadline = new Date(until); return `until ${intl.formatDate(deadline)}`; diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx index a16bffadaf0..a6d95d5a562 100644 --- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx @@ -210,7 +210,7 @@ export const SettingsConfigurationPanel: React.FC = () => { /> - + { { /> { value={general.transcodeInputArgs ?? []} /> { /> { value={general.liveTranscodeInputArgs ?? []} /> = ({ diff --git a/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx b/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx index 23e5f21db56..77020e3b30a 100644 --- a/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx @@ -40,6 +40,7 @@ export const GenerateOptions: React.FC = ({ onChange={(v) => setOptions({ previews: v })} /> = ({ onChange={(v) => setOptions({ markers: v })} /> = ({ } /> = ({ /> = ({ /> {selection ? ( = ( } async function onImport() { + if (!file) return; + try { setIsRunning(true); await mutateImportObjects({ diff --git a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx index 5529856d63d..18018987c55 100644 --- a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx @@ -327,7 +327,7 @@ export const LibraryTasks: React.FC = () => { - + @@ -349,7 +349,7 @@ export const LibraryTasks: React.FC = () => { - + = ({ onChange={(v) => setOptions({ scanGeneratePreviews: v })} /> >; +type PluginConfigs = Record>; + export interface ISettingsContextState { loading: boolean; error: ApolloError | undefined; @@ -32,7 +33,9 @@ export interface ISettingsContextState { scraping: GQL.ConfigScrapingInput; dlna: GQL.ConfigDlnaInput; ui: IUIConfig; - plugins: PluginSettings; + plugins: PluginConfigs; + + advancedMode: boolean; // apikey isn't directly settable, so expose it here apiKey: string; @@ -44,10 +47,40 @@ export interface ISettingsContextState { saveDLNA: (input: Partial) => void; saveUI: (input: Partial) => void; savePluginSettings: (pluginID: string, input: {}) => void; + setAdvancedMode: (value: boolean) => void; refetch: () => void; } +function noop() {} + +const emptyState: ISettingsContextState = { + loading: false, + error: undefined, + general: {}, + interface: {}, + defaults: {}, + scraping: {}, + dlna: {}, + ui: {}, + plugins: {}, + + advancedMode: false, + + apiKey: "", + + saveGeneral: noop, + saveInterface: noop, + saveDefaults: noop, + saveScraping: noop, + saveDLNA: noop, + saveUI: noop, + savePluginSettings: noop, + setAdvancedMode: noop, + + refetch: noop, +}; + export const SettingStateContext = React.createContext(null); @@ -61,6 +94,16 @@ export const useSettings = () => { return context; }; +export function useSettingsOptional(): ISettingsContextState { + const context = React.useContext(SettingStateContext); + + if (context === null) { + return emptyState; + } + + return context; +} + export const SettingsContext: React.FC = ({ children }) => { const Toast = useToast(); @@ -91,12 +134,12 @@ export const SettingsContext: React.FC = ({ children }) => { const [pendingDLNA, setPendingDLNA] = useState(); const [updateDLNAConfig] = useConfigureDLNA(); - const [ui, setUI] = useState({}); + const [ui, setUI] = useState({}); const [pendingUI, setPendingUI] = useState<{}>(); const [updateUIConfig] = useConfigureUI(); - const [plugins, setPlugins] = useState({}); - const [pendingPlugins, setPendingPlugins] = useState(); + const [plugins, setPlugins] = useState({}); + const [pendingPlugins, setPendingPlugins] = useState(); const [updatePluginConfig] = useConfigurePlugin(); const [updateSuccess, setUpdateSuccess] = useState(); @@ -380,13 +423,15 @@ export const SettingsContext: React.FC = ({ children }) => { }); } + type UIConfigInput = GQL.Scalars["Map"]["input"]; + // saves the configuration if no further changes are made after a half second const saveUIConfig = useDebounce(async (input: IUIConfig) => { try { setUpdateSuccess(undefined); await updateUIConfig({ variables: { - input, + input: input as UIConfigInput, }, }); @@ -430,8 +475,14 @@ export const SettingsContext: React.FC = ({ children }) => { }); } + function setAdvancedMode(value: boolean) { + saveUI({ + advancedMode: value, + }); + } + // saves the configuration if no further changes are made after a half second - const savePluginConfig = useDebounce(async (input: PluginSettings) => { + const savePluginConfig = useDebounce(async (input: PluginConfigs) => { try { setUpdateSuccess(undefined); @@ -536,6 +587,7 @@ export const SettingsContext: React.FC = ({ children }) => { dlna, ui, plugins, + advancedMode: ui.advancedMode ?? false, saveGeneral, saveInterface, saveDefaults, @@ -544,6 +596,7 @@ export const SettingsContext: React.FC = ({ children }) => { saveUI, refetch, savePluginSettings, + setAdvancedMode, }} > {maybeRenderLoadingIndicator()} diff --git a/ui/v2.5/src/components/Settings/styles.scss b/ui/v2.5/src/components/Settings/styles.scss index 02caf99ee66..46c86986f7a 100644 --- a/ui/v2.5/src/components/Settings/styles.scss +++ b/ui/v2.5/src/components/Settings/styles.scss @@ -67,7 +67,7 @@ min-width: 100px; text-align: right; - button { + .btn { margin: 0.25rem; } } @@ -410,3 +410,18 @@ .empty-queue-message { color: $text-muted; } + +.advanced-switch { + display: flex; + justify-content: space-between; + padding: 0.5rem 1rem; + + .form-label { + color: $text-muted; + margin-right: 0.5rem; + } + + .custom-switch { + display: inline-block; + } +} diff --git a/ui/v2.5/src/components/Setup/Migrate.tsx b/ui/v2.5/src/components/Setup/Migrate.tsx index 9f27d780509..23fc0b520f0 100644 --- a/ui/v2.5/src/components/Setup/Migrate.tsx +++ b/ui/v2.5/src/components/Setup/Migrate.tsx @@ -5,6 +5,7 @@ import { useHistory } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { useSystemStatus, mutateMigrate } from "src/core/StashService"; import { migrationNotes } from "src/docs/en/MigrationNotes"; +import { ExternalLink } from "../Shared/ExternalLink"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { MarkdownPage } from "../Shared/MarkdownPage"; @@ -35,18 +36,12 @@ export const Migrate: React.FC = () => { : ""; const discordLink = ( - - Discord - + Discord ); const githubLink = ( - + - + ); useEffect(() => { diff --git a/ui/v2.5/src/components/Setup/Setup.tsx b/ui/v2.5/src/components/Setup/Setup.tsx index d14323f9fe1..f63aaeaf35c 100644 --- a/ui/v2.5/src/components/Setup/Setup.tsx +++ b/ui/v2.5/src/components/Setup/Setup.tsx @@ -27,6 +27,7 @@ import { faQuestionCircle, } from "@fortawesome/free-solid-svg-icons"; import { releaseNotes } from "src/docs/en/ReleaseNotes"; +import { ExternalLink } from "../Shared/ExternalLink"; export const Setup: React.FC = () => { const { configuration, loading: configLoading } = @@ -103,18 +104,12 @@ export const Setup: React.FC = () => { }, [configuration]); const discordLink = ( - - Discord - + Discord ); const githubLink = ( - + - + ); function onConfigLocationChosen(inWorkDir: boolean) { @@ -825,14 +820,9 @@ export const Setup: React.FC = () => { id="setup.success.open_collective" values={{ open_collective_link: ( - - {" "} - OpenCollective{" "} - + + Open Collective + ), }} /> diff --git a/ui/v2.5/src/components/Shared/CountrySelect.tsx b/ui/v2.5/src/components/Shared/CountrySelect.tsx index 65973172575..1f1bdb4f247 100644 --- a/ui/v2.5/src/components/Shared/CountrySelect.tsx +++ b/ui/v2.5/src/components/Shared/CountrySelect.tsx @@ -3,6 +3,7 @@ import Creatable from "react-select/creatable"; import { useIntl } from "react-intl"; import { getCountries } from "src/utils/country"; import { CountryLabel } from "./CountryLabel"; +import { PatchComponent } from "src/pluginApi"; interface IProps { value?: string; @@ -14,7 +15,7 @@ interface IProps { menuPortalTarget?: HTMLElement | null; } -export const CountrySelect: React.FC = ({ +const _CountrySelect: React.FC = ({ value, onChange, disabled = false, @@ -50,3 +51,5 @@ export const CountrySelect: React.FC = ({ /> ); }; + +export const CountrySelect = PatchComponent("CountrySelect", _CountrySelect); diff --git a/ui/v2.5/src/components/Shared/DateInput.tsx b/ui/v2.5/src/components/Shared/DateInput.tsx index c8a0be24d2b..b63f0e7b6ed 100644 --- a/ui/v2.5/src/components/Shared/DateInput.tsx +++ b/ui/v2.5/src/components/Shared/DateInput.tsx @@ -7,6 +7,7 @@ import { Icon } from "./Icon"; import "react-datepicker/dist/react-datepicker.css"; import { useIntl } from "react-intl"; +import { PatchComponent } from "src/pluginApi"; interface IProps { disabled?: boolean; @@ -28,7 +29,7 @@ const ShowPickerButton = forwardRef< )); -export const DateInput: React.FC = (props: IProps) => { +const _DateInput: React.FC = (props: IProps) => { const intl = useIntl(); const date = useMemo(() => { @@ -98,3 +99,5 @@ export const DateInput: React.FC = (props: IProps) => {
); }; + +export const DateInput = PatchComponent("DateInput", _DateInput); diff --git a/ui/v2.5/src/components/Shared/DetailImage.tsx b/ui/v2.5/src/components/Shared/DetailImage.tsx index fc16d081378..357e5eee096 100644 --- a/ui/v2.5/src/components/Shared/DetailImage.tsx +++ b/ui/v2.5/src/components/Shared/DetailImage.tsx @@ -1,6 +1,7 @@ import { useLayoutEffect, useRef } from "react"; +import { remToPx } from "src/utils/units"; -const DEFAULT_WIDTH = "200"; +const DEFAULT_WIDTH = Math.round(remToPx(30)); // Props used by the element type IDetailImageProps = JSX.IntrinsicElements["img"]; @@ -17,7 +18,7 @@ export const DetailImage = (props: IDetailImageProps) => { // If the naturalWidth is zero, it means the image either hasn't loaded yet // or we're on Firefox and it is an SVG w/o an intrinsic size. // So set the width to our fallback width. - img.setAttribute("width", DEFAULT_WIDTH); + img.setAttribute("width", String(DEFAULT_WIDTH)); } else { // If we have a `naturalWidth`, this could either be the actual intrinsic width // of the image, or the image is an SVG w/o an intrinsic size and we're on Chrome or Safari, @@ -26,7 +27,7 @@ export const DetailImage = (props: IDetailImageProps) => { // so we need to clone the image to disconnect it from the DOM, and then get the `naturalWidth` of the clone, // in order to always return the same `naturalWidth` for a given src. const i = img.cloneNode() as HTMLImageElement; - img.setAttribute("width", (i.naturalWidth || DEFAULT_WIDTH).toString()); + img.setAttribute("width", String(i.naturalWidth || DEFAULT_WIDTH)); } } diff --git a/ui/v2.5/src/components/Shared/ExternalLink.tsx b/ui/v2.5/src/components/Shared/ExternalLink.tsx new file mode 100644 index 00000000000..e01572d22c3 --- /dev/null +++ b/ui/v2.5/src/components/Shared/ExternalLink.tsx @@ -0,0 +1,5 @@ +type IExternalLinkProps = JSX.IntrinsicElements["a"]; + +export const ExternalLink: React.FC = (props) => { + return ; +}; diff --git a/ui/v2.5/src/components/Shared/FilterSelect.tsx b/ui/v2.5/src/components/Shared/FilterSelect.tsx index 82b45661842..c8fcb7013c6 100644 --- a/ui/v2.5/src/components/Shared/FilterSelect.tsx +++ b/ui/v2.5/src/components/Shared/FilterSelect.tsx @@ -89,6 +89,7 @@ const SelectComponent = ( ...props, styles, defaultOptions: true, + isClearable: true, value: selectedOptions ?? null, className: cx("react-select", props.className), classNamePrefix: "react-select", @@ -133,8 +134,8 @@ export interface IFilterComponentProps extends IFilterProps { onCreate?: ( name: string ) => Promise<{ value: string; item: T; message: string }>; - getNamedObject: (id: string, name: string) => T; - isValidNewOption: (inputValue: string, options: T[]) => boolean; + getNamedObject?: (id: string, name: string) => T; + isValidNewOption?: (inputValue: string, options: T[]) => boolean; } export const FilterSelectComponent = < @@ -149,6 +150,7 @@ export const FilterSelectComponent = < values, isMulti, onSelect, + creatable = false, isValidNewOption, getNamedObject, loadOptions, @@ -181,52 +183,62 @@ export const FilterSelectComponent = < onSelect?.(selected.map((item) => item.object)); }; - const onCreate = async (name: string) => { - try { - setLoading(true); - const { value, item: newItem, message } = await props.onCreate!(name); - const newItemOption = { - object: newItem, - value, - } as Option; - if (!isMulti) { - onChange(newItemOption); - } else { - const o = (selectedOptions ?? []) as Option[]; - onChange([...o, newItemOption]); - } - - setLoading(false); - Toast.success( - - {message}: {name} - - ); - } catch (e) { - Toast.error(e); - } - }; + const onCreate = + creatable && props.onCreate + ? async (name: string) => { + try { + setLoading(true); + const { + value, + item: newItem, + message, + } = await props.onCreate!(name); + const newItemOption = { + object: newItem, + value, + } as Option; + if (!isMulti) { + onChange(newItemOption); + } else { + const o = (selectedOptions ?? []) as Option[]; + onChange([...o, newItemOption]); + } - const getNewOptionData = ( - inputValue: string, - optionLabel: React.ReactNode - ) => { - return { - value: "", - object: getNamedObject("", optionLabel as string), - }; - }; + setLoading(false); + Toast.success( + + {message}: {name} + + ); + } catch (e) { + Toast.error(e); + } + } + : undefined; - const validNewOption = ( - inputValue: string, - value: Options>, - options: OptionsOrGroups, GroupBase>> - ) => { - return isValidNewOption( - inputValue, - (options as Options>).map((o) => o.object) - ); - }; + const getNewOptionData = + creatable && getNamedObject + ? (inputValue: string, optionLabel: React.ReactNode) => { + return { + value: "", + object: getNamedObject("", optionLabel as string), + }; + } + : undefined; + + const validNewOption = + creatable && isValidNewOption + ? ( + inputValue: string, + value: Options>, + options: OptionsOrGroups, GroupBase>> + ) => { + return isValidNewOption( + inputValue, + (options as Options>).map((o) => o.object) + ); + } + : undefined; const debounceDelay = 100; const debounceLoadOptions = useDebounce((inputValue, callback) => { @@ -240,7 +252,7 @@ export const FilterSelectComponent = < isLoading={props.isLoading || loading} onChange={onChange} selectedOptions={selectedOptions} - onCreateOption={props.creatable ? onCreate : undefined} + onCreateOption={onCreate} getNewOptionData={getNewOptionData} isValidNewOption={validNewOption} /> diff --git a/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx b/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx index 3c0da291d7c..ba7279c4d86 100644 --- a/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx +++ b/ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx @@ -7,6 +7,7 @@ import { faEllipsis, faTimes } from "@fortawesome/free-solid-svg-icons"; import { useDebounce } from "src/hooks/debounce"; import TextUtils from "src/utils/text"; import { useDirectoryPaths } from "./useDirectoryPaths"; +import { PatchComponent } from "src/pluginApi"; interface IProps { currentDirectory: string; @@ -18,7 +19,7 @@ interface IProps { hideError?: boolean; } -export const FolderSelect: React.FC = ({ +const _FolderSelect: React.FC = ({ currentDirectory, onChangeDirectory, defaultDirectories = [], @@ -132,3 +133,5 @@ export const FolderSelect: React.FC = ({ ); }; + +export const FolderSelect = PatchComponent("FolderSelect", _FolderSelect); diff --git a/ui/v2.5/src/components/Shared/GridCard.tsx b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx similarity index 75% rename from ui/v2.5/src/components/Shared/GridCard.tsx rename to ui/v2.5/src/components/Shared/GridCard/GridCard.tsx index 65731c550e3..7d0976c4fa8 100644 --- a/ui/v2.5/src/components/Shared/GridCard.tsx +++ b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx @@ -1,13 +1,15 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { Card, Form } from "react-bootstrap"; import { Link } from "react-router-dom"; import cx from "classnames"; -import { TruncatedText } from "./TruncatedText"; +import { TruncatedText } from "../TruncatedText"; +import ScreenUtils from "src/utils/screen"; interface ICardProps { className?: string; linkClassName?: string; thumbnailSectionClassName?: string; + width?: number; url: string; pretitleIcon?: JSX.Element; title: JSX.Element | string; @@ -23,6 +25,46 @@ interface ICardProps { interactiveHeatmap?: string; } +export const calculateCardWidth = ( + containerWidth: number, + preferredWidth: number +) => { + const containerPadding = 30; + const cardMargin = 10; + let maxUsableWidth = containerWidth - containerPadding; + let maxElementsOnRow = Math.ceil(maxUsableWidth / preferredWidth); + return maxUsableWidth / maxElementsOnRow - cardMargin; +}; + +export const useContainerDimensions = ( + myRef: React.RefObject +) => { + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + + useEffect(() => { + const getDimensions = () => ({ + width: myRef.current!.offsetWidth, + height: myRef.current!.offsetHeight, + }); + + const handleResize = () => { + setDimensions(getDimensions()); + }; + + if (myRef.current) { + setDimensions(getDimensions()); + } + + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [myRef]); + + return dimensions; +}; + export const GridCard: React.FC = (props: ICardProps) => { function handleImageClick(event: React.MouseEvent) { const { shiftKey } = event; @@ -116,6 +158,11 @@ export const GridCard: React.FC = (props: ICardProps) => { onDragStart={handleDrag} onDragOver={handleDragOver} draggable={props.onSelectedChanged && props.selecting} + style={ + props.width && !ScreenUtils.isMobile() + ? { width: `${props.width}px` } + : {} + } > {maybeRenderCheckbox()} diff --git a/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx b/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx new file mode 100644 index 00000000000..875b122d89d --- /dev/null +++ b/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx @@ -0,0 +1,51 @@ +import React, { useMemo } from "react"; +import { Link } from "react-router-dom"; +import { ConfigurationContext } from "src/hooks/Config"; + +interface IStudio { + id: string; + name: string; + image_path?: string | null; +} + +export const StudioOverlay: React.FC<{ + studio: IStudio | null | undefined; +}> = ({ studio }) => { + const { configuration } = React.useContext(ConfigurationContext); + + const configValue = configuration?.interface.showStudioAsText; + + const showStudioAsText = useMemo(() => { + if (configValue || !studio?.image_path) { + return true; + } + + // If the studio has a default image, show the studio name as text + const studioImageURL = new URL(studio.image_path); + if (studioImageURL.searchParams.get("default") === "true") { + return true; + } + + return false; + }, [configValue, studio?.image_path]); + + if (!studio) return <>; + + return ( + // this class name is incorrect +
+ + {showStudioAsText ? ( + studio.name + ) : ( + {studio.name} + )} + +
+ ); +}; diff --git a/ui/v2.5/src/components/Shared/GridCard/styles.scss b/ui/v2.5/src/components/Shared/GridCard/styles.scss new file mode 100644 index 00000000000..fcf699fe234 --- /dev/null +++ b/ui/v2.5/src/components/Shared/GridCard/styles.scss @@ -0,0 +1,59 @@ +.grid-card { + a { + color: $text-color; + text-decoration: none; + } + + .rating-banner { + transition: opacity 0.5s; + } + + &:hover, + &:active { + .rating-banner, + .studio-overlay { + opacity: 0; + transition: opacity 0.5s; + } + + .studio-overlay:hover, + .studio-overlay:active { + opacity: 0.75; + transition: opacity 0.5s; + } + } +} + +.studio-overlay { + display: block; + font-weight: 900; + height: 10%; + max-width: 40%; + opacity: 0.75; + position: absolute; + right: 0.7rem; + top: 0.7rem; + transition: opacity 0.5s; + z-index: 8; + + .image-thumbnail { + height: 50px; + object-fit: contain; + width: 100%; + } + + a { + color: $text-color; + display: inline-block; + letter-spacing: -0.03rem; + text-align: right; + text-decoration: none; + text-shadow: 0 0 3px #000; + } + + &:hover, + &:active { + opacity: 0.75; + transition: opacity 0.5s; + } +} diff --git a/ui/v2.5/src/components/Shared/HoverPopover.tsx b/ui/v2.5/src/components/Shared/HoverPopover.tsx index 7d1c2464190..87fcce04544 100644 --- a/ui/v2.5/src/components/Shared/HoverPopover.tsx +++ b/ui/v2.5/src/components/Shared/HoverPopover.tsx @@ -9,6 +9,7 @@ interface IHoverPopover { placement?: OverlayProps["placement"]; onOpen?: () => void; onClose?: () => void; + target?: React.RefObject; } export const HoverPopover: React.FC = ({ @@ -20,6 +21,7 @@ export const HoverPopover: React.FC = ({ placement = "top", onOpen, onClose, + target, }) => { const [show, setShow] = useState(false); const triggerRef = useRef(null); @@ -61,7 +63,11 @@ export const HoverPopover: React.FC = ({ {children}
{triggerRef.current && ( - + = ({
{leftFooterButtons}
{footerButtons} - {cancel ? (
diff --git a/ui/v2.5/src/components/Shared/PackageManager/PackageManager.tsx b/ui/v2.5/src/components/Shared/PackageManager/PackageManager.tsx index cedee2060fe..2c36704cf1a 100644 --- a/ui/v2.5/src/components/Shared/PackageManager/PackageManager.tsx +++ b/ui/v2.5/src/components/Shared/PackageManager/PackageManager.tsx @@ -224,6 +224,9 @@ const InstalledPackagesList: React.FC<{ ) : undefined} + + + {renderBody()} @@ -620,6 +623,7 @@ const SourcePackagesList: React.FC<{ loadSource: () => Promise; selectedOnly: boolean; selectedPackages: RemotePackage[]; + allowSelectAll?: boolean; setSelectedPackages: React.Dispatch>; renderDescription?: (pkg: RemotePackage) => React.ReactNode; editSource: () => void; @@ -627,6 +631,7 @@ const SourcePackagesList: React.FC<{ }> = ({ source, loadSource, + allowSelectAll, selectedOnly, selectedPackages, setSelectedPackages, @@ -785,7 +790,7 @@ const SourcePackagesList: React.FC<{ <> - {packages !== undefined ? ( + {allowSelectAll && packages !== undefined ? ( toggleSource()} @@ -844,6 +849,7 @@ const AvailablePackagesList: React.FC<{ React.SetStateAction> >; selectedOnly: boolean; + allowSourceSelectAll?: boolean; addSource: (src: GQL.PackageSource) => void; editSource: (existing: GQL.PackageSource, changed: GQL.PackageSource) => void; deleteSource: (source: GQL.PackageSource) => void; @@ -859,6 +865,7 @@ const AvailablePackagesList: React.FC<{ addSource, editSource, deleteSource, + allowSourceSelectAll, }) => { const [deletingSource, setDeletingSource] = useState(); const [editingSource, setEditingSource] = useState(); @@ -920,6 +927,7 @@ const AvailablePackagesList: React.FC<{ setSelectedPackages={(v) => setSelectedSourcePackages(src, v)} editSource={() => setEditingSource(src)} deleteSource={() => setDeletingSource(src)} + allowSelectAll={allowSourceSelectAll} /> ))} @@ -983,6 +991,9 @@ const AvailablePackagesList: React.FC<{ + + + {renderBody()} @@ -1000,6 +1011,7 @@ export const AvailablePackages: React.FC<{ addSource: (src: GQL.PackageSource) => void; editSource: (existing: GQL.PackageSource, changed: GQL.PackageSource) => void; deleteSource: (source: GQL.PackageSource) => void; + allowSelectAll?: boolean; }> = ({ sources, loadSource, @@ -1009,6 +1021,7 @@ export const AvailablePackages: React.FC<{ addSource, editSource, deleteSource, + allowSelectAll, }) => { const [checkedPackages, setCheckedPackages] = useState< Record @@ -1060,6 +1073,7 @@ export const AvailablePackages: React.FC<{ addSource={addSource} editSource={editSource} deleteSource={deleteSource} + allowSourceSelectAll={allowSelectAll} />
); diff --git a/ui/v2.5/src/components/Shared/PackageManager/styles.scss b/ui/v2.5/src/components/Shared/PackageManager/styles.scss index 3613e5046c9..2926806a95f 100644 --- a/ui/v2.5/src/components/Shared/PackageManager/styles.scss +++ b/ui/v2.5/src/components/Shared/PackageManager/styles.scss @@ -29,6 +29,10 @@ .package-manager-table-container { max-height: 300px; overflow-y: auto; + + th { + border: none; + } } table thead { diff --git a/ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx b/ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx index 0f98f732b63..577faca8b0d 100644 --- a/ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx +++ b/ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx @@ -9,7 +9,10 @@ import { Icon } from "./Icon"; import { PerformerLink } from "./TagLink"; interface IProps { - performers: Partial[]; + performers: Pick< + GQL.Performer, + "id" | "name" | "image_path" | "disambiguation" | "gender" + >[]; } export const PerformerPopoverButton: React.FC = ({ performers }) => { diff --git a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx index 2fb5c1ef42d..dc30cfa1f8b 100644 --- a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx +++ b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx @@ -10,7 +10,6 @@ import React, { useMemo } from "react"; import { Button, OverlayTrigger, Tooltip } from "react-bootstrap"; import { FormattedNumber, useIntl } from "react-intl"; import { Link } from "react-router-dom"; -import { IUIConfig } from "src/core/config"; import { ConfigurationContext } from "src/hooks/Config"; import TextUtils from "src/utils/text"; import { Icon } from "./Icon"; @@ -37,8 +36,7 @@ export const PopoverCountButton: React.FC = ({ count, }) => { const { configuration } = React.useContext(ConfigurationContext); - const abbreviateCounter = - (configuration?.ui as IUIConfig)?.abbreviateCounters ?? false; + const abbreviateCounter = configuration?.ui.abbreviateCounters ?? false; const intl = useIntl(); diff --git a/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx b/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx index b64399a28cb..a33991cae86 100644 --- a/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx +++ b/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { IUIConfig } from "src/core/config"; import { ConfigurationContext } from "src/hooks/Config"; import { defaultRatingStarPrecision, @@ -21,8 +20,7 @@ export const RatingSystem: React.FC = ( ) => { const { configuration: config } = React.useContext(ConfigurationContext); const ratingSystemOptions = - (config?.ui as IUIConfig)?.ratingSystemOptions ?? - defaultRatingSystemOptions; + config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions; if (ratingSystemOptions.type === RatingSystemType.Stars) { return ( diff --git a/ui/v2.5/src/components/Shared/RatingBanner.tsx b/ui/v2.5/src/components/Shared/RatingBanner.tsx index f2f5cde4423..d152b8b5200 100644 --- a/ui/v2.5/src/components/Shared/RatingBanner.tsx +++ b/ui/v2.5/src/components/Shared/RatingBanner.tsx @@ -7,7 +7,6 @@ import { RatingSystemType, } from "src/utils/rating"; import { ConfigurationContext } from "src/hooks/Config"; -import { IUIConfig } from "src/core/config"; interface IProps { rating?: number | null; @@ -16,8 +15,7 @@ interface IProps { export const RatingBanner: React.FC = ({ rating }) => { const { configuration: config } = useContext(ConfigurationContext); const ratingSystemOptions = - (config?.ui as IUIConfig)?.ratingSystemOptions ?? - defaultRatingSystemOptions; + config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions; const isLegacy = ratingSystemOptions.type === RatingSystemType.Stars && ratingSystemOptions.starPrecision === RatingStarPrecision.Full; diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index 43207e94a98..50a1952337a 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -1,21 +1,22 @@ import React, { useMemo } from "react"; import * as GQL from "src/core/generated-graphql"; -import { - MovieSelect, - TagSelect, - StudioSelect, -} from "src/components/Shared/Select"; +import { MovieSelect } from "src/components/Shared/Select"; import { ScrapeDialogRow, IHasName, } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { PerformerSelect } from "src/components/Performers/PerformerSelect"; -import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult"; +import { + ObjectScrapeResult, + ScrapeResult, +} from "src/components/Shared/ScrapeDialog/scrapeResult"; +import { TagSelect } from "src/components/Tags/TagSelect"; +import { StudioSelect } from "src/components/Studios/StudioSelect"; interface IScrapedStudioRow { title: string; - result: ScrapeResult; - onChange: (value: ScrapeResult) => void; + result: ObjectScrapeResult; + onChange: (value: ObjectScrapeResult) => void; newStudio?: GQL.ScrapedStudio; onCreateNew?: (value: GQL.ScrapedStudio) => void; } @@ -28,25 +29,34 @@ export const ScrapedStudioRow: React.FC = ({ onCreateNew, }) => { function renderScrapedStudio( - scrapeResult: ScrapeResult, + scrapeResult: ObjectScrapeResult, isNew?: boolean, - onChangeFn?: (value: string) => void + onChangeFn?: (value: GQL.ScrapedStudio) => void ) { const resultValue = isNew ? scrapeResult.newValue : scrapeResult.originalValue; const value = resultValue ? [resultValue] : []; + const selectValue = value.map((p) => { + const aliases: string[] = []; + return { + id: p.stored_id ?? "", + name: p.name ?? "", + aliases, + }; + }); + return ( { if (onChangeFn) { - onChangeFn(items[0]?.id); + onChangeFn(items[0]); } }} - ids={value} + values={selectValue} /> ); } @@ -230,35 +240,45 @@ export const ScrapedMoviesRow: React.FC< }; export const ScrapedTagsRow: React.FC< - IScrapedObjectRowImpl + IScrapedObjectRowImpl > = ({ title, result, onChange, newObjects, onCreateNew }) => { function renderScrapedTags( - scrapeResult: ScrapeResult, + scrapeResult: ScrapeResult, isNew?: boolean, - onChangeFn?: (value: string[]) => void + onChangeFn?: (value: GQL.ScrapedTag[]) => void ) { const resultValue = isNew ? scrapeResult.newValue : scrapeResult.originalValue; const value = resultValue ?? []; + const selectValue = value.map((p) => { + const aliases: string[] = []; + return { + id: p.stored_id ?? "", + name: p.name ?? "", + aliases, + }; + }); + return ( { if (onChangeFn) { - onChangeFn(items.map((i) => i.id)); + // map the id back to stored_id + onChangeFn(items.map((p) => ({ ...p, stored_id: p.id }))); } }} - ids={value} + values={selectValue} /> ); } return ( - + title={title} result={result} renderObjects={renderScrapedTags} diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts index 0ce4c23e89f..faa378d114d 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts @@ -6,7 +6,7 @@ import { useStudioCreate, useTagCreate, } from "src/core/StashService"; -import { ScrapeResult } from "./scrapeResult"; +import { ObjectScrapeResult, ScrapeResult } from "./scrapeResult"; import { useIntl } from "react-intl"; import { scrapedPerformerToCreateInput } from "src/core/performers"; import { scrapedMovieToCreateInput } from "src/core/movies"; @@ -41,8 +41,10 @@ function useCreateObject( } interface IUseCreateNewStudioProps { - scrapeResult: ScrapeResult; - setScrapeResult: (scrapeResult: ScrapeResult) => void; + scrapeResult: ObjectScrapeResult; + setScrapeResult: ( + scrapeResult: ObjectScrapeResult + ) => void; setNewObject: (newObject: GQL.ScrapedStudio | undefined) => void; } @@ -62,21 +64,28 @@ export function useCreateScrapedStudio(props: IUseCreateNewStudioProps) { }); // set the new studio as the value - setScrapeResult(scrapeResult.cloneWithValue(result.data!.studioCreate!.id)); + setScrapeResult( + scrapeResult.cloneWithValue({ + stored_id: result.data!.studioCreate!.id, + name: toCreate.name, + }) + ); setNewObject(undefined); } return useCreateObject("studio", createNewStudio); } -interface IUseCreateNewPerformerProps { - scrapeResult: ScrapeResult; - setScrapeResult: (scrapeResult: ScrapeResult) => void; - newObjects: GQL.ScrapedPerformer[]; - setNewObjects: (newObject: GQL.ScrapedPerformer[]) => void; +interface IUseCreateNewObjectProps { + scrapeResult: ScrapeResult; + setScrapeResult: (scrapeResult: ScrapeResult) => void; + newObjects: T[]; + setNewObjects: (newObject: T[]) => void; } -export function useCreateScrapedPerformer(props: IUseCreateNewPerformerProps) { +export function useCreateScrapedPerformer( + props: IUseCreateNewObjectProps +) { const [createPerformer] = usePerformerCreate(); const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; @@ -173,20 +182,39 @@ export function useCreateScrapedMovie( } export function useCreateScrapedTag( - props: IUseCreateNewObjectIDListProps + props: IUseCreateNewObjectProps ) { const [createTag] = useTagCreate(); + const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; + async function createNewTag(toCreate: GQL.ScrapedTag) { - const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" }; + const input: GQL.TagCreateInput = { name: toCreate.name ?? "" }; + const result = await createTag({ - variables: { - input: tagInput, - }, + variables: { input }, }); - return result.data?.tagCreate?.id ?? ""; + const newValue = [...(scrapeResult.newValue ?? [])]; + if (result.data?.tagCreate) + newValue.push({ + stored_id: result.data.tagCreate.id, + name: result.data.tagCreate.name, + }); + + // add the new tag to the new tags value + const tagClone = scrapeResult.cloneWithValue(newValue); + setScrapeResult(tagClone); + + // remove the tag from the list + const newTagsClone = newObjects.concat(); + const pIndex = newTagsClone.findIndex((p) => p.name === toCreate.name); + if (pIndex === -1) throw new Error("Could not find tag to remove"); + + newTagsClone.splice(pIndex, 1); + + setNewObjects(newTagsClone); } - return useCreateNewObjectIDList("tag", props, createNewTag); + return useCreateObject("tag", createNewTag); } diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts index dd4662763b6..b9b88cef009 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts @@ -106,6 +106,23 @@ export class ObjectListScrapeResult< } } +export class ObjectScrapeResult< + T extends IHasStoredID +> extends ScrapeResult { + public constructor( + originalValue?: T | null, + newValue?: T | null, + useNewValue?: boolean + ) { + super( + originalValue, + newValue, + useNewValue, + (o1, o2) => o1?.stored_id === o2?.stored_id + ); + } +} + export function hasScrapedValues(values: { scraped: boolean }[]): boolean { return values.some((r) => r.scraped); } diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index a3459830fef..1b096c14179 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useMemo, useState } from "react"; import Select, { OnChangeValue, StylesConfig, @@ -8,17 +8,14 @@ import Select, { MenuListProps, GroupBase, OptionsOrGroups, + DropdownIndicatorProps, } from "react-select"; import CreatableSelect from "react-select/creatable"; import * as GQL from "src/core/generated-graphql"; import { - useAllTagsForFilter, useAllMoviesForFilter, - useAllStudiosForFilter, useMarkerStrings, - useTagCreate, - useStudioCreate, useMovieCreate, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; @@ -26,12 +23,15 @@ import { SelectComponents } from "react-select/dist/declarations/src/components" import { ConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; import { objectTitle } from "src/core/files"; -import { galleryTitle } from "src/core/galleries"; -import { TagPopover } from "../Tags/TagPopover"; -import { defaultMaxOptionsShown, IUIConfig } from "src/core/config"; +import { defaultMaxOptionsShown } from "src/core/config"; import { useDebounce } from "src/hooks/debounce"; import { Placement } from "react-bootstrap/esm/Overlay"; import { PerformerIDSelect } from "../Performers/PerformerSelect"; +import { Icon } from "./Icon"; +import { faTableColumns } from "@fortawesome/free-solid-svg-icons"; +import { TagIDSelect } from "../Tags/TagSelect"; +import { StudioIDSelect } from "../Studios/StudioSelect"; +import { GalleryIDSelect } from "../Galleries/GallerySelect"; export type SelectObject = { id: string; @@ -47,7 +47,8 @@ interface ITypeProps { | "tags" | "scene_tags" | "performer_tags" - | "movies"; + | "movies" + | "galleries"; } interface IFilterProps { ids?: string[]; @@ -132,7 +133,7 @@ const LimitedSelectMenu = ( ) => { const { configuration } = React.useContext(ConfigurationContext); const maxOptionsShown = - (configuration?.ui as IUIConfig).maxOptionsShown ?? defaultMaxOptionsShown; + configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; const [hiddenCount, setHiddenCount] = useState(0); const hiddenCountStyle = { @@ -333,55 +334,10 @@ const FilterSelectComponent = ( ); }; -export const GallerySelect: React.FC = (props) => { - const [query, setQuery] = useState(""); - const { data, loading } = GQL.useFindGalleriesQuery({ - skip: query === "", - variables: { - filter: { - q: query, - }, - }, - }); - - const galleries = data?.findGalleries.galleries ?? []; - const items = galleries.map((g) => ({ - label: galleryTitle(g), - value: g.id, - })); - - const onInputChange = useDebounce(setQuery, 500); - - const onChange = (selectedItems: OnChangeValue) => { - const selected = getSelectedItems(selectedItems); - props.onSelect( - selected.map((s) => ({ - id: s.value, - title: s.label, - })) - ); - }; - - const options = props.selected.map((g) => ({ - value: g.id, - label: g.title ?? "Unknown", - })); - - return ( - - ); +export const GallerySelect: React.FC< + IFilterProps & { excludeIds?: string[] } +> = (props) => { + return ; }; export const SceneSelect: React.FC = (props) => { @@ -533,144 +489,7 @@ export const PerformerSelect: React.FC = (props) => { export const StudioSelect: React.FC< IFilterProps & { excludeIds?: string[] } > = (props) => { - const [studioAliases, setStudioAliases] = useState>( - {} - ); - const [allAliases, setAllAliases] = useState([]); - const { data, loading } = useAllStudiosForFilter(); - const [createStudio] = useStudioCreate(); - const intl = useIntl(); - - const { configuration } = React.useContext(ConfigurationContext); - const defaultCreatable = - !configuration?.interface.disableDropdownCreate.studio ?? true; - - const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); - const studios = useMemo( - () => - (data?.allStudios ?? []).filter((studio) => !exclude.includes(studio.id)), - [data?.allStudios, exclude] - ); - - useEffect(() => { - // build the studio aliases map - const newAliases: Record = {}; - const newAll: string[] = []; - studios.forEach((s) => { - newAliases[s.id] = s.aliases; - newAll.push(...s.aliases); - }); - setStudioAliases(newAliases); - setAllAliases(newAll); - }, [studios]); - - const StudioOption: React.FC> = ( - optionProps - ) => { - const { inputValue } = optionProps.selectProps; - - let thisOptionProps = optionProps; - if ( - inputValue && - !optionProps.label.toLowerCase().includes(inputValue.toLowerCase()) - ) { - // must be alias - const newLabel = `${optionProps.data.label} (alias)`; - thisOptionProps = { - ...optionProps, - children: newLabel, - }; - } - - return ; - }; - - const filterOption = (option: Option, rawInput: string): boolean => { - if (!rawInput) { - return true; - } - - const input = rawInput.toLowerCase(); - const optionVal = option.label.toLowerCase(); - - if (optionVal.includes(input)) { - return true; - } - - // search for studio aliases - const aliases = studioAliases[option.value]; - // only match on alias if exact - if (aliases && aliases.some((a) => a.toLowerCase() === input)) { - return true; - } - - return false; - }; - - const onCreate = async (name: string) => { - const result = await createStudio({ - variables: { - input: { name }, - }, - }); - return { - item: result.data!.studioCreate!, - message: intl.formatMessage( - { id: "toast.created_entity" }, - { entity: intl.formatMessage({ id: "studio" }).toLocaleLowerCase() } - ), - }; - }; - - const isValidNewOption = ( - inputValue: string, - value: OnChangeValue, - options: OptionsOrGroups> - ) => { - if (!inputValue) { - return false; - } - - if ( - (options as Options