Add scene/image/gallery popover count buttons for performer/studio/tag cards (#1293)

* Add counts to graphql schema
* Add count resolvers and query refactor
* Add count popover buttons
This commit is contained in:
WithoutPants
2021-04-15 10:46:31 +10:00
committed by GitHub
parent e6aaa196f3
commit ea54a67798
27 changed files with 536 additions and 73 deletions

View File

@@ -20,6 +20,8 @@ fragment PerformerData on Performer {
favorite favorite
image_path image_path
scene_count scene_count
image_count
gallery_count
tags { tags {
...TagData ...TagData

View File

@@ -10,6 +10,8 @@ fragment StudioData on Studio {
url url
image_path image_path
scene_count scene_count
image_count
gallery_count
} }
child_studios { child_studios {
id id
@@ -18,9 +20,13 @@ fragment StudioData on Studio {
url url
image_path image_path
scene_count scene_count
image_count
gallery_count
} }
image_path image_path
scene_count scene_count
image_count
gallery_count
stash_ids { stash_ids {
stash_id stash_id
endpoint endpoint

View File

@@ -4,5 +4,7 @@ fragment TagData on Tag {
image_path image_path
scene_count scene_count
scene_marker_count scene_marker_count
image_count
gallery_count
performer_count performer_count
} }

View File

@@ -31,6 +31,8 @@ type Performer {
image_path: String # Resolver image_path: String # Resolver
scene_count: Int # Resolver scene_count: Int # Resolver
image_count: Int # Resolver
gallery_count: Int # Resolver
scenes: [Scene!]! scenes: [Scene!]!
stash_ids: [StashID!]! stash_ids: [StashID!]!
} }

View File

@@ -8,6 +8,8 @@ type Studio {
image_path: String # Resolver image_path: String # Resolver
scene_count: Int # Resolver scene_count: Int # Resolver
image_count: Int # Resolver
gallery_count: Int # Resolver
stash_ids: [StashID!]! stash_ids: [StashID!]!
} }

View File

@@ -5,6 +5,8 @@ type Tag {
image_path: String # Resolver image_path: String # Resolver
scene_count: Int # Resolver scene_count: Int # Resolver
scene_marker_count: Int # Resolver scene_marker_count: Int # Resolver
image_count: Int # Resolver
gallery_count: Int # Resolver
performer_count: Int performer_count: Int
} }

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"github.com/stashapp/stash/pkg/api/urlbuilders" "github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
@@ -161,6 +163,30 @@ func (r *performerResolver) SceneCount(ctx context.Context, obj *models.Performe
return &res, nil return &res, nil
} }
func (r *performerResolver) ImageCount(ctx context.Context, obj *models.Performer) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
res, err = image.CountByPerformerID(repo.Image(), obj.ID)
return err
}); err != nil {
return nil, err
}
return &res, nil
}
func (r *performerResolver) GalleryCount(ctx context.Context, obj *models.Performer) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
res, err = gallery.CountByPerformerID(repo.Gallery(), obj.ID)
return err
}); err != nil {
return nil, err
}
return &res, nil
}
func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) (ret []*models.Scene, err error) { func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) (ret []*models.Scene, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.Scene().FindByPerformerID(obj.ID) ret, err = repo.Scene().FindByPerformerID(obj.ID)

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"github.com/stashapp/stash/pkg/api/urlbuilders" "github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
@@ -54,6 +56,30 @@ func (r *studioResolver) SceneCount(ctx context.Context, obj *models.Studio) (re
return &res, err return &res, err
} }
func (r *studioResolver) ImageCount(ctx context.Context, obj *models.Studio) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
res, err = image.CountByStudioID(repo.Image(), obj.ID)
return err
}); err != nil {
return nil, err
}
return &res, nil
}
func (r *studioResolver) GalleryCount(ctx context.Context, obj *models.Studio) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
res, err = gallery.CountByStudioID(repo.Gallery(), obj.ID)
return err
}); err != nil {
return nil, err
}
return &res, nil
}
func (r *studioResolver) ParentStudio(ctx context.Context, obj *models.Studio) (ret *models.Studio, err error) { func (r *studioResolver) ParentStudio(ctx context.Context, obj *models.Studio) (ret *models.Studio, err error) {
if !obj.ParentID.Valid { if !obj.ParentID.Valid {
return nil, nil return nil, nil

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"github.com/stashapp/stash/pkg/api/urlbuilders" "github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
@@ -31,6 +33,30 @@ func (r *tagResolver) SceneMarkerCount(ctx context.Context, obj *models.Tag) (re
return &count, err return &count, err
} }
func (r *tagResolver) ImageCount(ctx context.Context, obj *models.Tag) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
res, err = image.CountByTagID(repo.Image(), obj.ID)
return err
}); err != nil {
return nil, err
}
return &res, nil
}
func (r *tagResolver) GalleryCount(ctx context.Context, obj *models.Tag) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
res, err = gallery.CountByTagID(repo.Gallery(), obj.ID)
return err
}); err != nil {
return nil, err
}
return &res, nil
}
func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag) (ret *int, err error) { func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag) (ret *int, err error) {
var count int var count int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {

40
pkg/gallery/query.go Normal file
View File

@@ -0,0 +1,40 @@
package gallery
import (
"strconv"
"github.com/stashapp/stash/pkg/models"
)
func CountByPerformerID(r models.GalleryReader, id int) (int, error) {
filter := &models.GalleryFilterType{
Performers: &models.MultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
},
}
return r.QueryCount(filter, nil)
}
func CountByStudioID(r models.GalleryReader, id int) (int, error) {
filter := &models.GalleryFilterType{
Studios: &models.MultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
},
}
return r.QueryCount(filter, nil)
}
func CountByTagID(r models.GalleryReader, id int) (int, error) {
filter := &models.GalleryFilterType{
Tags: &models.MultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
},
}
return r.QueryCount(filter, nil)
}

