diff --git a/go.mod b/go.mod index bb05736f6c4..5b82dcc4aec 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ require ( github.com/99designs/gqlgen v0.17.2 github.com/Yamashou/gqlgenc v0.0.6 github.com/anacrolix/dms v1.2.2 - github.com/antchfx/htmlquery v1.2.5-0.20211125074323-810ee8082758 + github.com/antchfx/htmlquery v1.3.0 github.com/chromedp/cdproto v0.0.0-20210622022015-fe1827b46b84 github.com/chromedp/chromedp v0.7.3 github.com/corona10/goimagehash v1.0.3 @@ -66,7 +66,7 @@ require ( require ( github.com/agnivade/levenshtein v1.1.1 // indirect - github.com/antchfx/xpath v1.2.0 // indirect + github.com/antchfx/xpath v1.2.3 // indirect github.com/asticode/go-astikit v0.20.0 // indirect github.com/asticode/go-astits v1.8.0 // indirect github.com/chromedp/sysutil v1.0.0 // indirect diff --git a/go.sum b/go.sum index b9524d0cbc1..e06dae76834 100644 --- a/go.sum +++ b/go.sum @@ -94,10 +94,10 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.0.3 h1:fpcw+r1N1h0Poc1F/pHbW40cUm/lMEQslZtCkBQ0UnM= github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/antchfx/htmlquery v1.2.5-0.20211125074323-810ee8082758 h1:Ldjwcl7T8VqCKgQQ0TfPI8fNb8O/GtMXcYaHlqOu99s= -github.com/antchfx/htmlquery v1.2.5-0.20211125074323-810ee8082758/go.mod h1:2xO6iu3EVWs7R2JYqBbp8YzG50gj/ofqs5/0VZoDZLc= -github.com/antchfx/xpath v1.2.0 h1:mbwv7co+x0RwgeGAOHdrKy89GvHaGvxxBtPK0uF9Zr8= -github.com/antchfx/xpath v1.2.0/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/antchfx/htmlquery v1.3.0 h1:5I5yNFOVI+egyia5F2s/5Do2nFWxJz41Tr3DyfKD25E= +github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8= +github.com/antchfx/xpath v1.2.3 h1:CCZWOzv5bAqjVv0offZ2LVgVYFbeldKQVuLNbViZdes= +github.com/antchfx/xpath v1.2.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/arrow/go/arrow v0.0.0-20200601151325-b2287a20f230/go.mod h1:QNYViu/X0HXDHw7m3KXzWSVXIbfUvJqBFe6Gj8/pYA0= github.com/apache/arrow/go/arrow v0.0.0-20210521153258-78c88a9f517b/go.mod h1:R4hW3Ug0s+n4CUsWHKOj00Pu01ZqU4x/hSF5kXUcXKQ= @@ -909,7 +909,6 @@ golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= @@ -935,6 +934,7 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/oauth2 v0.0.0-20180227000427-d7d64896b5ff/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -1064,10 +1064,12 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1079,6 +1081,7 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/graphql/documents/data/scene-marker.graphql b/graphql/documents/data/scene-marker.graphql index 61439bd1e80..9fd0c7d3ded 100644 --- a/graphql/documents/data/scene-marker.graphql +++ b/graphql/documents/data/scene-marker.graphql @@ -13,12 +13,10 @@ fragment SceneMarkerData on SceneMarker { primary_tag { id name - aliases } tags { id name - aliases } } diff --git a/graphql/documents/data/tag-slim.graphql b/graphql/documents/data/tag-slim.graphql index 26b7c277a5b..e35660de624 100644 --- a/graphql/documents/data/tag-slim.graphql +++ b/graphql/documents/data/tag-slim.graphql @@ -3,4 +3,6 @@ fragment SlimTagData on Tag { name aliases image_path + parent_count + child_count } diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 6260856572c..eba9b1996ef 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -15,6 +15,9 @@ type Tag { performer_count(depth: Int): Int! # Resolver parents: [Tag!]! children: [Tag!]! + + parent_count: Int! # Resolver + child_count: Int! # Resolver } input TagCreateInput { diff --git a/internal/api/changeset_translator.go b/internal/api/changeset_translator.go index 0472edc4c76..412f12db99e 100644 --- a/internal/api/changeset_translator.go +++ b/internal/api/changeset_translator.go @@ -415,9 +415,9 @@ func (t changesetTranslator) updateMovieIDsBulk(value *BulkUpdateIds, field stri return nil, fmt.Errorf("converting ids [%v]: %w", value.Ids, err) } - movies := make([]models.MoviesScenes, len(value.Ids)) - for _, id := range ids { - movies = append(movies, models.MoviesScenes{MovieID: id}) + movies := make([]models.MoviesScenes, len(ids)) + for i, id := range ids { + movies[i] = models.MoviesScenes{MovieID: id} } return &models.UpdateMovieIDs{ diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index 778dc7fa623..9124b18f483 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -113,3 +113,25 @@ func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj).GetTagImageURL(hasImage) return &imagePath, nil } + +func (r *tagResolver) ParentCount(ctx context.Context, obj *models.Tag) (ret int, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.Tag.CountByParentTagID(ctx, obj.ID) + return err + }); err != nil { + return ret, err + } + + return ret, nil +} + +func (r *tagResolver) ChildCount(ctx context.Context, obj *models.Tag) (ret int, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.Tag.CountByChildTagID(ctx, obj.ID) + return err + }); err != nil { + return ret, err + } + + return ret, nil +} diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index 782d0aff0af..37a9be9c844 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -536,6 +536,8 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput } var values *models.ScenePartial + var coverImageData []byte + if input.Values != nil { translator := changesetTranslator{ inputMap: getNamedUpdateInputMap(ctx, "input.values"), @@ -545,20 +547,19 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput if err != nil { return nil, err } + + if input.Values.CoverImage != nil { + var err error + coverImageData, err = utils.ProcessImageInput(ctx, *input.Values.CoverImage) + if err != nil { + return nil, fmt.Errorf("processing cover image: %w", err) + } + } } else { v := models.NewScenePartial() values = &v } - var coverImageData []byte - if input.Values.CoverImage != nil { - var err error - coverImageData, err = utils.ProcessImageInput(ctx, *input.Values.CoverImage) - if err != nil { - return nil, fmt.Errorf("processing cover image: %w", err) - } - } - 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 { diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index e69dccf1dfa..ed4eea17116 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -383,8 +383,8 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB } // Check if the user wants to refresh existing or new items - if (input.Refresh && len(performer.StashIDs.List()) > 0) || - (!input.Refresh && len(performer.StashIDs.List()) == 0) { + hasStashID := performer.StashIDs.ForEndpoint(box.Endpoint) != nil + if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) { tasks = append(tasks, StashBoxBatchTagTask{ performer: performer, refresh: input.Refresh, @@ -516,8 +516,8 @@ func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, input StashBoxBatc } // Check if the user wants to refresh existing or new items - if (input.Refresh && len(studio.StashIDs.List()) > 0) || - (!input.Refresh && len(studio.StashIDs.List()) == 0) { + hasStashID := studio.StashIDs.ForEndpoint(box.Endpoint) != nil + if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) { tasks = append(tasks, StashBoxBatchTagTask{ studio: studio, refresh: input.Refresh, diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index a061b79b2c9..9b610e49b6e 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -58,6 +58,48 @@ func (_m *TagReaderWriter) Count(ctx context.Context) (int, error) { return r0, r1 } +// CountByChildTagID provides a mock function with given fields: ctx, childID +func (_m *TagReaderWriter) CountByChildTagID(ctx context.Context, childID int) (int, error) { + ret := _m.Called(ctx, childID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, childID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, childID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// CountByParentTagID provides a mock function with given fields: ctx, parentID +func (_m *TagReaderWriter) CountByParentTagID(ctx context.Context, parentID int) (int, error) { + ret := _m.Called(ctx, parentID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, parentID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, parentID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Create provides a mock function with given fields: ctx, newTag func (_m *TagReaderWriter) Create(ctx context.Context, newTag *models.Tag) error { ret := _m.Called(ctx, newTag) diff --git a/pkg/models/relationships.go b/pkg/models/relationships.go index 2e6f07708a0..2c2bc60b10b 100644 --- a/pkg/models/relationships.go +++ b/pkg/models/relationships.go @@ -208,6 +208,19 @@ func (r RelatedStashIDs) List() []StashID { return r.list } +// ForID returns the StashID object for the given endpoint. Returns nil if not found. +func (r *RelatedStashIDs) ForEndpoint(endpoint string) *StashID { + r.mustLoaded() + + for _, v := range r.list { + if v.Endpoint == endpoint { + return &v + } + } + + return nil +} + func (r *RelatedStashIDs) load(fn func() ([]StashID, error)) error { if r.Loaded() { return nil diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index 6351c2bdfa6..ca8f6971bf7 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -42,6 +42,8 @@ type TagAutoTagQueryer interface { // TagCounter provides methods to count tags. type TagCounter interface { Count(ctx context.Context) (int, error) + CountByParentTagID(ctx context.Context, parentID int) (int, error) + CountByChildTagID(ctx context.Context, childID int) (int, error) } // TagCreator provides methods to create tags. diff --git a/pkg/sqlite/migrations/48_premigrate.go b/pkg/sqlite/migrations/48_premigrate.go index b16c2258f9d..f0e59620e04 100644 --- a/pkg/sqlite/migrations/48_premigrate.go +++ b/pkg/sqlite/migrations/48_premigrate.go @@ -130,7 +130,7 @@ func (m *schema48PreMigrator) fixStudioNames(ctx context.Context) error { } } - logger.Info("Renaming duplicate studio id %d to %s", id, newName) + logger.Infof("Renaming duplicate studio id %d to %s", id, newName) _, err := m.db.Exec("UPDATE studios SET name = ? WHERE id = ?", newName, id) if err != nil { return err diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 33273525402..ace5f8346da 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -396,6 +396,20 @@ func (qb *TagStore) FindByChildTagID(ctx context.Context, parentID int) ([]*mode return qb.queryTags(ctx, query, args) } +func (qb *TagStore) CountByParentTagID(ctx context.Context, parentID int) (int, error) { + q := dialect.Select(goqu.COUNT("*")).From(goqu.T("tags")). + InnerJoin(goqu.T("tags_relations"), goqu.On(goqu.I("tags_relations.parent_id").Eq(goqu.I("tags.id")))). + Where(goqu.I("tags_relations.child_id").Eq(goqu.V(parentID))) // Pass the parentID here + return count(ctx, q) +} + +func (qb *TagStore) CountByChildTagID(ctx context.Context, childID int) (int, error) { + q := dialect.Select(goqu.COUNT("*")).From(goqu.T("tags")). + InnerJoin(goqu.T("tags_relations"), goqu.On(goqu.I("tags_relations.child_id").Eq(goqu.I("tags.id")))). + Where(goqu.I("tags_relations.parent_id").Eq(goqu.V(childID))) // Pass the childID here + return count(ctx, q) +} + func (qb *TagStore) Count(ctx context.Context) (int, error) { q := dialect.Select(goqu.COUNT("*")).From(qb.table()) return count(ctx, q) diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index 88fe37f2aae..c62b5b7833a 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -5,7 +5,7 @@ import * as GQL from "src/core/generated-graphql"; import { GridCard } from "../Shared/GridCard"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; -import { TagLink } from "../Shared/TagLink"; +import { SceneLink, TagLink } from "../Shared/TagLink"; import { TruncatedText } from "../Shared/TruncatedText"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; @@ -31,7 +31,7 @@ export const GalleryCard: React.FC = (props) => { if (props.gallery.scenes.length === 0) return; const popoverContent = props.gallery.scenes.map((scene) => ( - + )); return ( @@ -52,7 +52,7 @@ export const GalleryCard: React.FC = (props) => { if (props.gallery.tags.length <= 0) return; const popoverContent = props.gallery.tags.map((tag) => ( - + )); return ( diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx index 463ced50611..83ffe2bc3d3 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx @@ -34,7 +34,7 @@ export const GalleryDetailPanel: React.FC = ({ function renderTags() { if (gallery.tags.length === 0) return; const tags = gallery.tags.map((tag) => ( - + )); return ( <> diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index 28598d417c2..5f8c57a53bf 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -3,7 +3,7 @@ import { Button, ButtonGroup } from "react-bootstrap"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "src/components/Shared/Icon"; -import { TagLink } from "src/components/Shared/TagLink"; +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"; @@ -41,7 +41,7 @@ export const ImageCard: React.FC = ( if (props.image.tags.length <= 0) return; const popoverContent = props.image.tags.map((tag) => ( - + )); return ( @@ -83,7 +83,7 @@ export const ImageCard: React.FC = ( if (props.image.galleries.length <= 0) return; const popoverContent = props.image.galleries.map((gallery) => ( - + )); return ( diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx index c4e840e2cbb..417d425cc7b 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; -import { TagLink } from "src/components/Shared/TagLink"; +import { GalleryLink, TagLink } from "src/components/Shared/TagLink"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; @@ -24,7 +24,7 @@ export const ImageDetailPanel: React.FC = (props) => { function renderTags() { if (props.image.tags.length === 0) return; const tags = props.image.tags.map((tag) => ( - + )); return ( <> @@ -67,8 +67,8 @@ export const ImageDetailPanel: React.FC = (props) => { function renderGalleries() { if (props.image.galleries.length === 0) return; - const tags = props.image.galleries.map((gallery) => ( - + const galleries = props.image.galleries.map((gallery) => ( + )); return ( <> @@ -78,7 +78,7 @@ export const ImageDetailPanel: React.FC = (props) => { values={{ count: props.image.galleries.length }} /> - {tags} + {galleries} ); } diff --git a/ui/v2.5/src/components/Images/ImageWallItem.tsx b/ui/v2.5/src/components/Images/ImageWallItem.tsx index f1d2856da52..8403b3a98da 100644 --- a/ui/v2.5/src/components/Images/ImageWallItem.tsx +++ b/ui/v2.5/src/components/Images/ImageWallItem.tsx @@ -44,6 +44,7 @@ export const ImageWallItem: React.FC = ( return ( = (props: IProps) => { if (props.movie.scenes.length === 0) return; const popoverContent = props.movie.scenes.map((scene) => ( - + )); return ( diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index c34b184a5bf..fab6acad865 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -168,7 +168,7 @@ export const PerformerCard: React.FC = ({ if (performer.tags.length <= 0) return; const popoverContent = performer.tags.map((tag) => ( - + )); return ( diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 84faefe6389..b9c9c2855e7 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -29,7 +29,7 @@ export const PerformerDetailsPanel: React.FC = ({ return (
    {(performer.tags ?? []).map((tag) => ( - + ))}
); diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx index c45d1b29362..d64d15a1585 100644 --- a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx +++ b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx @@ -1,9 +1,10 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Button, ButtonGroup, Card, Col, + Dropdown, Form, OverlayTrigger, Row, @@ -18,7 +19,12 @@ import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { ErrorMessage } from "../Shared/ErrorMessage"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; -import { TagLink } from "../Shared/TagLink"; +import { + GalleryLink, + MovieLink, + SceneMarkerLink, + TagLink, +} from "../Shared/TagLink"; import { SweatDrops } from "../Shared/SweatDrops"; import { Pagination } from "src/components/List/Pagination"; import TextUtils from "src/utils/text"; @@ -46,7 +52,6 @@ const defaultDurationDiff = "1"; export const SceneDuplicateChecker: React.FC = () => { const intl = useIntl(); const history = useHistory(); - const query = new URLSearchParams(history.location.search); const currentPage = Number.parseInt(query.get("page") ?? "1", 10); const pageSize = Number.parseInt(query.get("size") ?? "20", 10); @@ -59,9 +64,12 @@ export const SceneDuplicateChecker: React.FC = () => { const [isMultiDelete, setIsMultiDelete] = useState(false); const [deletingScenes, setDeletingScenes] = useState(false); const [editingScenes, setEditingScenes] = useState(false); + const [chkSafeSelect, setChkSafeSelect] = useState(true); + const [checkedScenes, setCheckedScenes] = useState>( {} ); + const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({ fetchPolicy: "no-cache", variables: { @@ -69,6 +77,9 @@ export const SceneDuplicateChecker: React.FC = () => { duration_diff: durationDiff, }, }); + + const scenes = data?.findDuplicateScenes ?? []; + const { data: missingPhash } = GQL.useFindScenesQuery({ variables: { filter: { @@ -91,10 +102,27 @@ export const SceneDuplicateChecker: React.FC = () => { const [mergeScenes, setMergeScenes] = useState<{ id: string; title: string }[]>(); + const pageOptions = useMemo(() => { + const pageSizes = [ + 10, 20, 30, 40, 50, 100, 150, 200, 250, 500, 750, 1000, 1250, 1500, + ]; + + const filteredSizes = pageSizes.filter((s, i) => { + return scenes.length > s || i == 0 || scenes.length > pageSizes[i - 1]; + }); + + return filteredSizes.map((size) => { + return ( + + ); + }); + }, [scenes.length]); + if (loading) return ; if (!data) return ; - const scenes = data?.findDuplicateScenes ?? []; const filteredScenes = scenes.slice( (currentPage - 1) * pageSize, currentPage * pageSize @@ -116,6 +144,16 @@ export const SceneDuplicateChecker: React.FC = () => { history.push({ search: newQuery.toString() }); }; + const resetCheckboxSelection = () => { + const updatedScenes: Record = {}; + + Object.keys(checkedScenes).forEach((sceneKey) => { + updatedScenes[sceneKey] = false; + }); + + setCheckedScenes(updatedScenes); + }; + function onDeleteDialogClosed(deleted: boolean) { setDeletingScenes(false); if (deleted) { @@ -123,8 +161,102 @@ export const SceneDuplicateChecker: React.FC = () => { refetch(); if (isMultiDelete) setCheckedScenes({}); } + resetCheckboxSelection(); + } + + const findLargestScene = (group: GQL.SlimSceneDataFragment[]) => { + // Get total size of a scene + const totalSize = (scene: GQL.SlimSceneDataFragment) => { + return scene.files.reduce((sum: number, f) => sum + (f.size || 0), 0); + }; + // Find scene object with maximum total size + return group.reduce((largest, scene) => { + const largestSize = totalSize(largest); + const currentSize = totalSize(scene); + return currentSize > largestSize ? scene : largest; + }); + }; + + // Helper to get file date + + const findFirstFileByAge = ( + oldest: boolean, + compareScenes: GQL.SlimSceneDataFragment[] + ) => { + let selectedFile: GQL.VideoFileDataFragment; + let oldestTimestamp: Date | undefined = undefined; + + // Loop through all files + for (const file of compareScenes.flatMap((s) => s.files)) { + // Get timestamp + const timestamp: Date = new Date(file.mod_time); + + // Check if current file is oldest + if (oldest) { + if (oldestTimestamp === undefined || timestamp < oldestTimestamp) { + oldestTimestamp = timestamp; + selectedFile = file; + } + } else { + if (oldestTimestamp === undefined || timestamp > oldestTimestamp) { + oldestTimestamp = timestamp; + selectedFile = file; + } + } + } + + // Find scene with oldest file + return compareScenes.find((s) => + s.files.some((f) => f.id === selectedFile.id) + ); + }; + + function checkSameCodec(codecGroup: GQL.SlimSceneDataFragment[]) { + const codecs = codecGroup.map((s) => s.files[0]?.video_codec); + return new Set(codecs).size === 1; } + const onSelectLargestClick = () => { + setSelectedScenes([]); + const checkedArray: Record = {}; + + filteredScenes.forEach((group) => { + if (chkSafeSelect && !checkSameCodec(group)) { + return; + } + // Find largest scene in group a + const largest = findLargestScene(group); + group.forEach((scene) => { + if (scene !== largest) { + checkedArray[scene.id] = true; + } + }); + }); + + setCheckedScenes(checkedArray); + }; + + const onSelectByAge = (oldest: boolean) => { + setSelectedScenes([]); + + const checkedArray: Record = {}; + + filteredScenes.forEach((group) => { + if (chkSafeSelect && !checkSameCodec(group)) { + return; + } + + const oldestScene = findFirstFileByAge(oldest, group); + group.forEach((scene) => { + if (scene !== oldestScene) { + checkedArray[scene.id] = true; + } + }); + }); + + setCheckedScenes(checkedArray); + }; + const handleCheck = (checked: boolean, sceneID: string) => { setCheckedScenes({ ...checkedScenes, [sceneID]: checked }); }; @@ -144,6 +276,7 @@ export const SceneDuplicateChecker: React.FC = () => { function onEdit() { setSelectedScenes(scenes.flat().filter((s) => checkedScenes[s.id])); setEditingScenes(true); + resetCheckboxSelection(); } const renderFilesize = (filesize: number | null | undefined) => { @@ -221,7 +354,7 @@ export const SceneDuplicateChecker: React.FC = () => { src={sceneMovie.movie.front_image_path ?? ""} /> - { if (scene.scene_markers.length <= 0) return; const popoverContent = scene.scene_markers.map((marker) => { - const markerPopover = { ...marker, scene: { id: scene.id } }; - return ; + const markerWithScene = { ...marker, scene: { id: scene.id } }; + return ; }); return ( @@ -282,7 +415,7 @@ export const SceneDuplicateChecker: React.FC = () => { if (scene.galleries.length <= 0) return; const popoverContent = scene.galleries.map((gallery) => ( - + )); return ( @@ -395,9 +528,10 @@ export const SceneDuplicateChecker: React.FC = () => { currentPage={currentPage} totalItems={scenes.length} metadataByline={[]} - onChangePage={(newPage) => - setQuery({ page: newPage === 1 ? undefined : newPage }) - } + onChangePage={(newPage) => { + setQuery({ page: newPage === 1 ? undefined : newPage }); + resetCheckboxSelection(); + }} /> { ? undefined : e.currentTarget.value, }); + resetCheckboxSelection(); }} > - - - - - + {pageOptions} ); @@ -572,6 +703,54 @@ export const SceneDuplicateChecker: React.FC = () => { + + + + + + + + + resetCheckboxSelection()}> + {intl.formatMessage({ id: "dupe_check.select_none" })} + + + onSelectLargestClick()}> + {intl.formatMessage({ + id: "dupe_check.select_all_but_largest_file", + })} + + + onSelectByAge(true)}> + {intl.formatMessage({ + id: "dupe_check.select_oldest", + })} + + + onSelectByAge(false)}> + {intl.formatMessage({ + id: "dupe_check.select_youngest", + })} + + + + + + + { + setChkSafeSelect(e.target.checked); + resetCheckboxSelection(); + }} + /> + + {maybeRenderMissingPhashWarning()} @@ -621,6 +800,7 @@ export const SceneDuplicateChecker: React.FC = () => { > handleCheck(e.currentTarget.checked, scene.id) } @@ -641,15 +821,36 @@ export const SceneDuplicateChecker: React.FC = () => { src={scene.paths.sprite ?? ""} alt="" width={100} + style={{ + border: checkedScenes[scene.id] + ? "2px solid red" + : "", + }} />

- + + {" "} {scene.title ? scene.title - : TextUtils.fileNameFromPath(file?.path ?? "")} + : TextUtils.fileNameFromPath( + file?.path ?? "" + )}{" "}

{file?.path ?? ""}

diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss b/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss index 9177a9367c9..750e4466fdd 100644 --- a/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss +++ b/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss @@ -8,7 +8,8 @@ } .separator { - height: 50px; + border-top: 1px solid white; + height: 10px; } .form-group .row { diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index b01cf698faf..0672ae4a61f 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -4,7 +4,12 @@ import { Link, useHistory } from "react-router-dom"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; -import { TagLink } from "../Shared/TagLink"; +import { + GalleryLink, + TagLink, + MovieLink, + SceneMarkerLink, +} from "../Shared/TagLink"; import { HoverPopover } from "../Shared/HoverPopover"; import { SweatDrops } from "../Shared/SweatDrops"; import { TruncatedText } from "../Shared/TruncatedText"; @@ -219,7 +224,7 @@ export const SceneCard: React.FC = ( src={sceneMovie.movie.front_image_path ?? ""} /> - = ( if (props.scene.scene_markers.length <= 0) return; const popoverContent = props.scene.scene_markers.map((marker) => { - const markerPopover = { ...marker, scene: { id: props.scene.id } }; - return ; + const markerWithScene = { ...marker, scene: { id: props.scene.id } }; + return ; }); return ( @@ -282,7 +287,7 @@ export const SceneCard: React.FC = ( if (props.scene.galleries.length <= 0) return; const popoverContent = props.scene.galleries.map((gallery) => ( - + )); return ( diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx index f658d34b16f..9694ca9ed29 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx @@ -18,18 +18,19 @@ export const PrimaryTags: React.FC = ({ }) => { if (!sceneMarkers?.length) return
; - const primaries: Record = {}; - const primaryTags: Record = {}; + const primaryTagNames: Record = {}; + const markersByTag: Record = {}; sceneMarkers.forEach((m) => { - if (primaryTags[m.primary_tag.id]) primaryTags[m.primary_tag.id].push(m); - else { - primaryTags[m.primary_tag.id] = [m]; - primaries[m.primary_tag.id] = m.primary_tag; + if (primaryTagNames[m.primary_tag.id]) { + markersByTag[m.primary_tag.id].push(m); + } else { + primaryTagNames[m.primary_tag.id] = m.primary_tag.name; + markersByTag[m.primary_tag.id] = [m]; } }); - const primaryCards = Object.keys(primaryTags).map((id) => { - const markers = primaryTags[id].map((marker) => { + const primaryCards = Object.keys(markersByTag).map((id) => { + const markers = markersByTag[id].map((marker) => { const tags = marker.tags.map((tag) => ( {tag.name} @@ -59,7 +60,7 @@ export const PrimaryTags: React.FC = ({ return ( -

{primaries[id].name}

+

{primaryTagNames[id]}

{markers}
); diff --git a/ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx b/ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx index 9d0cfb6fea5..0f98f732b63 100644 --- a/ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx +++ b/ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx @@ -6,7 +6,7 @@ import * as GQL from "src/core/generated-graphql"; import { sortPerformers } from "src/core/performers"; import { HoverPopover } from "./HoverPopover"; import { Icon } from "./Icon"; -import { TagLink } from "./TagLink"; +import { PerformerLink } from "./TagLink"; interface IProps { performers: Partial[]; @@ -26,7 +26,11 @@ export const PerformerPopoverButton: React.FC = ({ performers }) => { src={performer.image_path ?? ""} /> - +
)); diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 5a1c5b2fecc..e99556a6da3 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -767,12 +767,12 @@ export const TagSelect: React.FC< }; } - const id = (optionProps.data as Option & { __isNew__: boolean }).__isNew__ - ? "" - : optionProps.data.value; + const id = optionProps.data.value; + const hide = (optionProps.data as Option & { __isNew__: boolean }) + .__isNew__; return ( - + ); diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index 38cb1326623..2af75a8ae11 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -1,97 +1,252 @@ -import { Badge } from "react-bootstrap"; -import React from "react"; +import { Badge, OverlayTrigger, Tooltip } from "react-bootstrap"; +import React, { useMemo } from "react"; import { Link } from "react-router-dom"; import cx from "classnames"; -import { - PerformerDataFragment, - TagDataFragment, - MovieDataFragment, - SceneDataFragment, -} from "src/core/generated-graphql"; -import NavUtils from "src/utils/navigation"; +import NavUtils, { INamedObject } from "src/utils/navigation"; import TextUtils from "src/utils/text"; -import { objectTitle } from "src/core/files"; +import { IFile, IObjectWithTitleFiles, objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import * as GQL from "src/core/generated-graphql"; import { TagPopover } from "../Tags/TagPopover"; import { markerTitle } from "src/core/markers"; import { Placement } from "react-bootstrap/esm/Overlay"; +import { faFolderTree } from "@fortawesome/free-solid-svg-icons"; +import { Icon } from "../Shared/Icon"; +import { FormattedMessage } from "react-intl"; -interface IFile { - path: string; +type SceneMarkerFragment = Pick & { + scene: Pick; + primary_tag: Pick; +}; + +interface ICommonLinkProps { + link: string; + className?: string; +} + +const CommonLinkComponent: React.FC = ({ + link, + className, + children, +}) => { + return ( + + {children} + + ); +}; + +interface IPerformerLinkProps { + performer: INamedObject; + linkType?: "scene" | "gallery" | "image"; + className?: string; } -interface IGallery { + +export const PerformerLink: React.FC = ({ + performer, + linkType = "scene", + className, +}) => { + const link = useMemo(() => { + switch (linkType) { + case "gallery": + return NavUtils.makePerformerGalleriesUrl(performer); + case "image": + return NavUtils.makePerformerImagesUrl(performer); + case "scene": + default: + return NavUtils.makePerformerScenesUrl(performer); + } + }, [performer, linkType]); + + const title = performer.name || ""; + + return ( + + {title} + + ); +}; + +interface IMovieLinkProps { + movie: INamedObject; + linkType?: "scene"; + className?: string; +} + +export const MovieLink: React.FC = ({ + movie, + linkType = "scene", + className, +}) => { + const link = useMemo(() => { + switch (linkType) { + case "scene": + return NavUtils.makeMovieScenesUrl(movie); + } + }, [movie, linkType]); + + const title = movie.name || ""; + + return ( + + {title} + + ); +}; + +interface ISceneMarkerLinkProps { + marker: SceneMarkerFragment; + linkType?: "scene"; + className?: string; +} + +export const SceneMarkerLink: React.FC = ({ + marker, + linkType = "scene", + className, +}) => { + const link = useMemo(() => { + switch (linkType) { + case "scene": + return NavUtils.makeSceneMarkerUrl(marker); + } + }, [marker, linkType]); + + const title = `${markerTitle(marker)} - ${TextUtils.secondsToTimestamp( + marker.seconds || 0 + )}`; + + return ( + + {title} + + ); +}; + +interface IObjectWithIDTitleFiles extends IObjectWithTitleFiles { id: string; - files: IFile[]; +} + +interface ISceneLinkProps { + scene: IObjectWithIDTitleFiles; + linkType?: "details"; + className?: string; +} + +export const SceneLink: React.FC = ({ + scene, + linkType = "details", + className, +}) => { + const link = useMemo(() => { + switch (linkType) { + case "details": + return `/scenes/${scene.id}`; + } + }, [scene, linkType]); + + const title = objectTitle(scene); + + return ( + + {title} + + ); +}; + +interface IGallery extends IObjectWithIDTitleFiles { folder?: GQL.Maybe; - title: GQL.Maybe; } -type SceneMarkerFragment = Pick & { - scene: Pick; - primary_tag: Pick; +interface IGalleryLinkProps { + gallery: IGallery; + linkType?: "details"; + className?: string; +} + +export const GalleryLink: React.FC = ({ + gallery, + linkType = "details", + className, +}) => { + const link = useMemo(() => { + switch (linkType) { + case "details": + return `/galleries/${gallery.id}`; + } + }, [gallery, linkType]); + + const title = galleryTitle(gallery); + + return ( + + {title} + + ); }; -interface IProps { - tag?: Partial; - tagType?: "performer" | "scene" | "gallery" | "image" | "details"; - performer?: Partial; - marker?: SceneMarkerFragment; - movie?: Partial; - scene?: Partial>; - gallery?: Partial; +interface ITagLinkProps { + tag: INamedObject; + linkType?: "scene" | "gallery" | "image" | "details" | "performer"; className?: string; hoverPlacement?: Placement; + showHierarchyIcon?: boolean; + hierarchyTooltipID?: string; } -export const TagLink: React.FC = (props: IProps) => { - let id: string = ""; - let link: string = "#"; - let title: string = ""; - if (props.tag) { - id = props.tag.id || ""; - switch (props.tagType) { +export const TagLink: React.FC = ({ + tag, + linkType = "scene", + className, + hoverPlacement, + showHierarchyIcon = false, + hierarchyTooltipID, +}) => { + const link = useMemo(() => { + switch (linkType) { case "scene": - case undefined: - link = NavUtils.makeTagScenesUrl(props.tag); - break; + return NavUtils.makeTagScenesUrl(tag); case "performer": - link = NavUtils.makeTagPerformersUrl(props.tag); - break; + return NavUtils.makeTagPerformersUrl(tag); case "gallery": - link = NavUtils.makeTagGalleriesUrl(props.tag); - break; + return NavUtils.makeTagGalleriesUrl(tag); case "image": - link = NavUtils.makeTagImagesUrl(props.tag); - break; + return NavUtils.makeTagImagesUrl(tag); case "details": - link = NavUtils.makeTagUrl(id); - break; + return NavUtils.makeTagUrl(tag.id ?? ""); } - title = props.tag.name || ""; - } else if (props.performer) { - link = NavUtils.makePerformerScenesUrl(props.performer); - title = props.performer.name || ""; - } else if (props.movie) { - link = NavUtils.makeMovieScenesUrl(props.movie); - title = props.movie.name || ""; - } else if (props.marker) { - link = NavUtils.makeSceneMarkerUrl(props.marker); - title = `${markerTitle(props.marker)} - ${TextUtils.secondsToTimestamp( - props.marker.seconds || 0 - )}`; - } else if (props.gallery) { - link = `/galleries/${props.gallery.id}`; - title = galleryTitle(props.gallery); - } else if (props.scene) { - link = `/scenes/${props.scene.id}`; - title = objectTitle(props.scene); - } + }, [tag, linkType]); + + const title = tag.name || ""; + + const tooltip = useMemo(() => { + if (!hierarchyTooltipID) { + return <>; + } + + return ( + + + + ); + }, [hierarchyTooltipID]); + return ( - - - {title} + + + + {title} + {showHierarchyIcon && ( + + + | + + + + )} + - + ); }; diff --git a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx index 7988a5b828e..327bb3ff507 100644 --- a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx @@ -7,7 +7,7 @@ import { FormattedMessage } from "react-intl"; import { sortPerformers } from "src/core/performers"; import { Icon } from "src/components/Shared/Icon"; import { OperationButton } from "src/components/Shared/OperationButton"; -import { TagLink } from "src/components/Shared/TagLink"; +import { PerformerLink, TagLink } from "src/components/Shared/TagLink"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { parsePath, prepareQueryString } from "src/components/Tagger/utils"; import { ScenePreview } from "src/components/Scenes/SceneCard"; @@ -54,7 +54,7 @@ const TaggerSceneDetails: React.FC = ({ scene }) => { src={performer.image_path ?? ""} /> - = ({ tag, fullWidth }) => { key={p.id} tag={p} hoverPlacement="bottom" - tagType="details" + linkType="details" + showHierarchyIcon={p.parent_count !== 0} + hierarchyTooltipID="tag_parent_tooltip" /> ))} @@ -40,7 +42,9 @@ export const TagDetailsPanel: React.FC = ({ tag, fullWidth }) => { key={c.id} tag={c} hoverPlacement="bottom" - tagType="details" + linkType="details" + showHierarchyIcon={c.child_count !== 0} + hierarchyTooltipID="tag_sub_tag_tooltip" /> ))} diff --git a/ui/v2.5/src/components/Tags/TagPopover.tsx b/ui/v2.5/src/components/Tags/TagPopover.tsx index 1edd2ea4f31..e85b64a5a98 100644 --- a/ui/v2.5/src/components/Tags/TagPopover.tsx +++ b/ui/v2.5/src/components/Tags/TagPopover.tsx @@ -8,13 +8,12 @@ import { ConfigurationContext } from "../../hooks/Config"; import { IUIConfig } from "src/core/config"; import { Placement } from "react-bootstrap/esm/Overlay"; -interface ITagPopoverProps { - id?: string; - placement?: Placement; +interface ITagPopoverCardProps { + id: string; } export const TagPopoverCard: React.FC = ({ id }) => { - const { data, loading, error } = useFindTag(id ?? ""); + const { data, loading, error } = useFindTag(id); if (loading) return ( @@ -35,8 +34,15 @@ export const TagPopoverCard: React.FC = ({ id }) => { ); }; +interface ITagPopoverProps { + id: string; + hide?: boolean; + placement?: Placement; +} + export const TagPopover: React.FC = ({ id, + hide, children, placement = "top", }) => { @@ -45,7 +51,7 @@ export const TagPopover: React.FC = ({ const showTagCardOnHover = (config?.ui as IUIConfig)?.showTagCardOnHover ?? true; - if (!id || !showTagCardOnHover) { + if (hide || !showTagCardOnHover) { return <>{children}; } @@ -60,7 +66,3 @@ export const TagPopover: React.FC = ({ ); }; - -interface ITagPopoverCardProps { - id?: string; -} diff --git a/ui/v2.5/src/components/Tags/styles.scss b/ui/v2.5/src/components/Tags/styles.scss index d5aeccc7c41..8b84555b2ae 100644 --- a/ui/v2.5/src/components/Tags/styles.scss +++ b/ui/v2.5/src/components/Tags/styles.scss @@ -72,3 +72,21 @@ padding: 0; } } + +.tag-item { + .icon-wrapper { + color: #202b33; + opacity: 0.5; + padding-left: 6px; + } +} + +.tag-item { + .tag-icon { + color: #202b33; + margin: 0; + opacity: 0.5; + padding-left: 3px; + transform: scale(0.7); + } +} diff --git a/ui/v2.5/src/core/files.ts b/ui/v2.5/src/core/files.ts index 1c2505840c3..52bac6ec036 100644 --- a/ui/v2.5/src/core/files.ts +++ b/ui/v2.5/src/core/files.ts @@ -1,16 +1,16 @@ import TextUtils from "src/utils/text"; import * as GQL from "src/core/generated-graphql"; -interface IFile { +export interface IFile { path: string; } interface IObjectWithFiles { - files: IFile[]; + files?: IFile[]; } -interface IObjectWithTitleFiles extends IObjectWithFiles { - title: GQL.Maybe; +export interface IObjectWithTitleFiles extends IObjectWithFiles { + title?: GQL.Maybe; } export function objectTitle(s: Partial) { diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index 8218253f706..9388061beb5 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -73,6 +73,9 @@ const CLASSNAME_NAVSELECTED = `${CLASSNAME_NAV}-selected`; const DEFAULT_SLIDESHOW_DELAY = 5000; const SECONDS_TO_MS = 1000; const MIN_VALID_INTERVAL_SECONDS = 1; +const MIN_ZOOM = 0.1; +const SCROLL_ZOOM_TIMEOUT = 250; +const ZOOM_NONE_EPSILON = 0.015; interface IProps { images: ILightboxImage[]; @@ -120,6 +123,18 @@ export const LightboxComponent: React.FC = ({ const oldImages = useRef([]); const [zoom, setZoom] = useState(1); + + function updateZoom(v: number) { + if (v < MIN_ZOOM) { + setZoom(MIN_ZOOM); + } else if (Math.abs(v - 1) < ZOOM_NONE_EPSILON) { + // "snap to 1" effect: if new zoom is close to 1, set to 1 + setZoom(1); + } else { + setZoom(v); + } + } + const [resetPosition, setResetPosition] = useState(false); const containerRef = useRef(null); @@ -373,6 +388,14 @@ export const LightboxComponent: React.FC = ({ ] ); + const firstScroll = useRef(null); + const inScrollGroup = useRef(false); + + const debouncedScrollReset = useDebounce(() => { + firstScroll.current = null; + inScrollGroup.current = false; + }, SCROLL_ZOOM_TIMEOUT); + const handleKey = useCallback( (e: KeyboardEvent) => { if (e.repeat && (e.key === "ArrowRight" || e.key === "ArrowLeft")) @@ -842,14 +865,17 @@ export const LightboxComponent: React.FC = ({ lightboxSettings?.scrollMode ?? GQL.ImageLightboxScrollMode.Zoom } - onLeft={handleLeft} - onRight={handleRight} - alignBottom={movingLeft} + resetPosition={resetPosition} zoom={i === currentIndex ? zoom : 1} - current={i === currentIndex} scrollAttemptsBeforeChange={scrollAttemptsBeforeChange} - setZoom={(v) => setZoom(v)} - resetPosition={resetPosition} + firstScroll={firstScroll} + inScrollGroup={inScrollGroup} + current={i === currentIndex} + alignBottom={movingLeft} + setZoom={updateZoom} + debouncedScrollReset={debouncedScrollReset} + onLeft={handleLeft} + onRight={handleRight} isVideo={isVideo(image.visual_files?.[0] ?? {})} /> ) : undefined} diff --git a/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx b/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx index 425a3aacdd4..a7695edd38b 100644 --- a/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx +++ b/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx @@ -2,7 +2,12 @@ import React, { useEffect, useRef, useState, useCallback } from "react"; import * as GQL from "src/core/generated-graphql"; const ZOOM_STEP = 1.1; +const ZOOM_FACTOR = 700; +const SCROLL_GROUP_THRESHOLD = 8; +const SCROLL_GROUP_EXIT_THRESHOLD = 4; +const SCROLL_INFINITE_THRESHOLD = 10; const SCROLL_PAN_STEP = 75; +const SCROLL_PAN_FACTOR = 2; const CLASSNAME = "Lightbox"; const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`; const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`; @@ -53,10 +58,15 @@ interface IProps { resetPosition?: boolean; zoom: number; scrollAttemptsBeforeChange: number; + // these refs must be outside of LightboxImage, + // since they need to be shared between all LightboxImages + firstScroll: React.MutableRefObject; + inScrollGroup: React.MutableRefObject; current: boolean; // set to true to align image with bottom instead of top alignBottom?: boolean; setZoom: (v: number) => void; + debouncedScrollReset: () => void; onLeft: () => void; onRight: () => void; isVideo: boolean; @@ -64,17 +74,20 @@ interface IProps { export const LightboxImage: React.FC = ({ src, - onLeft, - onRight, displayMode, scaleUp, scrollMode, - alignBottom, + resetPosition, zoom, scrollAttemptsBeforeChange, + firstScroll, + inScrollGroup, current, + alignBottom, setZoom, - resetPosition, + debouncedScrollReset, + onLeft, + onRight, isVideo, }) => { const [defaultZoom, setDefaultZoom] = useState(1); @@ -253,12 +266,7 @@ export const LightboxImage: React.FC = ({ calculateInitialPosition, ]); - function getScrollMode( - ev: - | React.WheelEvent - | React.WheelEvent - | React.WheelEvent - ) { + function getScrollMode(ev: React.WheelEvent) { if (ev.shiftKey) { switch (scrollMode) { case GQL.ImageLightboxScrollMode.Zoom: @@ -271,91 +279,134 @@ export const LightboxImage: React.FC = ({ return scrollMode; } - function onContainerScroll( - ev: - | React.WheelEvent - | React.WheelEvent - | React.WheelEvent - ) { + function onContainerScroll(ev: React.WheelEvent) { // don't zoom if mouse isn't over image if (getScrollMode(ev) === GQL.ImageLightboxScrollMode.PanY) { onImageScroll(ev); } } - function onImageScrollPanY( - ev: - | React.WheelEvent - | React.WheelEvent - | React.WheelEvent + function onLeftScroll( + ev: React.WheelEvent, + scrollable: boolean, + infinite: boolean ) { - if (current) { - const [minY, maxY] = minMaxY(zoom * defaultZoom); - - const scrollable = positionY !== maxY || positionY !== minY; - - let newPositionY = - positionY + (ev.deltaY < 0 ? SCROLL_PAN_STEP : -SCROLL_PAN_STEP); - - // #2389 - if scroll up and at top, then go to previous image - // if scroll down and at bottom, then go to next image - if (newPositionY > maxY && positionY === maxY) { - // #2535 - require additional scrolls before changing page - if ( - !scrollable || - scrollAttempts.current <= -scrollAttemptsBeforeChange - ) { + if (infinite) { + // for infinite scrolls, only change once per scroll "group" + if (ev.deltaY <= -SCROLL_GROUP_THRESHOLD) { + if (!inScrollGroup.current) { onLeft(); - } else { - scrollAttempts.current--; } - } else if (newPositionY < minY && positionY === minY) { - // #2535 - require additional scrolls before changing page - if ( - !scrollable || - scrollAttempts.current >= scrollAttemptsBeforeChange - ) { + } + } else { + // #2535 - require additional scrolls before changing page + if ( + !scrollable || + scrollAttempts.current <= -scrollAttemptsBeforeChange + ) { + scrollAttempts.current = 0; + onLeft(); + } else { + scrollAttempts.current--; + } + } + } + + function onRightScroll( + ev: React.WheelEvent, + scrollable: boolean, + infinite: boolean + ) { + if (infinite) { + // for infinite scrolls, only change once per scroll "group" + if (ev.deltaY >= SCROLL_GROUP_THRESHOLD) { + if (!inScrollGroup.current) { onRight(); - } else { - scrollAttempts.current++; } - } else { + } + } else { + // #2535 - require additional scrolls before changing page + if (!scrollable || scrollAttempts.current >= scrollAttemptsBeforeChange) { scrollAttempts.current = 0; + onRight(); + } else { + scrollAttempts.current++; + } + } + } - // ensure image doesn't go offscreen - newPositionY = Math.max(newPositionY, minY); - newPositionY = Math.min(newPositionY, maxY); + function onImageScrollPanY(ev: React.WheelEvent, infinite: boolean) { + if (!current) return; - setPositionY(newPositionY); - } + const [minY, maxY] = minMaxY(zoom * defaultZoom); + + const scrollable = positionY !== maxY || positionY !== minY; + + let newPositionY: number; + if (infinite) { + newPositionY = positionY - ev.deltaY / SCROLL_PAN_FACTOR; + } else { + newPositionY = + positionY + (ev.deltaY < 0 ? SCROLL_PAN_STEP : -SCROLL_PAN_STEP); + } - ev.stopPropagation(); + // #2389 - if scroll up and at top, then go to previous image + // if scroll down and at bottom, then go to next image + if (newPositionY > maxY && positionY === maxY) { + onLeftScroll(ev, scrollable, infinite); + } else if (newPositionY < minY && positionY === minY) { + onRightScroll(ev, scrollable, infinite); + } else { + scrollAttempts.current = 0; + + // ensure image doesn't go offscreen + newPositionY = Math.max(newPositionY, minY); + newPositionY = Math.min(newPositionY, maxY); + + setPositionY(newPositionY); } + + ev.stopPropagation(); } - function onImageScroll( - ev: - | React.WheelEvent - | React.WheelEvent - | React.WheelEvent - ) { - const percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; + function onImageScroll(ev: React.WheelEvent) { + const absDeltaY = Math.abs(ev.deltaY); + const firstDeltaY = firstScroll.current; + // detect infinite scrolling (mousepad, mouse with infinite scrollwheel) + const infinite = + // scrolling is infinite if deltaY is small + absDeltaY < SCROLL_INFINITE_THRESHOLD || + // or if scroll events come quickly and the first one was small + (firstDeltaY !== null && + Math.abs(firstDeltaY) < SCROLL_INFINITE_THRESHOLD); switch (getScrollMode(ev)) { case GQL.ImageLightboxScrollMode.Zoom: + let percent: number; + if (infinite) { + percent = 1 - ev.deltaY / ZOOM_FACTOR; + } else { + percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; + } setZoom(zoom * percent); break; case GQL.ImageLightboxScrollMode.PanY: - onImageScrollPanY(ev); + onImageScrollPanY(ev, infinite); break; } + if (firstDeltaY === null) { + firstScroll.current = ev.deltaY; + } + if (absDeltaY >= SCROLL_GROUP_THRESHOLD) { + inScrollGroup.current = true; + } else if (absDeltaY <= SCROLL_GROUP_EXIT_THRESHOLD) { + // only "exit" the scroll group if speed has slowed considerably + inScrollGroup.current = false; + } + debouncedScrollReset(); } - function onImageMouseOver( - ev: - | React.MouseEvent - | React.MouseEvent - ) { + function onImageMouseOver(ev: React.MouseEvent) { if (!moving) return; if (!ev.buttons) { @@ -371,22 +422,14 @@ export const LightboxImage: React.FC = ({ setPositionY(positionY + posY); } - function onImageMouseDown( - ev: - | React.MouseEvent - | React.MouseEvent - ) { + function onImageMouseDown(ev: React.MouseEvent) { startPoints.current = [ev.pageX, ev.pageY]; setMoving(true); mouseDownEvent.current = ev.nativeEvent; } - function onImageMouseUp( - ev: - | React.MouseEvent - | React.MouseEvent - ) { + function onImageMouseUp(ev: React.MouseEvent) { if (ev.button !== 0) return; if ( @@ -412,12 +455,7 @@ export const LightboxImage: React.FC = ({ } } - function onTouchStart( - ev: - | React.TouchEvent - | React.TouchEvent - | React.TouchEvent - ) { + function onTouchStart(ev: React.TouchEvent) { ev.preventDefault(); if (ev.touches.length === 1) { startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY]; @@ -425,12 +463,7 @@ export const LightboxImage: React.FC = ({ } } - function onTouchMove( - ev: - | React.TouchEvent - | React.TouchEvent - | React.TouchEvent - ) { + function onTouchMove(ev: React.TouchEvent) { if (!moving) return; if (ev.touches.length === 1) { @@ -443,12 +476,7 @@ export const LightboxImage: React.FC = ({ } } - function onPointerDown( - ev: - | React.PointerEvent - | React.PointerEvent - | React.PointerEvent - ) { + function onPointerDown(ev: React.PointerEvent) { // replace pointer event with the same id, if applicable pointerCache.current = pointerCache.current.filter( (e) => e.pointerId !== ev.pointerId @@ -458,12 +486,7 @@ export const LightboxImage: React.FC = ({ prevDiff.current = undefined; } - function onPointerUp( - ev: - | React.PointerEvent - | React.PointerEvent - | React.PointerEvent - ) { + function onPointerUp(ev: React.PointerEvent) { for (let i = 0; i < pointerCache.current.length; i++) { if (pointerCache.current[i].pointerId === ev.pointerId) { pointerCache.current.splice(i, 1); @@ -472,12 +495,7 @@ export const LightboxImage: React.FC = ({ } } - function onPointerMove( - ev: - | React.PointerEvent - | React.PointerEvent - | React.PointerEvent - ) { + function onPointerMove(ev: React.PointerEvent) { // find the event in the cache const cachedIndex = pointerCache.current.findIndex( (c) => c.pointerId === ev.pointerId @@ -543,14 +561,14 @@ export const LightboxImage: React.FC = ({ draggable={false} style={customStyle} onWheel={current ? (e) => onImageScroll(e) : undefined} - onMouseDown={(e) => onImageMouseDown(e)} - onMouseUp={(e) => onImageMouseUp(e)} - onMouseMove={(e) => onImageMouseOver(e)} - onTouchStart={(e) => onTouchStart(e)} - onTouchMove={(e) => onTouchMove(e)} - onPointerDown={(e) => onPointerDown(e)} - onPointerUp={(e) => onPointerUp(e)} - onPointerMove={(e) => onPointerMove(e)} + onMouseDown={onImageMouseDown} + onMouseUp={onImageMouseUp} + onMouseMove={onImageMouseOver} + onTouchStart={onTouchStart} + onTouchMove={onTouchMove} + onPointerDown={onPointerDown} + onPointerUp={onPointerUp} + onPointerMove={onPointerMove} /> ) : undefined} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 3747b969d92..a911ad14e8c 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -918,6 +918,7 @@ "equal": "Equal" }, "found_sets": "{setCount, plural, one{# set of duplicates found.} other {# sets of duplicates found.}}", + "only_select_matching_codecs": "Only select if all codecs match in the duplicate group", "options": { "exact": "Exact", "high": "High", @@ -925,6 +926,11 @@ "medium": "Medium" }, "search_accuracy_label": "Search Accuracy", + "select_options" : "Select Options…", + "select_all_but_largest_file": "Select every file in each duplicated group, except the largest file", + "select_none": "Select None", + "select_oldest": "Select the oldest file in the duplicate group", + "select_youngest": "Select the youngest file in the duplicate group", "title": "Duplicate Scenes" }, "duplicated_phash": "Duplicated (phash)", @@ -1313,6 +1319,8 @@ "synopsis": "Synopsis", "tag": "Tag", "tag_count": "Tag Count", + "tag_parent_tooltip": "Has parent tags", + "tag_sub_tag_tooltip": "Has sub-tags", "tags": "Tags", "tattoos": "Tattoos", "title": "Title", diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index ddefaeec75f..df7b5b5fc53 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -73,8 +73,13 @@ const makePerformerImagesUrl = ( return `/images?${filter.makeQueryParameters()}`; }; +export interface INamedObject { + id?: string; + name?: string; +} + const makePerformerGalleriesUrl = ( - performer: Partial, + performer: INamedObject, extraPerformer?: ILabeledId, extraCriteria?: Criterion[] ) => {