40
pkg/image/query.go Normal file
View File

@@ -0,0 +1,40 @@
package image
import (
"strconv"
"github.com/stashapp/stash/pkg/models"
)
func CountByPerformerID(r models.ImageReader, id int) (int, error) {
filter := &models.ImageFilterType{
Performers: &models.MultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
},
}
return r.QueryCount(filter, nil)
}
func CountByStudioID(r models.ImageReader, id int) (int, error) {
filter := &models.ImageFilterType{
Studios: &models.MultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
},
}
return r.QueryCount(filter, nil)
}
func CountByTagID(r models.ImageReader, id int) (int, error) {
filter := &models.ImageFilterType{
Tags: &models.MultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
},
}
return r.QueryCount(filter, nil)
}

View File

@@ -11,6 +11,7 @@ type GalleryReader interface {
Count() (int, error) Count() (int, error)
All() ([]*Gallery, error) All() ([]*Gallery, error)
Query(galleryFilter *GalleryFilterType, findFilter *FindFilterType) ([]*Gallery, int, error) Query(galleryFilter *GalleryFilterType, findFilter *FindFilterType) ([]*Gallery, int, error)
QueryCount(galleryFilter *GalleryFilterType, findFilter *FindFilterType) (int, error)
GetPerformerIDs(galleryID int) ([]int, error) GetPerformerIDs(galleryID int) ([]int, error)
GetTagIDs(galleryID int) ([]int, error) GetTagIDs(galleryID int) ([]int, error)
GetSceneIDs(galleryID int) ([]int, error) GetSceneIDs(galleryID int) ([]int, error)

View File

@@ -17,6 +17,7 @@ type ImageReader interface {
// CountByTagID(tagID int) (int, error) // CountByTagID(tagID int) (int, error)
All() ([]*Image, error) All() ([]*Image, error)
Query(imageFilter *ImageFilterType, findFilter *FindFilterType) ([]*Image, int, error) Query(imageFilter *ImageFilterType, findFilter *FindFilterType) ([]*Image, int, error)
QueryCount(imageFilter *ImageFilterType, findFilter *FindFilterType) (int, error)
GetGalleryIDs(imageID int) ([]int, error) GetGalleryIDs(imageID int) ([]int, error)
GetTagIDs(imageID int) ([]int, error) GetTagIDs(imageID int) ([]int, error)
GetPerformerIDs(imageID int) ([]int, error) GetPerformerIDs(imageID int) ([]int, error)

View File

@@ -376,6 +376,27 @@ func (_m *GalleryReaderWriter) Query(galleryFilter *models.GalleryFilterType, fi
return r0, r1, r2 return r0, r1, r2
} }
// QueryCount provides a mock function with given fields: galleryFilter, findFilter
func (_m *GalleryReaderWriter) QueryCount(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (int, error) {
ret := _m.Called(galleryFilter, findFilter)
var r0 int
if rf, ok := ret.Get(0).(func(*models.GalleryFilterType, *models.FindFilterType) int); ok {
r0 = rf(galleryFilter, findFilter)
} else {
r0 = ret.Get(0).(int)
}
var r1 error
if rf, ok := ret.Get(1).(func(*models.GalleryFilterType, *models.FindFilterType) error); ok {
r1 = rf(galleryFilter, findFilter)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Update provides a mock function with given fields: updatedGallery // Update provides a mock function with given fields: updatedGallery
func (_m *GalleryReaderWriter) Update(updatedGallery models.Gallery) (*models.Gallery, error) { func (_m *GalleryReaderWriter) Update(updatedGallery models.Gallery) (*models.Gallery, error) {
ret := _m.Called(updatedGallery) ret := _m.Called(updatedGallery)

View File

@@ -370,6 +370,27 @@ func (_m *ImageReaderWriter) Query(imageFilter *models.ImageFilterType, findFilt
return r0, r1, r2 return r0, r1, r2
} }
// QueryCount provides a mock function with given fields: imageFilter, findFilter
func (_m *ImageReaderWriter) QueryCount(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error) {
ret := _m.Called(imageFilter, findFilter)
var r0 int
if rf, ok := ret.Get(0).(func(*models.ImageFilterType, *models.FindFilterType) int); ok {
r0 = rf(imageFilter, findFilter)
} else {
r0 = ret.Get(0).(int)
}
var r1 error
if rf, ok := ret.Get(1).(func(*models.ImageFilterType, *models.FindFilterType) error); ok {
r1 = rf(imageFilter, findFilter)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ResetOCounter provides a mock function with given fields: id // ResetOCounter provides a mock function with given fields: id
func (_m *ImageReaderWriter) ResetOCounter(id int) (int, error) { func (_m *ImageReaderWriter) ResetOCounter(id int) (int, error) {
ret := _m.Called(id) ret := _m.Called(id)

View File

@@ -415,6 +415,29 @@ func (_m *SceneReaderWriter) FindByPerformerID(performerID int) ([]*models.Scene
return r0, r1 return r0, r1
} }
// FindDuplicates provides a mock function with given fields: distance
func (_m *SceneReaderWriter) FindDuplicates(distance int) ([][]*models.Scene, error) {
ret := _m.Called(distance)
var r0 [][]*models.Scene
if rf, ok := ret.Get(0).(func(int) [][]*models.Scene); ok {
r0 = rf(distance)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([][]*models.Scene)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(distance)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindMany provides a mock function with given fields: ids // FindMany provides a mock function with given fields: ids
func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) { func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) {
ret := _m.Called(ids) ret := _m.Called(ids)
@@ -438,30 +461,6 @@ func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) {
return r0, r1 return r0, r1
} }
// FindDuplicates provides a mock function with given fields: distance
func (_m *SceneReaderWriter) FindDuplicates(distance int) ([][]*models.Scene, error) {
ret := _m.Called(distance)
var r0 [][]*models.Scene
if rf, ok := ret.Get(0).(func(int) [][]*models.Scene); ok {
r0 = rf(distance)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([][]*models.Scene)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(distance)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetCover provides a mock function with given fields: sceneID // GetCover provides a mock function with given fields: sceneID
func (_m *SceneReaderWriter) GetCover(sceneID int) ([]byte, error) { func (_m *SceneReaderWriter) GetCover(sceneID int) ([]byte, error) {
ret := _m.Called(sceneID) ret := _m.Called(sceneID)
@@ -651,29 +650,6 @@ func (_m *SceneReaderWriter) Query(sceneFilter *models.SceneFilterType, findFilt
return r0, r1, r2 return r0, r1, r2
} }
// QueryForAutoTag provides a mock function with given fields: regex, pathPrefixes
func (_m *SceneReaderWriter) QueryForAutoTag(regex string, pathPrefixes []string) ([]*models.Scene, error) {
ret := _m.Called(regex, pathPrefixes)
var r0 []*models.Scene
if rf, ok := ret.Get(0).(func(string, []string) []*models.Scene); ok {
r0 = rf(regex, pathPrefixes)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Scene)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, []string) error); ok {
r1 = rf(regex, pathPrefixes)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ResetOCounter provides a mock function with given fields: id // ResetOCounter provides a mock function with given fields: id
func (_m *SceneReaderWriter) ResetOCounter(id int) (int, error) { func (_m *SceneReaderWriter) ResetOCounter(id int) (int, error) {
ret := _m.Called(id) ret := _m.Called(id)

View File

@@ -159,7 +159,7 @@ func (qb *galleryQueryBuilder) All() ([]*models.Gallery, error) {
return qb.queryGalleries(selectAll("galleries")+qb.getGallerySort(nil), nil) return qb.queryGalleries(selectAll("galleries")+qb.getGallerySort(nil), nil)
} }
func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) ([]*models.Gallery, int, error) { func (qb *galleryQueryBuilder) makeQuery(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) queryBuilder {
if galleryFilter == nil { if galleryFilter == nil {
galleryFilter = &models.GalleryFilterType{} galleryFilter = &models.GalleryFilterType{}
} }
@@ -283,6 +283,13 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi
handleGalleryPerformerTagsCriterion(&query, galleryFilter.PerformerTags) handleGalleryPerformerTagsCriterion(&query, galleryFilter.PerformerTags)
query.sortAndPagination = qb.getGallerySort(findFilter) + getPagination(findFilter) query.sortAndPagination = qb.getGallerySort(findFilter) + getPagination(findFilter)
return query
}
func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) ([]*models.Gallery, int, error) {
query := qb.makeQuery(galleryFilter, findFilter)
idsResult, countResult, err := query.executeFind() idsResult, countResult, err := query.executeFind()
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -301,6 +308,12 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi
return galleries, countResult, nil return galleries, countResult, nil
} }
func (qb *galleryQueryBuilder) QueryCount(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (int, error) {
query := qb.makeQuery(galleryFilter, findFilter)
return query.executeCount()
}
func (qb *galleryQueryBuilder) handleAverageResolutionFilter(query *queryBuilder, resolutionFilter *models.ResolutionEnum) { func (qb *galleryQueryBuilder) handleAverageResolutionFilter(query *queryBuilder, resolutionFilter *models.ResolutionEnum) {
if resolutionFilter == nil { if resolutionFilter == nil {
return return

View File

@@ -216,7 +216,7 @@ func (qb *imageQueryBuilder) All() ([]*models.Image, error) {
return qb.queryImages(selectAll(imageTable)+qb.getImageSort(nil), nil) return qb.queryImages(selectAll(imageTable)+qb.getImageSort(nil), nil)
} }
func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, int, error) { func (qb *imageQueryBuilder) makeQuery(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) queryBuilder {
if imageFilter == nil { if imageFilter == nil {
imageFilter = &models.ImageFilterType{} imageFilter = &models.ImageFilterType{}
} }
@@ -383,6 +383,13 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt
handleImagePerformerTagsCriterion(&query, imageFilter.PerformerTags) handleImagePerformerTagsCriterion(&query, imageFilter.PerformerTags)
query.sortAndPagination = qb.getImageSort(findFilter) + getPagination(findFilter) query.sortAndPagination = qb.getImageSort(findFilter) + getPagination(findFilter)
return query
}
func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, int, error) {
query := qb.makeQuery(imageFilter, findFilter)
idsResult, countResult, err := query.executeFind() idsResult, countResult, err := query.executeFind()
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -401,6 +408,12 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt
return images, countResult, nil return images, countResult, nil
} }
func (qb *imageQueryBuilder) QueryCount(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error) {
query := qb.makeQuery(imageFilter, findFilter)
return query.executeCount()
}
func handleImagePerformerTagsCriterion(query *queryBuilder, performerTagsFilter *models.MultiCriterionInput) { func handleImagePerformerTagsCriterion(query *queryBuilder, performerTagsFilter *models.MultiCriterionInput) {
if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 {
for _, tagID := range performerTagsFilter.Value { for _, tagID := range performerTagsFilter.Value {

View File

@@ -95,6 +95,12 @@ func imageQueryQ(t *testing.T, sqb models.ImageReader, q string, expectedImageId
image := images[0] image := images[0]
assert.Equal(t, imageIDs[expectedImageIdx], image.ID) assert.Equal(t, imageIDs[expectedImageIdx], image.ID)
count, err := sqb.QueryCount(nil, &filter)
if err != nil {
t.Errorf("Error querying image: %s", err.Error())
}
assert.Equal(t, len(images), count)
// no Q should return all results // no Q should return all results
filter.Q = nil filter.Q = nil
images, _, err = sqb.Query(nil, &filter) images, _, err = sqb.Query(nil, &filter)

View File

@@ -33,6 +33,19 @@ func (qb queryBuilder) executeFind() ([]int, int, error) {
return qb.repository.executeFindQuery(body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses) return qb.repository.executeFindQuery(body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses)
} }
func (qb queryBuilder) executeCount() (int, error) {
if qb.err != nil {
return 0, qb.err
}
body := qb.body
body += qb.joins.toSQL()
body = qb.repository.buildQueryBody(body, qb.whereClauses, qb.havingClauses)
countQuery := qb.repository.buildCountQuery(body)
return qb.repository.runCountQuery(countQuery, qb.args)
}
func (qb *queryBuilder) addWhere(clauses ...string) { func (qb *queryBuilder) addWhere(clauses ...string) {
for _, clause := range clauses { for _, clause := range clauses {
if len(clause) > 0 { if len(clause) > 0 {

View File

@@ -234,7 +234,7 @@ func (r *repository) querySimple(query string, args []interface{}, out interface
return nil return nil
} }
func (r *repository) executeFindQuery(body string, args []interface{}, sortAndPagination string, whereClauses []string, havingClauses []string) ([]int, int, error) { func (r *repository) buildQueryBody(body string, whereClauses []string, havingClauses []string) string {
if len(whereClauses) > 0 { if len(whereClauses) > 0 {
body = body + " WHERE " + strings.Join(whereClauses, " AND ") // TODO handle AND or OR body = body + " WHERE " + strings.Join(whereClauses, " AND ") // TODO handle AND or OR
} }
@@ -243,6 +243,12 @@ func (r *repository) executeFindQuery(body string, args []interface{}, sortAndPa
body = body + " HAVING " + strings.Join(havingClauses, " AND ") // TODO handle AND or OR body = body + " HAVING " + strings.Join(havingClauses, " AND ") // TODO handle AND or OR
} }
return body
}
func (r *repository) executeFindQuery(body string, args []interface{}, sortAndPagination string, whereClauses []string, havingClauses []string) ([]int, int, error) {
body = r.buildQueryBody(body, whereClauses, havingClauses)
countQuery := r.buildCountQuery(body) countQuery := r.buildCountQuery(body)
idsQuery := body + sortAndPagination idsQuery := body + sortAndPagination

View File

@@ -4,6 +4,7 @@
* Added scene queue. * Added scene queue.
### 🎨 Improvements ### 🎨 Improvements
* Add popover buttons for scenes/images/galleries on performer/studio/tag cards.
* Add slideshow to image wall view. * Add slideshow to image wall view.
* Support API key via URL query parameter, and added API key to stream link in Scene File Info. * Support API key via URL query parameter, and added API key to stream link in Scene File Info.
* Revamped setup wizard and migration UI. * Revamped setup wizard and migration UI.

View File

@@ -12,6 +12,7 @@ import {
TruncatedText, TruncatedText,
} from "src/components/Shared"; } from "src/components/Shared";
import { Button, ButtonGroup } from "react-bootstrap"; import { Button, ButtonGroup } from "react-bootstrap";
import { PopoverCountButton } from "../Shared/PopoverCountButton";
interface IPerformerCardProps { interface IPerformerCardProps {
performer: GQL.PerformerDataFragment; performer: GQL.PerformerDataFragment;
@@ -46,12 +47,35 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
if (!performer.scene_count) return; if (!performer.scene_count) return;
return ( return (
<Link to={NavUtils.makePerformerScenesUrl(performer)}> <PopoverCountButton
<Button className="minimal"> type="scene"
<Icon icon="play-circle" /> count={performer.scene_count}
<span>{performer.scene_count}</span> url={NavUtils.makePerformerScenesUrl(performer)}
</Button> />
</Link> );
}
function maybeRenderImagesPopoverButton() {
if (!performer.image_count) return;
return (
<PopoverCountButton
type="image"
count={performer.image_count}
url={NavUtils.makePerformerImagesUrl(performer)}
/>
);
}
function maybeRenderGalleriesPopoverButton() {
if (!performer.gallery_count) return;
return (
<PopoverCountButton
type="gallery"
count={performer.gallery_count}
url={NavUtils.makePerformerGalleriesUrl(performer)}
/>
); );
} }
@@ -73,12 +97,19 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
} }
function maybeRenderPopoverButtonGroup() { function maybeRenderPopoverButtonGroup() {
if (performer.scene_count || performer.tags.length > 0) { if (
performer.scene_count ||
performer.image_count ||
performer.gallery_count ||
performer.tags.length > 0
) {
return ( return (
<> <>
<hr /> <hr />
<ButtonGroup className="card-popovers"> <ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()} {maybeRenderScenesPopoverButton()}
{maybeRenderImagesPopoverButton()}
{maybeRenderGalleriesPopoverButton()}
{maybeRenderTagPopoverButton()} {maybeRenderTagPopoverButton()}
</ButtonGroup> </ButtonGroup>
</> </>

View File

@@ -0,0 +1,64 @@
import React from "react";
import { Button } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Link } from "react-router-dom";
import Icon from "./Icon";
type PopoverLinkType = "scene" | "image" | "gallery";
interface IProps {
url: string;
type: PopoverLinkType;
count: number;
}
export const PopoverCountButton: React.FC<IProps> = ({ url, type, count }) => {
const intl = useIntl();
function getIcon() {
switch (type) {
case "scene":
return "play-circle";
case "image":
return "image";
case "gallery":
return "images";
}
}
function getPluralOptions() {
switch (type) {
case "scene":
return {
one: "scene",
other: "scenes",
};
case "image":
return {
one: "image",
other: "images",
};
case "gallery":
return {
one: "gallery",
other: "galleries",
};
}
}
function getTitle() {
const pluralCategory = intl.formatPlural(count);
const options = getPluralOptions();
const plural = options[pluralCategory as "one"] || options.other;
return `${count} ${plural}`;
}
return (
<Link to={url} title={getTitle()}>
<Button className="minimal">
<Icon icon={getIcon()} />
<span>{count}</span>
</Button>
</Link>
);
};

View File

@@ -1,9 +1,10 @@
import React from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { FormattedPlural } from "react-intl";
import { NavUtils } from "src/utils"; import { NavUtils } from "src/utils";
import { BasicCard, TruncatedText } from "src/components/Shared"; import { BasicCard, TruncatedText } from "src/components/Shared";
import { ButtonGroup } from "react-bootstrap";
import { PopoverCountButton } from "../Shared/PopoverCountButton";
interface IProps { interface IProps {
studio: GQL.StudioDataFragment; studio: GQL.StudioDataFragment;
@@ -51,6 +52,57 @@ export const StudioCard: React.FC<IProps> = ({
selected, selected,
onSelectedChanged, onSelectedChanged,
}) => { }) => {
function maybeRenderScenesPopoverButton() {
if (!studio.scene_count) return;
return (
<PopoverCountButton
type="scene"
count={studio.scene_count}
url={NavUtils.makeStudioScenesUrl(studio)}
/>
);
}
function maybeRenderImagesPopoverButton() {
if (!studio.image_count) return;
return (
<PopoverCountButton
type="image"
count={studio.image_count}
url={NavUtils.makeStudioImagesUrl(studio)}
/>
);
}
function maybeRenderGalleriesPopoverButton() {
if (!studio.gallery_count) return;
return (
<PopoverCountButton
type="gallery"
count={studio.gallery_count}
url={NavUtils.makeStudioGalleriesUrl(studio)}
/>
);
}
function maybeRenderPopoverButtonGroup() {
if (studio.scene_count || studio.image_count || studio.gallery_count) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()}
{maybeRenderImagesPopoverButton()}
{maybeRenderGalleriesPopoverButton()}
</ButtonGroup>
</>
);
}
}
return ( return (
<BasicCard <BasicCard
className="studio-card" className="studio-card"
@@ -68,17 +120,9 @@ export const StudioCard: React.FC<IProps> = ({
<h5> <h5>
<TruncatedText text={studio.name} /> <TruncatedText text={studio.name} />
</h5> </h5>
<span>
{studio.scene_count}&nbsp;
<FormattedPlural
value={studio.scene_count ?? 0}
one="scene"
other="scenes"
/>
.
</span>
{maybeRenderParent(studio, hideParent)} {maybeRenderParent(studio, hideParent)}
{maybeRenderChildren(studio)} {maybeRenderChildren(studio)}
{maybeRenderPopoverButtonGroup()}
</> </>
} }
selected={selected} selected={selected}

View File

@@ -5,6 +5,7 @@ import * as GQL from "src/core/generated-graphql";
import { NavUtils } from "src/utils"; import { NavUtils } from "src/utils";
import { Icon, TruncatedText } from "../Shared"; import { Icon, TruncatedText } from "../Shared";
import { BasicCard } from "../Shared/BasicCard"; import { BasicCard } from "../Shared/BasicCard";
import { PopoverCountButton } from "../Shared/PopoverCountButton";
interface IProps { interface IProps {
tag: GQL.TagDataFragment; tag: GQL.TagDataFragment;
@@ -25,12 +26,11 @@ export const TagCard: React.FC<IProps> = ({
if (!tag.scene_count) return; if (!tag.scene_count) return;
return ( return (
<Link to={NavUtils.makeTagScenesUrl(tag)}> <PopoverCountButton
<Button className="minimal"> type="scene"
<Icon icon="play-circle" /> count={tag.scene_count}
<span>{tag.scene_count}</span> url={NavUtils.makeTagScenesUrl(tag)}
</Button> />
</Link>
); );
} }
@@ -47,6 +47,30 @@ export const TagCard: React.FC<IProps> = ({
); );
} }
function maybeRenderImagesPopoverButton() {
if (!tag.image_count) return;
return (
<PopoverCountButton
type="image"
count={tag.image_count}
url={NavUtils.makeTagImagesUrl(tag)}
/>
);
}
function maybeRenderGalleriesPopoverButton() {
if (!tag.gallery_count) return;
return (
<PopoverCountButton
type="gallery"
count={tag.gallery_count}
url={NavUtils.makeTagGalleriesUrl(tag)}
/>
);
}
function maybeRenderPerformersPopoverButton() { function maybeRenderPerformersPopoverButton() {
if (!tag.performer_count) return; if (!tag.performer_count) return;
@@ -67,6 +91,8 @@ export const TagCard: React.FC<IProps> = ({
<hr /> <hr />
<ButtonGroup className="card-popovers"> <ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()} {maybeRenderScenesPopoverButton()}
{maybeRenderImagesPopoverButton()}
{maybeRenderGalleriesPopoverButton()}
{maybeRenderSceneMarkersPopoverButton()} {maybeRenderSceneMarkersPopoverButton()}
{maybeRenderPerformersPopoverButton()} {maybeRenderPerformersPopoverButton()}
</ButtonGroup> </ButtonGroup>

View File

@@ -23,6 +23,32 @@ const makePerformerScenesUrl = (
return `/scenes?${filter.makeQueryParameters()}`; return `/scenes?${filter.makeQueryParameters()}`;
}; };
const makePerformerImagesUrl = (
performer: Partial<GQL.PerformerDataFragment>
) => {
if (!performer.id) return "#";
const filter = new ListFilterModel(FilterMode.Images);
const criterion = new PerformersCriterion();
criterion.value = [
{ id: performer.id, label: performer.name || `Performer ${performer.id}` },
];
filter.criteria.push(criterion);
return `/images?${filter.makeQueryParameters()}`;
};
const makePerformerGalleriesUrl = (
performer: Partial<GQL.PerformerDataFragment>
) => {
if (!performer.id) return "#";
const filter = new ListFilterModel(FilterMode.Galleries);
const criterion = new PerformersCriterion();
criterion.value = [
{ id: performer.id, label: performer.name || `Performer ${performer.id}` },
];
filter.criteria.push(criterion);
return `/galleries?${filter.makeQueryParameters()}`;
};
const makePerformersCountryUrl = ( const makePerformersCountryUrl = (
performer: Partial<GQL.PerformerDataFragment> performer: Partial<GQL.PerformerDataFragment>
) => { ) => {
@@ -45,6 +71,28 @@ const makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
return `/scenes?${filter.makeQueryParameters()}`; return `/scenes?${filter.makeQueryParameters()}`;
}; };
const makeStudioImagesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (!studio.id) return "#";
const filter = new ListFilterModel(FilterMode.Images);
const criterion = new StudiosCriterion();
criterion.value = [
{ id: studio.id, label: studio.name || `Studio ${studio.id}` },
];
filter.criteria.push(criterion);
return `/images?${filter.makeQueryParameters()}`;
};
const makeStudioGalleriesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (!studio.id) return "#";
const filter = new ListFilterModel(FilterMode.Galleries);
const criterion = new StudiosCriterion();
criterion.value = [
{ id: studio.id, label: studio.name || `Studio ${studio.id}` },
];
filter.criteria.push(criterion);
return `/galleries?${filter.makeQueryParameters()}`;
};
const makeChildStudiosUrl = (studio: Partial<GQL.StudioDataFragment>) => { const makeChildStudiosUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (!studio.id) return "#"; if (!studio.id) return "#";
const filter = new ListFilterModel(FilterMode.Studios); const filter = new ListFilterModel(FilterMode.Studios);
@@ -121,8 +169,12 @@ const makeSceneMarkerUrl = (
export default { export default {
makePerformerScenesUrl, makePerformerScenesUrl,
makePerformerImagesUrl,
makePerformerGalleriesUrl,
makePerformersCountryUrl, makePerformersCountryUrl,
makeStudioScenesUrl, makeStudioScenesUrl,
makeStudioImagesUrl,
makeStudioGalleriesUrl,
makeTagSceneMarkersUrl, makeTagSceneMarkersUrl,
makeTagScenesUrl, makeTagScenesUrl,
makeTagPerformersUrl, makeTagPerformersUrl,