mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Add Chapters for Galleries (#3289)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
parent
32c91c4855
commit
7e8f941155
58 changed files with 1685 additions and 133 deletions
9
graphql/documents/data/gallery-chapter.graphql
Normal file
9
graphql/documents/data/gallery-chapter.graphql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
fragment GalleryChapterData on GalleryChapter {
|
||||
id
|
||||
title
|
||||
image_index
|
||||
|
||||
gallery {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,11 @@ fragment SlimGalleryData on Gallery {
|
|||
thumbnail
|
||||
}
|
||||
}
|
||||
chapters {
|
||||
id
|
||||
title
|
||||
image_index
|
||||
}
|
||||
studio {
|
||||
id
|
||||
name
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ fragment GalleryData on Gallery {
|
|||
...FolderData
|
||||
}
|
||||
|
||||
chapters {
|
||||
...GalleryChapterData
|
||||
}
|
||||
cover {
|
||||
...SlimImageData
|
||||
}
|
||||
|
|
|
|||
31
graphql/documents/mutations/gallery-chapter.graphql
Normal file
31
graphql/documents/mutations/gallery-chapter.graphql
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
mutation GalleryChapterCreate(
|
||||
$title: String!,
|
||||
$image_index: Int!,
|
||||
$gallery_id: ID!) {
|
||||
galleryChapterCreate(input: {
|
||||
title: $title,
|
||||
image_index: $image_index,
|
||||
gallery_id: $gallery_id,
|
||||
}) {
|
||||
...GalleryChapterData
|
||||
}
|
||||
}
|
||||
|
||||
mutation GalleryChapterUpdate(
|
||||
$id: ID!,
|
||||
$title: String!,
|
||||
$image_index: Int!,
|
||||
$gallery_id: ID!) {
|
||||
galleryChapterUpdate(input: {
|
||||
id: $id,
|
||||
title: $title,
|
||||
image_index: $image_index,
|
||||
gallery_id: $gallery_id,
|
||||
}) {
|
||||
...GalleryChapterData
|
||||
}
|
||||
}
|
||||
|
||||
mutation GalleryChapterDestroy($id: ID!) {
|
||||
galleryChapterDestroy(id: $id)
|
||||
}
|
||||
|
|
@ -218,6 +218,10 @@ type Mutation {
|
|||
addGalleryImages(input: GalleryAddInput!): Boolean!
|
||||
removeGalleryImages(input: GalleryRemoveInput!): Boolean!
|
||||
|
||||
galleryChapterCreate(input: GalleryChapterCreateInput!): GalleryChapter
|
||||
galleryChapterUpdate(input: GalleryChapterUpdateInput!): GalleryChapter
|
||||
galleryChapterDestroy(id: ID!): Boolean!
|
||||
|
||||
performerCreate(input: PerformerCreateInput!): Performer
|
||||
performerUpdate(input: PerformerUpdateInput!): Performer
|
||||
performerDestroy(input: PerformerDestroyInput!): Boolean!
|
||||
|
|
|
|||
|
|
@ -324,6 +324,8 @@ input GalleryFilterType {
|
|||
organized: Boolean
|
||||
"""Filter by average image resolution"""
|
||||
average_resolution: ResolutionCriterionInput
|
||||
"""Filter to only include galleries that have chapters. `true` or `false`"""
|
||||
has_chapters: String
|
||||
"""Filter to only include galleries with this studio"""
|
||||
studios: HierarchicalMultiCriterionInput
|
||||
"""Filter to only include galleries with these tags"""
|
||||
|
|
|
|||
26
graphql/schema/types/gallery-chapter.graphql
Normal file
26
graphql/schema/types/gallery-chapter.graphql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
type GalleryChapter {
|
||||
id: ID!
|
||||
gallery: Gallery!
|
||||
title: String!
|
||||
image_index: Int!
|
||||
created_at: Time!
|
||||
updated_at: Time!
|
||||
}
|
||||
|
||||
input GalleryChapterCreateInput {
|
||||
gallery_id: ID!
|
||||
title: String!
|
||||
image_index: Int!
|
||||
}
|
||||
|
||||
input GalleryChapterUpdateInput {
|
||||
id: ID!
|
||||
gallery_id: ID!
|
||||
title: String!
|
||||
image_index: Int!
|
||||
}
|
||||
|
||||
type FindGalleryChaptersResultType {
|
||||
count: Int!
|
||||
chapters: [GalleryChapter!]!
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ type Gallery {
|
|||
files: [GalleryFile!]!
|
||||
folder: Folder
|
||||
|
||||
chapters: [GalleryChapter!]!
|
||||
scenes: [Scene!]!
|
||||
studio: Studio
|
||||
image_count: Int!
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ func (r *Resolver) scraperCache() *scraper.Cache {
|
|||
func (r *Resolver) Gallery() GalleryResolver {
|
||||
return &galleryResolver{r}
|
||||
}
|
||||
func (r *Resolver) GalleryChapter() GalleryChapterResolver {
|
||||
return &galleryChapterResolver{r}
|
||||
}
|
||||
func (r *Resolver) Mutation() MutationResolver {
|
||||
return &mutationResolver{r}
|
||||
}
|
||||
|
|
@ -83,6 +86,7 @@ type queryResolver struct{ *Resolver }
|
|||
type subscriptionResolver struct{ *Resolver }
|
||||
|
||||
type galleryResolver struct{ *Resolver }
|
||||
type galleryChapterResolver struct{ *Resolver }
|
||||
type performerResolver struct{ *Resolver }
|
||||
type sceneResolver struct{ *Resolver }
|
||||
type sceneMarkerResolver struct{ *Resolver }
|
||||
|
|
|
|||
|
|
@ -249,3 +249,14 @@ func (r *galleryResolver) ImageCount(ctx context.Context, obj *models.Gallery) (
|
|||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *galleryResolver) Chapters(ctx context.Context, obj *models.Gallery) (ret []*models.GalleryChapter, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.GalleryChapter.FindByGalleryID(ctx, obj.ID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
|
|
|||
32
internal/api/resolver_model_gallery_chapter.go
Normal file
32
internal/api/resolver_model_gallery_chapter.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func (r *galleryChapterResolver) Gallery(ctx context.Context, obj *models.GalleryChapter) (ret *models.Gallery, err error) {
|
||||
if !obj.GalleryID.Valid {
|
||||
panic("Invalid gallery id")
|
||||
}
|
||||
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
galleryID := int(obj.GalleryID.Int64)
|
||||
ret, err = r.repository.Gallery.Find(ctx, galleryID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *galleryChapterResolver) CreatedAt(ctx context.Context, obj *models.GalleryChapter) (*time.Time, error) {
|
||||
return &obj.CreatedAt.Timestamp, nil
|
||||
}
|
||||
|
||||
func (r *galleryChapterResolver) UpdatedAt(ctx context.Context, obj *models.GalleryChapter) (*time.Time, error) {
|
||||
return &obj.UpdatedAt.Timestamp, nil
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
|
@ -10,6 +11,7 @@ import (
|
|||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/gallery"
|
||||
"github.com/stashapp/stash/pkg/image"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/plugin"
|
||||
|
|
@ -489,3 +491,150 @@ func (r *mutationResolver) RemoveGalleryImages(ctx context.Context, input Galler
|
|||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) getGalleryChapter(ctx context.Context, id int) (ret *models.GalleryChapter, err error) {
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.GalleryChapter.Find(ctx, id)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) GalleryChapterCreate(ctx context.Context, input GalleryChapterCreateInput) (*models.GalleryChapter, error) {
|
||||
galleryID, err := strconv.Atoi(input.GalleryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var imageCount int
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
imageCount, err = r.repository.Image.CountByGalleryID(ctx, galleryID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Sanity Check of Index
|
||||
if input.ImageIndex > imageCount || input.ImageIndex < 1 {
|
||||
return nil, errors.New("Image # must greater than zero and in range of the gallery images")
|
||||
}
|
||||
|
||||
currentTime := time.Now()
|
||||
newGalleryChapter := models.GalleryChapter{
|
||||
Title: input.Title,
|
||||
ImageIndex: input.ImageIndex,
|
||||
GalleryID: sql.NullInt64{Int64: int64(galleryID), Valid: galleryID != 0},
|
||||
CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret, err := r.changeChapter(ctx, create, newGalleryChapter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.hookExecutor.ExecutePostHooks(ctx, ret.ID, plugin.GalleryChapterCreatePost, input, nil)
|
||||
return r.getGalleryChapter(ctx, ret.ID)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) GalleryChapterUpdate(ctx context.Context, input GalleryChapterUpdateInput) (*models.GalleryChapter, error) {
|
||||
// Populate gallery chapter from the input
|
||||
galleryChapterID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
galleryID, err := strconv.Atoi(input.GalleryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var imageCount int
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
imageCount, err = r.repository.Image.CountByGalleryID(ctx, galleryID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Sanity Check of Index
|
||||
if input.ImageIndex > imageCount || input.ImageIndex < 1 {
|
||||
return nil, errors.New("Image # must greater than zero and in range of the gallery images")
|
||||
}
|
||||
|
||||
updatedGalleryChapter := models.GalleryChapter{
|
||||
ID: galleryChapterID,
|
||||
Title: input.Title,
|
||||
ImageIndex: input.ImageIndex,
|
||||
GalleryID: sql.NullInt64{Int64: int64(galleryID), Valid: galleryID != 0},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: time.Now()},
|
||||
}
|
||||
|
||||
ret, err := r.changeChapter(ctx, update, updatedGalleryChapter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
r.hookExecutor.ExecutePostHooks(ctx, ret.ID, plugin.GalleryChapterUpdatePost, input, translator.getFields())
|
||||
return r.getGalleryChapter(ctx, ret.ID)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) GalleryChapterDestroy(ctx context.Context, id string) (bool, error) {
|
||||
chapterID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.GalleryChapter
|
||||
|
||||
chapter, err := qb.Find(ctx, chapterID)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if chapter == nil {
|
||||
return fmt.Errorf("Chapter with id %d not found", chapterID)
|
||||
}
|
||||
|
||||
return gallery.DestroyChapter(ctx, chapter, qb)
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
r.hookExecutor.ExecutePostHooks(ctx, chapterID, plugin.GalleryChapterDestroyPost, id, nil)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) changeChapter(ctx context.Context, changeType int, changedChapter models.GalleryChapter) (*models.GalleryChapter, error) {
|
||||
var galleryChapter *models.GalleryChapter
|
||||
|
||||
// Start the transaction and save the gallery chapter
|
||||
var err = r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.GalleryChapter
|
||||
var err error
|
||||
|
||||
switch changeType {
|
||||
case create:
|
||||
galleryChapter, err = qb.Create(ctx, changedChapter)
|
||||
case update:
|
||||
galleryChapter, err = qb.Update(ctx, changedChapter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
return galleryChapter, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ type Repository struct {
|
|||
File FileReaderWriter
|
||||
Folder FolderReaderWriter
|
||||
Gallery GalleryReaderWriter
|
||||
GalleryChapter models.GalleryChapterReaderWriter
|
||||
Image ImageReaderWriter
|
||||
Movie models.MovieReaderWriter
|
||||
Performer models.PerformerReaderWriter
|
||||
|
|
@ -83,6 +84,7 @@ func sqliteRepository(d *sqlite.Database) Repository {
|
|||
File: d.File,
|
||||
Folder: d.Folder,
|
||||
Gallery: d.Gallery,
|
||||
GalleryChapter: txnRepo.GalleryChapter,
|
||||
Image: d.Image,
|
||||
Movie: txnRepo.Movie,
|
||||
Performer: txnRepo.Performer,
|
||||
|
|
|
|||
|
|
@ -765,6 +765,7 @@ func exportGallery(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *mode
|
|||
studioReader := repo.Studio
|
||||
performerReader := repo.Performer
|
||||
tagReader := repo.Tag
|
||||
galleryChapterReader := repo.GalleryChapter
|
||||
|
||||
for g := range jobChan {
|
||||
if err := g.LoadFiles(ctx, repo.Gallery); err != nil {
|
||||
|
|
@ -821,6 +822,12 @@ func exportGallery(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *mode
|
|||
continue
|
||||
}
|
||||
|
||||
newGalleryJSON.Chapters, err = gallery.GetGalleryChaptersJSON(ctx, galleryChapterReader, g)
|
||||
if err != nil {
|
||||
logger.Errorf("[galleries] <%s> error getting gallery chapters JSON: %s", galleryHash, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
newGalleryJSON.Tags = tag.GetNames(tags)
|
||||
|
||||
if t.includeDependencies {
|
||||
|
|
|
|||
|
|
@ -487,6 +487,7 @@ func (t *ImportTask) ImportGalleries(ctx context.Context) {
|
|||
tagWriter := r.Tag
|
||||
performerWriter := r.Performer
|
||||
studioWriter := r.Studio
|
||||
chapterWriter := r.GalleryChapter
|
||||
|
||||
galleryImporter := &gallery.Importer{
|
||||
ReaderWriter: readerWriter,
|
||||
|
|
@ -499,7 +500,25 @@ func (t *ImportTask) ImportGalleries(ctx context.Context) {
|
|||
MissingRefBehaviour: t.MissingRefBehaviour,
|
||||
}
|
||||
|
||||
return performImport(ctx, galleryImporter, t.DuplicateBehaviour)
|
||||
if err := performImport(ctx, galleryImporter, t.DuplicateBehaviour); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// import the gallery chapters
|
||||
for _, m := range galleryJSON.Chapters {
|
||||
chapterImporter := &gallery.ChapterImporter{
|
||||
GalleryID: galleryImporter.ID,
|
||||
Input: m,
|
||||
MissingRefBehaviour: t.MissingRefBehaviour,
|
||||
ReaderWriter: chapterWriter,
|
||||
}
|
||||
|
||||
if err := performImport(ctx, chapterImporter, t.DuplicateBehaviour); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
logger.Errorf("[galleries] <%s> import failed to commit: %s", fi.Name(), err.Error())
|
||||
continue
|
||||
|
|
|
|||
83
pkg/gallery/chapter_import.go
Normal file
83
pkg/gallery/chapter_import.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package gallery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/jsonschema"
|
||||
)
|
||||
|
||||
type ChapterCreatorUpdater interface {
|
||||
Create(ctx context.Context, newGalleryChapter models.GalleryChapter) (*models.GalleryChapter, error)
|
||||
Update(ctx context.Context, updatedGalleryChapter models.GalleryChapter) (*models.GalleryChapter, error)
|
||||
FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error)
|
||||
}
|
||||
|
||||
type ChapterImporter struct {
|
||||
GalleryID int
|
||||
ReaderWriter ChapterCreatorUpdater
|
||||
Input jsonschema.GalleryChapter
|
||||
MissingRefBehaviour models.ImportMissingRefEnum
|
||||
|
||||
chapter models.GalleryChapter
|
||||
}
|
||||
|
||||
func (i *ChapterImporter) PreImport(ctx context.Context) error {
|
||||
i.chapter = models.GalleryChapter{
|
||||
Title: i.Input.Title,
|
||||
ImageIndex: i.Input.ImageIndex,
|
||||
GalleryID: sql.NullInt64{Int64: int64(i.GalleryID), Valid: true},
|
||||
CreatedAt: models.SQLiteTimestamp{Timestamp: i.Input.CreatedAt.GetTime()},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: i.Input.UpdatedAt.GetTime()},
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *ChapterImporter) Name() string {
|
||||
return fmt.Sprintf("%s (%d)", i.Input.Title, i.Input.ImageIndex)
|
||||
}
|
||||
|
||||
func (i *ChapterImporter) PostImport(ctx context.Context, id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *ChapterImporter) FindExistingID(ctx context.Context) (*int, error) {
|
||||
existingChapters, err := i.ReaderWriter.FindByGalleryID(ctx, i.GalleryID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range existingChapters {
|
||||
if m.ImageIndex == i.chapter.ImageIndex {
|
||||
id := m.ID
|
||||
return &id, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (i *ChapterImporter) Create(ctx context.Context) (*int, error) {
|
||||
created, err := i.ReaderWriter.Create(ctx, i.chapter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating chapter: %v", err)
|
||||
}
|
||||
|
||||
id := created.ID
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
func (i *ChapterImporter) Update(ctx context.Context, id int) error {
|
||||
chapter := i.chapter
|
||||
chapter.ID = id
|
||||
_, err := i.ReaderWriter.Update(ctx, chapter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error updating existing chapter: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ import (
|
|||
func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) {
|
||||
var imgsDestroyed []*models.Image
|
||||
|
||||
// chapter deletion is done via delete cascade, so we don't need to do anything here
|
||||
|
||||
// if this is a zip-based gallery, delete the images as well first
|
||||
zipImgsDestroyed, err := s.destroyZipFileImages(ctx, i, fileDeleter, deleteGenerated, deleteFile)
|
||||
if err != nil {
|
||||
|
|
@ -39,6 +41,15 @@ func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *i
|
|||
return imgsDestroyed, nil
|
||||
}
|
||||
|
||||
type ChapterDestroyer interface {
|
||||
FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error)
|
||||
Destroy(ctx context.Context, id int) error
|
||||
}
|
||||
|
||||
func DestroyChapter(ctx context.Context, galleryChapter *models.GalleryChapter, qb ChapterDestroyer) error {
|
||||
return qb.Destroy(ctx, galleryChapter.ID)
|
||||
}
|
||||
|
||||
func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) {
|
||||
if err := i.LoadFiles(ctx, s.Repository); err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package gallery
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/json"
|
||||
|
|
@ -9,6 +10,10 @@ import (
|
|||
"github.com/stashapp/stash/pkg/studio"
|
||||
)
|
||||
|
||||
type ChapterFinder interface {
|
||||
FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error)
|
||||
}
|
||||
|
||||
// ToBasicJSON converts a gallery object into its JSON object equivalent. It
|
||||
// does not convert the relationships to other objects.
|
||||
func ToBasicJSON(gallery *models.Gallery) (*jsonschema.Gallery, error) {
|
||||
|
|
@ -58,6 +63,30 @@ func GetStudioName(ctx context.Context, reader studio.Finder, gallery *models.Ga
|
|||
return "", nil
|
||||
}
|
||||
|
||||
// GetGalleryChaptersJSON returns a slice of GalleryChapter JSON representation
|
||||
// objects corresponding to the provided gallery's chapters.
|
||||
func GetGalleryChaptersJSON(ctx context.Context, chapterReader ChapterFinder, gallery *models.Gallery) ([]jsonschema.GalleryChapter, error) {
|
||||
galleryChapters, err := chapterReader.FindByGalleryID(ctx, gallery.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting gallery chapters: %v", err)
|
||||
}
|
||||
|
||||
var results []jsonschema.GalleryChapter
|
||||
|
||||
for _, galleryChapter := range galleryChapters {
|
||||
galleryChapterJSON := jsonschema.GalleryChapter{
|
||||
Title: galleryChapter.Title,
|
||||
ImageIndex: galleryChapter.ImageIndex,
|
||||
CreatedAt: json.JSONTime{Time: galleryChapter.CreatedAt.Timestamp},
|
||||
UpdatedAt: json.JSONTime{Time: galleryChapter.UpdatedAt.Timestamp},
|
||||
}
|
||||
|
||||
results = append(results, galleryChapterJSON)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func GetIDs(galleries []*models.Gallery) []int {
|
||||
var results []int
|
||||
for _, gallery := range galleries {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ const (
|
|||
errStudioID = 6
|
||||
|
||||
// noTagsID = 11
|
||||
noChaptersID = 7
|
||||
errChaptersID = 8
|
||||
errFindByChapterID = 9
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -63,6 +66,19 @@ func createFullGallery(id int) models.Gallery {
|
|||
}
|
||||
}
|
||||
|
||||
func createEmptyGallery(id int) models.Gallery {
|
||||
return models.Gallery{
|
||||
ID: id,
|
||||
Files: models.NewRelatedFiles([]file.File{
|
||||
&file.BaseFile{
|
||||
Path: path,
|
||||
},
|
||||
}),
|
||||
CreatedAt: createTime,
|
||||
UpdatedAt: updateTime,
|
||||
}
|
||||
}
|
||||
|
||||
func createFullJSONGallery() *jsonschema.Gallery {
|
||||
return &jsonschema.Gallery{
|
||||
Title: title,
|
||||
|
|
@ -168,3 +184,109 @@ func TestGetStudioName(t *testing.T) {
|
|||
|
||||
mockStudioReader.AssertExpectations(t)
|
||||
}
|
||||
|
||||
const (
|
||||
validChapterID1 = 1
|
||||
validChapterID2 = 2
|
||||
|
||||
chapterTitle1 = "chapterTitle1"
|
||||
chapterTitle2 = "chapterTitle2"
|
||||
|
||||
chapterImageIndex1 = 10
|
||||
chapterImageIndex2 = 50
|
||||
)
|
||||
|
||||
type galleryChaptersTestScenario struct {
|
||||
input models.Gallery
|
||||
expected []jsonschema.GalleryChapter
|
||||
err bool
|
||||
}
|
||||
|
||||
var getGalleryChaptersJSONScenarios = []galleryChaptersTestScenario{
|
||||
{
|
||||
createEmptyGallery(galleryID),
|
||||
[]jsonschema.GalleryChapter{
|
||||
{
|
||||
Title: chapterTitle1,
|
||||
ImageIndex: chapterImageIndex1,
|
||||
CreatedAt: json.JSONTime{
|
||||
Time: createTime,
|
||||
},
|
||||
UpdatedAt: json.JSONTime{
|
||||
Time: updateTime,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: chapterTitle2,
|
||||
ImageIndex: chapterImageIndex2,
|
||||
CreatedAt: json.JSONTime{
|
||||
Time: createTime,
|
||||
},
|
||||
UpdatedAt: json.JSONTime{
|
||||
Time: updateTime,
|
||||
},
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
createEmptyGallery(noChaptersID),
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
createEmptyGallery(errChaptersID),
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
var validChapters = []*models.GalleryChapter{
|
||||
{
|
||||
ID: validChapterID1,
|
||||
Title: chapterTitle1,
|
||||
ImageIndex: chapterImageIndex1,
|
||||
CreatedAt: models.SQLiteTimestamp{
|
||||
Timestamp: createTime,
|
||||
},
|
||||
UpdatedAt: models.SQLiteTimestamp{
|
||||
Timestamp: updateTime,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: validChapterID2,
|
||||
Title: chapterTitle2,
|
||||
ImageIndex: chapterImageIndex2,
|
||||
CreatedAt: models.SQLiteTimestamp{
|
||||
Timestamp: createTime,
|
||||
},
|
||||
UpdatedAt: models.SQLiteTimestamp{
|
||||
Timestamp: updateTime,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestGetGalleryChaptersJSON(t *testing.T) {
|
||||
mockChapterReader := &mocks.GalleryChapterReaderWriter{}
|
||||
|
||||
chaptersErr := errors.New("error getting gallery chapters")
|
||||
|
||||
mockChapterReader.On("FindByGalleryID", testCtx, galleryID).Return(validChapters, nil).Once()
|
||||
mockChapterReader.On("FindByGalleryID", testCtx, noChaptersID).Return(nil, nil).Once()
|
||||
mockChapterReader.On("FindByGalleryID", testCtx, errChaptersID).Return(nil, chaptersErr).Once()
|
||||
|
||||
for i, s := range getGalleryChaptersJSONScenarios {
|
||||
gallery := s.input
|
||||
json, err := GetGalleryChaptersJSON(testCtx, mockChapterReader, &gallery)
|
||||
|
||||
switch {
|
||||
case !s.err && err != nil:
|
||||
t.Errorf("[%d] unexpected error: %s", i, err.Error())
|
||||
case s.err && err == nil:
|
||||
t.Errorf("[%d] expected error not returned", i)
|
||||
default:
|
||||
assert.Equal(t, s.expected, json, "[%d]", i)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type Importer struct {
|
|||
Input jsonschema.Gallery
|
||||
MissingRefBehaviour models.ImportMissingRefEnum
|
||||
|
||||
ID int
|
||||
gallery models.Gallery
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ type ImageService interface {
|
|||
DestroyZipImages(ctx context.Context, zipFile file.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error)
|
||||
}
|
||||
|
||||
type ChapterRepository interface {
|
||||
ChapterFinder
|
||||
ChapterDestroyer
|
||||
|
||||
Update(ctx context.Context, updatedObject models.GalleryChapter) (*models.GalleryChapter, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Repository Repository
|
||||
ImageFinder ImageFinder
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ type GalleryFilterType struct {
|
|||
Organized *bool `json:"organized"`
|
||||
// Filter by average image resolution
|
||||
AverageResolution *ResolutionCriterionInput `json:"average_resolution"`
|
||||
// Filter to only include scenes which have chapters. `true` or `false`
|
||||
HasChapters *string `json:"has_chapters"`
|
||||
// Filter to only include galleries with this studio
|
||||
Studios *HierarchicalMultiCriterionInput `json:"studios"`
|
||||
// Filter to only include galleries with these tags
|
||||
|
|
|
|||
20
pkg/models/gallery_chapter.go
Normal file
20
pkg/models/gallery_chapter.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package models
|
||||
|
||||
import "context"
|
||||
|
||||
type GalleryChapterReader interface {
|
||||
Find(ctx context.Context, id int) (*GalleryChapter, error)
|
||||
FindMany(ctx context.Context, ids []int) ([]*GalleryChapter, error)
|
||||
FindByGalleryID(ctx context.Context, galleryID int) ([]*GalleryChapter, error)
|
||||
}
|
||||
|
||||
type GalleryChapterWriter interface {
|
||||
Create(ctx context.Context, newGalleryChapter GalleryChapter) (*GalleryChapter, error)
|
||||
Update(ctx context.Context, updatedGalleryChapter GalleryChapter) (*GalleryChapter, error)
|
||||
Destroy(ctx context.Context, id int) error
|
||||
}
|
||||
|
||||
type GalleryChapterReaderWriter interface {
|
||||
GalleryChapterReader
|
||||
GalleryChapterWriter
|
||||
}
|
||||
|
|
@ -10,6 +10,13 @@ import (
|
|||
"github.com/stashapp/stash/pkg/models/json"
|
||||
)
|
||||
|
||||
type GalleryChapter struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
ImageIndex int `json:"image_index,omitempty"`
|
||||
CreatedAt json.JSONTime `json:"created_at,omitempty"`
|
||||
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type Gallery struct {
|
||||
ZipFiles []string `json:"zip_files,omitempty"`
|
||||
FolderPath string `json:"folder_path,omitempty"`
|
||||
|
|
@ -19,6 +26,7 @@ type Gallery struct {
|
|||
Details string `json:"details,omitempty"`
|
||||
Rating int `json:"rating,omitempty"`
|
||||
Organized bool `json:"organized,omitempty"`
|
||||
Chapters []GalleryChapter `json:"chapters,omitempty"`
|
||||
Studio string `json:"studio,omitempty"`
|
||||
Performers []string `json:"performers,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
|
|
|
|||
144
pkg/models/mocks/GalleryChapterReaderWriter.go
Normal file
144
pkg/models/mocks/GalleryChapterReaderWriter.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// Code generated by mockery v2.10.0. DO NOT EDIT.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
models "github.com/stashapp/stash/pkg/models"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// GalleryChapterReaderWriter is an autogenerated mock type for the GalleryChapterReaderWriter type
|
||||
type GalleryChapterReaderWriter struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Create provides a mock function with given fields: ctx, newGalleryChapter
|
||||
func (_m *GalleryChapterReaderWriter) Create(ctx context.Context, newGalleryChapter models.GalleryChapter) (*models.GalleryChapter, error) {
|
||||
ret := _m.Called(ctx, newGalleryChapter)
|
||||
|
||||
var r0 *models.GalleryChapter
|
||||
if rf, ok := ret.Get(0).(func(context.Context, models.GalleryChapter) *models.GalleryChapter); ok {
|
||||
r0 = rf(ctx, newGalleryChapter)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*models.GalleryChapter)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, models.GalleryChapter) error); ok {
|
||||
r1 = rf(ctx, newGalleryChapter)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Destroy provides a mock function with given fields: ctx, id
|
||||
func (_m *GalleryChapterReaderWriter) Destroy(ctx context.Context, id int) error {
|
||||
ret := _m.Called(ctx, id)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int) error); ok {
|
||||
r0 = rf(ctx, id)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Find provides a mock function with given fields: ctx, id
|
||||
func (_m *GalleryChapterReaderWriter) Find(ctx context.Context, id int) (*models.GalleryChapter, error) {
|
||||
ret := _m.Called(ctx, id)
|
||||
|
||||
var r0 *models.GalleryChapter
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int) *models.GalleryChapter); ok {
|
||||
r0 = rf(ctx, id)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*models.GalleryChapter)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
|
||||
r1 = rf(ctx, id)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByGalleryID provides a mock function with given fields: ctx, galleryID
|
||||
func (_m *GalleryChapterReaderWriter) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error) {
|
||||
ret := _m.Called(ctx, galleryID)
|
||||
|
||||
var r0 []*models.GalleryChapter
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int) []*models.GalleryChapter); ok {
|
||||
r0 = rf(ctx, galleryID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.GalleryChapter)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
|
||||
r1 = rf(ctx, galleryID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindMany provides a mock function with given fields: ctx, ids
|
||||
func (_m *GalleryChapterReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.GalleryChapter, error) {
|
||||
ret := _m.Called(ctx, ids)
|
||||
|
||||
var r0 []*models.GalleryChapter
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.GalleryChapter); ok {
|
||||
r0 = rf(ctx, ids)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.GalleryChapter)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {
|
||||
r1 = rf(ctx, ids)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Update provides a mock function with given fields: ctx, updatedGalleryChapter
|
||||
func (_m *GalleryChapterReaderWriter) Update(ctx context.Context, updatedGalleryChapter models.GalleryChapter) (*models.GalleryChapter, error) {
|
||||
ret := _m.Called(ctx, updatedGalleryChapter)
|
||||
|
||||
var r0 *models.GalleryChapter
|
||||
if rf, ok := ret.Get(0).(func(context.Context, models.GalleryChapter) *models.GalleryChapter); ok {
|
||||
r0 = rf(ctx, updatedGalleryChapter)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*models.GalleryChapter)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, models.GalleryChapter) error); ok {
|
||||
r1 = rf(ctx, updatedGalleryChapter)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ func NewTxnRepository() models.Repository {
|
|||
return models.Repository{
|
||||
TxnManager: &TxnManager{},
|
||||
Gallery: &GalleryReaderWriter{},
|
||||
GalleryChapter: &GalleryChapterReaderWriter{},
|
||||
Image: &ImageReaderWriter{},
|
||||
Movie: &MovieReaderWriter{},
|
||||
Performer: &PerformerReaderWriter{},
|
||||
|
|
|
|||
24
pkg/models/model_gallery_chapter.go
Normal file
24
pkg/models/model_gallery_chapter.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type GalleryChapter struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
ImageIndex int `db:"image_index" json:"image_index"`
|
||||
GalleryID sql.NullInt64 `db:"gallery_id,omitempty" json:"gallery_id"`
|
||||
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
|
||||
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type GalleryChapters []*GalleryChapter
|
||||
|
||||
func (m *GalleryChapters) Append(o interface{}) {
|
||||
*m = append(*m, o.(*GalleryChapter))
|
||||
}
|
||||
|
||||
func (m *GalleryChapters) New() interface{} {
|
||||
return &GalleryChapter{}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ type Repository struct {
|
|||
File file.Store
|
||||
Folder file.FolderStore
|
||||
Gallery GalleryReaderWriter
|
||||
GalleryChapter GalleryChapterReaderWriter
|
||||
Image ImageReaderWriter
|
||||
Movie MovieReaderWriter
|
||||
Performer PerformerReaderWriter
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ const (
|
|||
GalleryUpdatePost HookTriggerEnum = "Gallery.Update.Post"
|
||||
GalleryDestroyPost HookTriggerEnum = "Gallery.Destroy.Post"
|
||||
|
||||
GalleryChapterCreatePost HookTriggerEnum = "GalleryChapter.Create.Post"
|
||||
GalleryChapterUpdatePost HookTriggerEnum = "GalleryChapter.Update.Post"
|
||||
GalleryChapterDestroyPost HookTriggerEnum = "GalleryChapter.Destroy.Post"
|
||||
|
||||
MovieCreatePost HookTriggerEnum = "Movie.Create.Post"
|
||||
MovieUpdatePost HookTriggerEnum = "Movie.Update.Post"
|
||||
MovieDestroyPost HookTriggerEnum = "Movie.Destroy.Post"
|
||||
|
|
@ -69,6 +73,10 @@ var AllHookTriggerEnum = []HookTriggerEnum{
|
|||
GalleryUpdatePost,
|
||||
GalleryDestroyPost,
|
||||
|
||||
GalleryChapterCreatePost,
|
||||
GalleryChapterUpdatePost,
|
||||
GalleryChapterDestroyPost,
|
||||
|
||||
MovieCreatePost,
|
||||
MovieUpdatePost,
|
||||
MovieDestroyPost,
|
||||
|
|
@ -106,6 +114,10 @@ func (e HookTriggerEnum) IsValid() bool {
|
|||
GalleryUpdatePost,
|
||||
GalleryDestroyPost,
|
||||
|
||||
GalleryChapterCreatePost,
|
||||
GalleryChapterUpdatePost,
|
||||
GalleryChapterDestroyPost,
|
||||
|
||||
MovieCreatePost,
|
||||
MovieUpdatePost,
|
||||
MovieDestroyPost,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const (
|
|||
dbConnTimeout = 30
|
||||
)
|
||||
|
||||
var appSchemaVersion uint = 43
|
||||
var appSchemaVersion uint = 44
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsBox embed.FS
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const (
|
|||
galleriesTagsTable = "galleries_tags"
|
||||
galleriesImagesTable = "galleries_images"
|
||||
galleriesScenesTable = "scenes_galleries"
|
||||
galleriesChaptersTable = "galleries_chapters"
|
||||
galleryIDColumn = "gallery_id"
|
||||
)
|
||||
|
||||
|
|
@ -668,6 +669,7 @@ func (qb *GalleryStore) makeFilter(ctx context.Context, galleryFilter *models.Ga
|
|||
query.handleCriterion(ctx, galleryTagCountCriterionHandler(qb, galleryFilter.TagCount))
|
||||
query.handleCriterion(ctx, galleryPerformersCriterionHandler(qb, galleryFilter.Performers))
|
||||
query.handleCriterion(ctx, galleryPerformerCountCriterionHandler(qb, galleryFilter.PerformerCount))
|
||||
query.handleCriterion(ctx, hasChaptersCriterionHandler(galleryFilter.HasChapters))
|
||||
query.handleCriterion(ctx, galleryStudioCriterionHandler(qb, galleryFilter.Studios))
|
||||
query.handleCriterion(ctx, galleryPerformerTagsCriterionHandler(qb, galleryFilter.PerformerTags))
|
||||
query.handleCriterion(ctx, galleryAverageResolutionCriterionHandler(qb, galleryFilter.AverageResolution))
|
||||
|
|
@ -729,11 +731,15 @@ func (qb *GalleryStore) makeQuery(ctx context.Context, galleryFilter *models.Gal
|
|||
as: "gallery_folder",
|
||||
onClause: "galleries.folder_id = gallery_folder.id",
|
||||
},
|
||||
join{
|
||||
table: galleriesChaptersTable,
|
||||
onClause: "galleries_chapters.gallery_id = galleries.id",
|
||||
},
|
||||
)
|
||||
|
||||
// add joins for files and checksum
|
||||
filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename"
|
||||
searchColumns := []string{"galleries.title", "gallery_folder.path", filepathColumn, "files_fingerprints.fingerprint"}
|
||||
searchColumns := []string{"galleries.title", "gallery_folder.path", filepathColumn, "files_fingerprints.fingerprint", "galleries_chapters.title"}
|
||||
query.parseQueryString(searchColumns, *q)
|
||||
}
|
||||
|
||||
|
|
@ -949,6 +955,19 @@ func galleryImageCountCriterionHandler(qb *GalleryStore, imageCount *models.IntC
|
|||
return h.handler(imageCount)
|
||||
}
|
||||
|
||||
func hasChaptersCriterionHandler(hasChapters *string) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if hasChapters != nil {
|
||||
f.addLeftJoin("galleries_chapters", "", "galleries_chapters.gallery_id = galleries.id")
|
||||
if *hasChapters == "true" {
|
||||
f.addHaving("count(galleries_chapters.gallery_id) > 0")
|
||||
} else {
|
||||
f.addWhere("galleries_chapters.id IS NULL")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func galleryStudioCriterionHandler(qb *GalleryStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
h := hierarchicalMultiCriterionHandlerBuilder{
|
||||
tx: qb.tx,
|
||||
|
|
|
|||
94
pkg/sqlite/gallery_chapter.go
Normal file
94
pkg/sqlite/gallery_chapter.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
type galleryChapterQueryBuilder struct {
|
||||
repository
|
||||
}
|
||||
|
||||
var GalleryChapterReaderWriter = &galleryChapterQueryBuilder{
|
||||
repository{
|
||||
tableName: galleriesChaptersTable,
|
||||
idColumn: idColumn,
|
||||
},
|
||||
}
|
||||
|
||||
func (qb *galleryChapterQueryBuilder) Create(ctx context.Context, newObject models.GalleryChapter) (*models.GalleryChapter, error) {
|
||||
var ret models.GalleryChapter
|
||||
if err := qb.insertObject(ctx, newObject, &ret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
func (qb *galleryChapterQueryBuilder) Update(ctx context.Context, updatedObject models.GalleryChapter) (*models.GalleryChapter, error) {
|
||||
const partial = false
|
||||
if err := qb.update(ctx, updatedObject.ID, updatedObject, partial); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ret models.GalleryChapter
|
||||
if err := qb.getByID(ctx, updatedObject.ID, &ret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
func (qb *galleryChapterQueryBuilder) Destroy(ctx context.Context, id int) error {
|
||||
return qb.destroyExisting(ctx, []int{id})
|
||||
}
|
||||
|
||||
func (qb *galleryChapterQueryBuilder) Find(ctx context.Context, id int) (*models.GalleryChapter, error) {
|
||||
query := "SELECT * FROM galleries_chapters WHERE id = ? LIMIT 1"
|
||||
args := []interface{}{id}
|
||||
results, err := qb.queryGalleryChapters(ctx, query, args)
|
||||
if err != nil || len(results) < 1 {
|
||||
return nil, err
|
||||
}
|
||||
return results[0], nil
|
||||
}
|
||||
|
||||
func (qb *galleryChapterQueryBuilder) FindMany(ctx context.Context, ids []int) ([]*models.GalleryChapter, error) {
|
||||
var markers []*models.GalleryChapter
|
||||
for _, id := range ids {
|
||||
marker, err := qb.Find(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if marker == nil {
|
||||
return nil, fmt.Errorf("gallery chapter with id %d not found", id)
|
||||
}
|
||||
|
||||
markers = append(markers, marker)
|
||||
}
|
||||
|
||||
return markers, nil
|
||||
}
|
||||
|
||||
func (qb *galleryChapterQueryBuilder) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error) {
|
||||
query := `
|
||||
SELECT galleries_chapters.* FROM galleries_chapters
|
||||
WHERE galleries_chapters.gallery_id = ?
|
||||
GROUP BY galleries_chapters.id
|
||||
ORDER BY galleries_chapters.image_index ASC
|
||||
`
|
||||
args := []interface{}{galleryID}
|
||||
return qb.queryGalleryChapters(ctx, query, args)
|
||||
}
|
||||
|
||||
func (qb *galleryChapterQueryBuilder) queryGalleryChapters(ctx context.Context, query string, args []interface{}) ([]*models.GalleryChapter, error) {
|
||||
var ret models.GalleryChapters
|
||||
if err := qb.query(ctx, query, args, &ret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*models.GalleryChapter(ret), nil
|
||||
}
|
||||
44
pkg/sqlite/gallery_chapter_test.go
Normal file
44
pkg/sqlite/gallery_chapter_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestChapterFindByGalleryID(t *testing.T) {
|
||||
withTxn(func(ctx context.Context) error {
|
||||
mqb := sqlite.GalleryChapterReaderWriter
|
||||
|
||||
galleryID := galleryIDs[galleryIdxWithChapters]
|
||||
chapters, err := mqb.FindByGalleryID(ctx, galleryID)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Error finding chapters: %s", err.Error())
|
||||
}
|
||||
|
||||
assert.Greater(t, len(chapters), 0)
|
||||
for _, chapter := range chapters {
|
||||
assert.Equal(t, galleryIDs[galleryIdxWithChapters], int(chapter.GalleryID.Int64))
|
||||
}
|
||||
|
||||
chapters, err = mqb.FindByGalleryID(ctx, 0)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Error finding chapter: %s", err.Error())
|
||||
}
|
||||
|
||||
assert.Len(t, chapters, 0)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// TODO Update
|
||||
// TODO Destroy
|
||||
// TODO Find
|
||||
|
|
@ -2616,6 +2616,37 @@ func TestGalleryStore_RemoveImages(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGalleryQueryHasChapters(t *testing.T) {
|
||||
withTxn(func(ctx context.Context) error {
|
||||
sqb := db.Gallery
|
||||
hasChapters := "true"
|
||||
galleryFilter := models.GalleryFilterType{
|
||||
HasChapters: &hasChapters,
|
||||
}
|
||||
|
||||
q := getGalleryStringValue(galleryIdxWithChapters, titleField)
|
||||
findFilter := models.FindFilterType{
|
||||
Q: &q,
|
||||
}
|
||||
|
||||
galleries := queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)
|
||||
|
||||
assert.Len(t, galleries, 1)
|
||||
assert.Equal(t, galleryIDs[galleryIdxWithChapters], galleries[0].ID)
|
||||
|
||||
hasChapters = "false"
|
||||
galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)
|
||||
assert.Len(t, galleries, 0)
|
||||
|
||||
findFilter.Q = nil
|
||||
galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)
|
||||
|
||||
assert.NotEqual(t, 0, len(galleries))
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// TODO Count
|
||||
// TODO All
|
||||
// TODO Query
|
||||
|
|
|
|||
10
pkg/sqlite/migrations/44_gallery_chapters.up.sql
Normal file
10
pkg/sqlite/migrations/44_gallery_chapters.up.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE `galleries_chapters` (
|
||||
`id` integer not null primary key autoincrement,
|
||||
`title` varchar(255) not null,
|
||||
`image_index` integer not null,
|
||||
`gallery_id` integer not null,
|
||||
`created_at` datetime not null,
|
||||
`updated_at` datetime not null,
|
||||
foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE
|
||||
);
|
||||
CREATE INDEX `index_galleries_chapters_on_gallery_id` on `galleries_chapters` (`gallery_id`);
|
||||
|
|
@ -146,6 +146,7 @@ const (
|
|||
|
||||
const (
|
||||
galleryIdxWithScene = iota
|
||||
galleryIdxWithChapters
|
||||
galleryIdxWithImage
|
||||
galleryIdx1WithImage
|
||||
galleryIdx2WithImage
|
||||
|
|
@ -236,6 +237,11 @@ const (
|
|||
totalMarkers
|
||||
)
|
||||
|
||||
const (
|
||||
chapterIdxWithGallery = iota
|
||||
totalChapters
|
||||
)
|
||||
|
||||
const (
|
||||
savedFilterIdxDefaultScene = iota
|
||||
savedFilterIdxDefaultImage
|
||||
|
|
@ -261,6 +267,7 @@ var (
|
|||
sceneFileIDs []file.ID
|
||||
imageFileIDs []file.ID
|
||||
galleryFileIDs []file.ID
|
||||
chapterIDs []int
|
||||
|
||||
sceneIDs []int
|
||||
imageIDs []int
|
||||
|
|
@ -372,6 +379,19 @@ var (
|
|||
}
|
||||
)
|
||||
|
||||
type chapterSpec struct {
|
||||
galleryIdx int
|
||||
title string
|
||||
imageIndex int
|
||||
}
|
||||
|
||||
var (
|
||||
// indexed by chapter
|
||||
chapterSpecs = []chapterSpec{
|
||||
{galleryIdxWithChapters, "Test1", 10},
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
imageGalleries = linkMap{
|
||||
imageIdxWithGallery: {galleryIdxWithImage},
|
||||
|
|
@ -599,6 +619,11 @@ func populateDB() error {
|
|||
return fmt.Errorf("error creating scene marker: %s", err.Error())
|
||||
}
|
||||
}
|
||||
for _, cs := range chapterSpecs {
|
||||
if err := createChapter(ctx, sqlite.GalleryChapterReaderWriter, cs); err != nil {
|
||||
return fmt.Errorf("error creating gallery chapter: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
|
|
@ -1580,6 +1605,24 @@ func createMarker(ctx context.Context, mqb models.SceneMarkerReaderWriter, marke
|
|||
return nil
|
||||
}
|
||||
|
||||
func createChapter(ctx context.Context, mqb models.GalleryChapterReaderWriter, chapterSpec chapterSpec) error {
|
||||
chapter := models.GalleryChapter{
|
||||
GalleryID: sql.NullInt64{Int64: int64(sceneIDs[chapterSpec.galleryIdx]), Valid: true},
|
||||
Title: chapterSpec.title,
|
||||
ImageIndex: chapterSpec.imageIndex,
|
||||
}
|
||||
|
||||
created, err := mqb.Create(ctx, chapter)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating chapter %v+: %w", chapter, err)
|
||||
}
|
||||
|
||||
chapterIDs = append(chapterIDs, created.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSavedFilterMode(index int) models.FilterMode {
|
||||
switch index {
|
||||
case savedFilterIdxScene, savedFilterIdxDefaultScene:
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ func (db *Database) TxnRepository() models.Repository {
|
|||
File: db.File,
|
||||
Folder: db.Folder,
|
||||
Gallery: db.Gallery,
|
||||
GalleryChapter: GalleryChapterReaderWriter,
|
||||
Image: db.Image,
|
||||
Movie: MovieReaderWriter,
|
||||
Performer: db.Performer,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ database: generated.sqlite
|
|||
scenes: 30000
|
||||
images: 4000000
|
||||
galleries: 1500
|
||||
chapters: 3000
|
||||
markers: 3000
|
||||
performers: 10000
|
||||
studios: 1500
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import (
|
|||
const batchSize = 50000
|
||||
|
||||
// create an example database by generating a number of scenes, markers,
|
||||
// performers, studios and tags, and associating between them all
|
||||
// performers, studios, galleries, chapters and tags, and associating between them all
|
||||
|
||||
type config struct {
|
||||
Database string `yaml:"database"`
|
||||
|
|
@ -36,6 +36,7 @@ type config struct {
|
|||
Markers int `yaml:"markers"`
|
||||
Images int `yaml:"images"`
|
||||
Galleries int `yaml:"galleries"`
|
||||
Chapters int `yaml:"chapters"`
|
||||
Performers int `yaml:"performers"`
|
||||
Studios int `yaml:"studios"`
|
||||
Tags int `yaml:"tags"`
|
||||
|
|
@ -97,6 +98,7 @@ func populateDB() {
|
|||
makeScenes(c.Scenes)
|
||||
makeImages(c.Images)
|
||||
makeGalleries(c.Galleries)
|
||||
makeChapters(c.Chapters)
|
||||
makeMarkers(c.Markers)
|
||||
}
|
||||
|
||||
|
|
@ -496,6 +498,38 @@ func generateGallery(i int) models.Gallery {
|
|||
}
|
||||
}
|
||||
|
||||
func makeChapters(n int) {
|
||||
logf("creating %d chapters...", n)
|
||||
for i := 0; i < n; {
|
||||
// do in batches of 1000
|
||||
batch := i + batchSize
|
||||
if err := withTxn(func(ctx context.Context) error {
|
||||
for ; i < batch && i < n; i++ {
|
||||
chapter := generateChapter(i)
|
||||
chapter.GalleryID = models.NullInt64(int64(getRandomGallery()))
|
||||
|
||||
created, err := repo.GalleryChapter.Create(ctx, chapter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logf("... created %d chapters", i)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateChapter(i int) models.GalleryChapter {
|
||||
return models.GalleryChapter{
|
||||
Title: names[c.Naming.Galleries].generateName(rand.Intn(7) + 1),
|
||||
ImageIndex: rand.Intn(200),
|
||||
}
|
||||
}
|
||||
|
||||
func makeMarkers(n int) {
|
||||
logf("creating %d markers...", n)
|
||||
for i := 0; i < n; {
|
||||
|
|
@ -617,6 +651,10 @@ func getRandomScene() int {
|
|||
return rand.Intn(c.Scenes) + 1
|
||||
}
|
||||
|
||||
func getRandomGallery() int {
|
||||
return rand.Intn(c.Galleries) + 1
|
||||
}
|
||||
|
||||
func getRandomTags(ctx context.Context, min, max int) []int {
|
||||
var n int
|
||||
if min == max {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import React from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { Button } from "react-bootstrap";
|
||||
|
||||
interface IChapterEntries {
|
||||
galleryChapters: GQL.GalleryChapterDataFragment[];
|
||||
onClickChapter: (image_index: number) => void;
|
||||
onEdit: (chapter: GQL.GalleryChapterDataFragment) => void;
|
||||
}
|
||||
|
||||
export const ChapterEntries: React.FC<IChapterEntries> = ({
|
||||
galleryChapters,
|
||||
onClickChapter,
|
||||
onEdit,
|
||||
}) => {
|
||||
if (!galleryChapters?.length) return <div />;
|
||||
|
||||
const chapterCards = galleryChapters.map((chapter) => {
|
||||
return (
|
||||
<div key={chapter.id}>
|
||||
<hr />
|
||||
<div className="row">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => onClickChapter(chapter.image_index)}
|
||||
>
|
||||
<div className="row">
|
||||
{chapter.title}
|
||||
{chapter.title.length > 0 ? " - #" : "#"}
|
||||
{chapter.image_index}
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="ml-auto"
|
||||
onClick={() => onEdit(chapter)}
|
||||
>
|
||||
<FormattedMessage id="actions.edit" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return <div>{chapterCards}</div>;
|
||||
};
|
||||
|
|
@ -14,6 +14,7 @@ import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
|||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { Counter } from "src/components/Shared/Counter";
|
||||
import Mousetrap from "mousetrap";
|
||||
import { useGalleryLightbox } from "src/hooks/Lightbox/hooks";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton";
|
||||
import { GalleryEditPanel } from "./GalleryEditPanel";
|
||||
|
|
@ -25,6 +26,7 @@ import { GalleryFileInfoPanel } from "./GalleryFileInfoPanel";
|
|||
import { GalleryScenesPanel } from "./GalleryScenesPanel";
|
||||
import { faEllipsisV } from "@fortawesome/free-solid-svg-icons";
|
||||
import { galleryPath, galleryTitle } from "src/core/galleries";
|
||||
import { GalleryChapterPanel } from "./GalleryChaptersPanel";
|
||||
|
||||
interface IProps {
|
||||
gallery: GQL.GalleryDataFragment;
|
||||
|
|
@ -39,6 +41,7 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
|||
const history = useHistory();
|
||||
const Toast = useToast();
|
||||
const intl = useIntl();
|
||||
const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters);
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
|
|
@ -99,6 +102,10 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
|||
});
|
||||
}
|
||||
|
||||
async function onClickChapter(imageindex: number) {
|
||||
showLightbox(imageindex - 1);
|
||||
}
|
||||
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
|
|
@ -189,6 +196,11 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
|||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
) : undefined}
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="gallery-chapter-panel">
|
||||
<FormattedMessage id="chapters" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="gallery-edit-panel">
|
||||
<FormattedMessage id="actions.edit" />
|
||||
|
|
@ -215,6 +227,13 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
|||
>
|
||||
<GalleryFileInfoPanel gallery={gallery} />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="gallery-chapter-panel">
|
||||
<GalleryChapterPanel
|
||||
gallery={gallery}
|
||||
onClickChapter={onClickChapter}
|
||||
isVisible={activeTabKey === "gallery-chapter-panel"}
|
||||
/>
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="gallery-edit-panel">
|
||||
<GalleryEditPanel
|
||||
isVisible={activeTabKey === "gallery-edit-panel"}
|
||||
|
|
@ -279,12 +298,14 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
|||
// set up hotkeys
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("a", () => setActiveTabKey("gallery-details-panel"));
|
||||
Mousetrap.bind("c", () => setActiveTabKey("gallery-chapter-panel"));
|
||||
Mousetrap.bind("e", () => setActiveTabKey("gallery-edit-panel"));
|
||||
Mousetrap.bind("f", () => setActiveTabKey("gallery-file-info-panel"));
|
||||
Mousetrap.bind(",", () => setCollapsed(!collapsed));
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("a");
|
||||
Mousetrap.unbind("c");
|
||||
Mousetrap.unbind("e");
|
||||
Mousetrap.unbind("f");
|
||||
Mousetrap.unbind(",");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
import React from "react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Form as FormikForm, Formik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
useGalleryChapterCreate,
|
||||
useGalleryChapterUpdate,
|
||||
useGalleryChapterDestroy,
|
||||
} from "src/core/StashService";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import isEqual from "lodash-es/isEqual";
|
||||
|
||||
interface IFormFields {
|
||||
title: string;
|
||||
imageIndex: number;
|
||||
}
|
||||
|
||||
interface IGalleryChapterForm {
|
||||
galleryID: string;
|
||||
editingChapter?: GQL.GalleryChapterDataFragment;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({
|
||||
galleryID,
|
||||
editingChapter,
|
||||
onClose,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [galleryChapterCreate] = useGalleryChapterCreate();
|
||||
const [galleryChapterUpdate] = useGalleryChapterUpdate();
|
||||
const [galleryChapterDestroy] = useGalleryChapterDestroy();
|
||||
const Toast = useToast();
|
||||
|
||||
const schema = yup.object({
|
||||
title: yup.string().ensure(),
|
||||
imageIndex: yup
|
||||
.number()
|
||||
.required()
|
||||
.label(intl.formatMessage({ id: "image_index" }))
|
||||
.moreThan(0),
|
||||
});
|
||||
|
||||
const onSubmit = (values: IFormFields) => {
|
||||
const variables:
|
||||
| GQL.GalleryChapterUpdateInput
|
||||
| GQL.GalleryChapterCreateInput = {
|
||||
title: values.title,
|
||||
image_index: values.imageIndex,
|
||||
gallery_id: galleryID,
|
||||
};
|
||||
|
||||
if (!editingChapter) {
|
||||
galleryChapterCreate({ variables })
|
||||
.then(onClose)
|
||||
.catch((err) => Toast.error(err));
|
||||
} else {
|
||||
const updateVariables = variables as GQL.GalleryChapterUpdateInput;
|
||||
updateVariables.id = editingChapter!.id;
|
||||
galleryChapterUpdate({ variables: updateVariables })
|
||||
.then(onClose)
|
||||
.catch((err) => Toast.error(err));
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
if (!editingChapter) return;
|
||||
|
||||
galleryChapterDestroy({ variables: { id: editingChapter.id } })
|
||||
.then(onClose)
|
||||
.catch((err) => Toast.error(err));
|
||||
};
|
||||
|
||||
const values: IFormFields = {
|
||||
title: editingChapter?.title ?? "",
|
||||
imageIndex: editingChapter?.image_index ?? 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={values}
|
||||
onSubmit={onSubmit}
|
||||
validationSchema={schema}
|
||||
>
|
||||
{(formik) => (
|
||||
<FormikForm>
|
||||
<div>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<FormattedMessage id="title" />
|
||||
</Form.Label>
|
||||
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
placeholder={intl.formatMessage({ id: "title" })}
|
||||
{...formik.getFieldProps("title")}
|
||||
isInvalid={!!formik.getFieldMeta("title").error}
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{formik.getFieldMeta("title").error}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<FormattedMessage id="image_index" />
|
||||
</Form.Label>
|
||||
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
placeholder={intl.formatMessage({ id: "image_index" })}
|
||||
{...formik.getFieldProps("imageIndex")}
|
||||
isInvalid={!!formik.getFieldMeta("imageIndex").error}
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{formik.getFieldMeta("imageIndex").error}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
</div>
|
||||
<div className="buttons-container row">
|
||||
<div className="col d-flex">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={
|
||||
(editingChapter && !formik.dirty) ||
|
||||
!isEqual(formik.errors, {})
|
||||
}
|
||||
onClick={() => formik.submitForm()}
|
||||
>
|
||||
<FormattedMessage id="actions.save" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-2"
|
||||
>
|
||||
<FormattedMessage id="actions.cancel" />
|
||||
</Button>
|
||||
{editingChapter && (
|
||||
<Button
|
||||
variant="danger"
|
||||
className="ml-auto"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
<FormattedMessage id="actions.delete" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormikForm>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import Mousetrap from "mousetrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { ChapterEntries } from "./ChapterEntry";
|
||||
import { GalleryChapterForm } from "./GalleryChapterForm";
|
||||
|
||||
interface IGalleryChapterPanelProps {
|
||||
gallery: GQL.GalleryDataFragment;
|
||||
isVisible: boolean;
|
||||
onClickChapter: (index: number) => void;
|
||||
}
|
||||
|
||||
export const GalleryChapterPanel: React.FC<IGalleryChapterPanelProps> = (
|
||||
props: IGalleryChapterPanelProps
|
||||
) => {
|
||||
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
|
||||
const [editingChapter, setEditingChapter] =
|
||||
useState<GQL.GalleryChapterDataFragment>();
|
||||
|
||||
// set up hotkeys
|
||||
useEffect(() => {
|
||||
if (props.isVisible) {
|
||||
Mousetrap.bind("n", () => onOpenEditor());
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("n");
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
function onOpenEditor(chapter?: GQL.GalleryChapterDataFragment) {
|
||||
setIsEditorOpen(true);
|
||||
setEditingChapter(chapter ?? undefined);
|
||||
}
|
||||
|
||||
function onClickChapter(image_index: number) {
|
||||
props.onClickChapter(image_index);
|
||||
}
|
||||
|
||||
const closeEditor = () => {
|
||||
setEditingChapter(undefined);
|
||||
setIsEditorOpen(false);
|
||||
};
|
||||
|
||||
if (isEditorOpen)
|
||||
return (
|
||||
<GalleryChapterForm
|
||||
galleryID={props.gallery.id}
|
||||
editingChapter={editingChapter}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button onClick={() => onOpenEditor()}>
|
||||
<FormattedMessage id="actions.create_chapters" />
|
||||
</Button>
|
||||
<div className="container">
|
||||
<ChapterEntries
|
||||
galleryChapters={props.gallery.chapters}
|
||||
onClickChapter={onClickChapter}
|
||||
onEdit={onOpenEditor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GalleryChapterPanel;
|
||||
|
|
@ -104,6 +104,7 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> = ({
|
|||
extraOperations={otherOperations}
|
||||
persistState={PersistanceLevel.VIEW}
|
||||
persistanceKey="galleryimages"
|
||||
chapters={gallery.chapters}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ interface IProps {
|
|||
|
||||
const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
|
||||
const intl = useIntl();
|
||||
const showLightbox = useGalleryLightbox(gallery.id);
|
||||
const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters);
|
||||
|
||||
const coverFile = gallery?.cover?.files.length
|
||||
? gallery.cover.files[0]
|
||||
|
|
@ -37,12 +37,16 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
|
|||
? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")]
|
||||
: performerNames;
|
||||
|
||||
async function showLightboxStart() {
|
||||
showLightbox(0);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
className={`${CLASSNAME} ${CLASSNAME}-${orientation}`}
|
||||
onClick={showLightbox}
|
||||
onKeyPress={showLightbox}
|
||||
onClick={showLightboxStart}
|
||||
onKeyPress={showLightboxStart}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ interface IImageListImages {
|
|||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
|
||||
slideshowRunning: boolean;
|
||||
setSlideshowRunning: (running: boolean) => void;
|
||||
chapters?: GQL.GalleryChapterDataFragment[];
|
||||
}
|
||||
|
||||
const ImageListImages: React.FC<IImageListImages> = ({
|
||||
|
|
@ -116,23 +117,30 @@ const ImageListImages: React.FC<IImageListImages> = ({
|
|||
onSelectChange,
|
||||
slideshowRunning,
|
||||
setSlideshowRunning,
|
||||
chapters = [],
|
||||
}) => {
|
||||
const handleLightBoxPage = useCallback(
|
||||
(direction: number) => {
|
||||
if (direction === -1) {
|
||||
(props: { direction?: number; page?: number }) => {
|
||||
const { direction, page: newPage } = props;
|
||||
|
||||
if (direction !== undefined) {
|
||||
if (direction < 0) {
|
||||
if (filter.currentPage === 1) {
|
||||
onChangePage(pageCount);
|
||||
} else {
|
||||
onChangePage(filter.currentPage - 1);
|
||||
onChangePage(filter.currentPage + direction);
|
||||
}
|
||||
} else if (direction === 1) {
|
||||
} else if (direction > 0) {
|
||||
if (filter.currentPage === pageCount) {
|
||||
// return to the first page
|
||||
onChangePage(1);
|
||||
} else {
|
||||
onChangePage(filter.currentPage + 1);
|
||||
onChangePage(filter.currentPage + direction);
|
||||
}
|
||||
}
|
||||
} else if (newPage !== undefined) {
|
||||
onChangePage(newPage);
|
||||
}
|
||||
},
|
||||
[onChangePage, filter.currentPage, pageCount]
|
||||
);
|
||||
|
|
@ -146,7 +154,9 @@ const ImageListImages: React.FC<IImageListImages> = ({
|
|||
images,
|
||||
showNavigation: false,
|
||||
pageCallback: pageCount > 1 ? handleLightBoxPage : undefined,
|
||||
pageHeader: `Page ${filter.currentPage} / ${pageCount}`,
|
||||
page: filter.currentPage,
|
||||
pages: pageCount,
|
||||
pageSize: filter.itemsPerPage,
|
||||
slideshowEnabled: slideshowRunning,
|
||||
onClose: handleClose,
|
||||
};
|
||||
|
|
@ -154,12 +164,19 @@ const ImageListImages: React.FC<IImageListImages> = ({
|
|||
images,
|
||||
pageCount,
|
||||
filter.currentPage,
|
||||
filter.itemsPerPage,
|
||||
slideshowRunning,
|
||||
handleClose,
|
||||
handleLightBoxPage,
|
||||
]);
|
||||
|
||||
const showLightbox = useLightbox(lightboxState);
|
||||
const showLightbox = useLightbox(
|
||||
lightboxState,
|
||||
filter.sortBy === "path" &&
|
||||
filter.sortDirection === GQL.SortDirectionEnum.Asc
|
||||
? chapters
|
||||
: []
|
||||
);
|
||||
|
||||
const handleImageOpen = useCallback(
|
||||
(index) => {
|
||||
|
|
@ -273,6 +290,7 @@ interface IImageList {
|
|||
persistanceKey?: string;
|
||||
alterQuery?: boolean;
|
||||
extraOperations?: IItemListOperation<GQL.FindImagesQueryResult>[];
|
||||
chapters?: GQL.GalleryChapterDataFragment[];
|
||||
}
|
||||
|
||||
export const ImageList: React.FC<IImageList> = ({
|
||||
|
|
@ -281,6 +299,7 @@ export const ImageList: React.FC<IImageList> = ({
|
|||
persistanceKey,
|
||||
alterQuery,
|
||||
extraOperations,
|
||||
chapters = [],
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
|
@ -386,6 +405,7 @@ export const ImageList: React.FC<IImageList> = ({
|
|||
selectedIds={selectedIds}
|
||||
slideshowRunning={slideshowRunning}
|
||||
setSlideshowRunning={setSlideshowRunning}
|
||||
chapters={chapters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -752,6 +752,27 @@ export const mutateGallerySetPrimaryFile = (id: string, fileID: string) =>
|
|||
update: deleteCache(galleryMutationImpactedQueries),
|
||||
});
|
||||
|
||||
const galleryChapterMutationImpactedQueries = [
|
||||
GQL.FindGalleryDocument,
|
||||
GQL.FindGalleriesDocument,
|
||||
];
|
||||
|
||||
export const useGalleryChapterCreate = () =>
|
||||
GQL.useGalleryChapterCreateMutation({
|
||||
refetchQueries: getQueryNames([GQL.FindGalleryDocument]),
|
||||
update: deleteCache(galleryChapterMutationImpactedQueries),
|
||||
});
|
||||
export const useGalleryChapterUpdate = () =>
|
||||
GQL.useGalleryChapterUpdateMutation({
|
||||
refetchQueries: getQueryNames([GQL.FindGalleryDocument]),
|
||||
update: deleteCache(galleryChapterMutationImpactedQueries),
|
||||
});
|
||||
export const useGalleryChapterDestroy = () =>
|
||||
GQL.useGalleryChapterDestroyMutation({
|
||||
refetchQueries: getQueryNames([GQL.FindGalleryDocument]),
|
||||
update: deleteCache(galleryChapterMutationImpactedQueries),
|
||||
});
|
||||
|
||||
export const studioMutationImpactedQueries = [
|
||||
GQL.FindStudiosDocument,
|
||||
GQL.FindSceneDocument,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
##### 💥 Note: The cache directory is now required if using HLS/DASH streaming. Please set the cache directory in the System Settings page.
|
||||
|
||||
### ✨ New Features
|
||||
* Added Chapters to Galleries. ([#3289](https://github.com/stashapp/stash/pull/3289))
|
||||
* Added button to tagger scene cards to view scene sprite. ([#3536](https://github.com/stashapp/stash/pull/3536))
|
||||
* Added hardware acceleration support (for a limited number of encoders) for transcoding. ([#3419](https://github.com/stashapp/stash/pull/3419))
|
||||
* Added support for DASH streaming. ([#3275](https://github.com/stashapp/stash/pull/3275))
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Popover,
|
||||
Form,
|
||||
Row,
|
||||
Dropdown,
|
||||
} from "react-bootstrap";
|
||||
import cx from "classnames";
|
||||
import Mousetrap from "mousetrap";
|
||||
|
|
@ -30,7 +31,7 @@ import {
|
|||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useInterfaceLocalForage } from "../LocalForage";
|
||||
import { imageLightboxDisplayModeIntlMap } from "src/core/enums";
|
||||
import { ILightboxImage } from "./types";
|
||||
import { ILightboxImage, IChapter } from "./types";
|
||||
import {
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
|
|
@ -42,6 +43,7 @@ import {
|
|||
faPlay,
|
||||
faSearchMinus,
|
||||
faTimes,
|
||||
faBars,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||
import { useDebounce } from "../debounce";
|
||||
|
|
@ -49,6 +51,8 @@ import { useDebounce } from "../debounce";
|
|||
const CLASSNAME = "Lightbox";
|
||||
const CLASSNAME_HEADER = `${CLASSNAME}-header`;
|
||||
const CLASSNAME_LEFT_SPACER = `${CLASSNAME_HEADER}-left-spacer`;
|
||||
const CLASSNAME_CHAPTERS = `${CLASSNAME_HEADER}-chapters`;
|
||||
const CLASSNAME_CHAPTER_BUTTON = `${CLASSNAME_HEADER}-chapter-button`;
|
||||
const CLASSNAME_INDICATOR = `${CLASSNAME_HEADER}-indicator`;
|
||||
const CLASSNAME_OPTIONS = `${CLASSNAME_HEADER}-options`;
|
||||
const CLASSNAME_OPTIONS_ICON = `${CLASSNAME_OPTIONS}-icon`;
|
||||
|
|
@ -76,8 +80,11 @@ interface IProps {
|
|||
initialIndex?: number;
|
||||
showNavigation: boolean;
|
||||
slideshowEnabled?: boolean;
|
||||
pageHeader?: string;
|
||||
pageCallback?: (direction: number) => void;
|
||||
page?: number;
|
||||
pages?: number;
|
||||
pageSize?: number;
|
||||
pageCallback?: (props: { direction?: number; page?: number }) => void;
|
||||
chapters?: IChapter[];
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -88,12 +95,16 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
initialIndex = 0,
|
||||
showNavigation,
|
||||
slideshowEnabled = false,
|
||||
pageHeader,
|
||||
page,
|
||||
pages,
|
||||
pageSize: pageSize = 40,
|
||||
pageCallback,
|
||||
chapters = [],
|
||||
hide,
|
||||
}) => {
|
||||
const [updateImage] = useImageUpdate();
|
||||
|
||||
// zero-based
|
||||
const [index, setIndex] = useState<number | null>(null);
|
||||
const [movingLeft, setMovingLeft] = useState(false);
|
||||
const oldIndex = useRef<number | null>(null);
|
||||
|
|
@ -101,6 +112,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
const [isSwitchingPage, setIsSwitchingPage] = useState(true);
|
||||
const [isFullscreen, setFullscreen] = useState(false);
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const [showChapters, setShowChapters] = useState(false);
|
||||
const [imagesLoaded, setImagesLoaded] = useState(0);
|
||||
const [navOffset, setNavOffset] = useState<React.CSSProperties | undefined>();
|
||||
|
||||
|
|
@ -310,12 +322,13 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
(isUserAction = true) => {
|
||||
if (isSwitchingPage || index === -1) return;
|
||||
|
||||
setShowChapters(false);
|
||||
setMovingLeft(true);
|
||||
|
||||
if (index === 0) {
|
||||
// go to next page, or loop back if no callback is set
|
||||
if (pageCallback) {
|
||||
pageCallback(-1);
|
||||
pageCallback({ direction: -1 });
|
||||
setIndex(-1);
|
||||
oldImages.current = images;
|
||||
setIsSwitchingPage(true);
|
||||
|
|
@ -334,11 +347,12 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
if (isSwitchingPage) return;
|
||||
|
||||
setMovingLeft(false);
|
||||
setShowChapters(false);
|
||||
|
||||
if (index === images.length - 1) {
|
||||
// go to preview page, or loop back if no callback is set
|
||||
if (pageCallback) {
|
||||
pageCallback(1);
|
||||
pageCallback({ direction: 1 });
|
||||
oldImages.current = images;
|
||||
setIsSwitchingPage(true);
|
||||
setIndex(0);
|
||||
|
|
@ -449,6 +463,65 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
|
||||
const currentIndex = index === null ? initialIndex : index;
|
||||
|
||||
function gotoPage(imageIndex: number) {
|
||||
const indexInPage = (imageIndex - 1) % pageSize;
|
||||
if (pageCallback) {
|
||||
let jumppage = Math.floor(imageIndex / pageSize) + 1;
|
||||
if (page !== jumppage) {
|
||||
pageCallback({ page: jumppage });
|
||||
oldImages.current = images;
|
||||
setIsSwitchingPage(true);
|
||||
}
|
||||
}
|
||||
|
||||
setIndex(indexInPage);
|
||||
setShowChapters(false);
|
||||
}
|
||||
|
||||
function chapterHeader() {
|
||||
const imageNumber = (index ?? 0) + 1;
|
||||
const globalIndex = page
|
||||
? (page - 1) * pageSize + imageNumber
|
||||
: imageNumber;
|
||||
|
||||
let chapterTitle = "";
|
||||
chapters.forEach(function (chapter) {
|
||||
if (chapter.image_index > globalIndex) {
|
||||
return;
|
||||
}
|
||||
chapterTitle = chapter.title;
|
||||
});
|
||||
|
||||
return chapterTitle ?? "";
|
||||
}
|
||||
|
||||
const renderChapterMenu = () => {
|
||||
if (chapters.length <= 0) return;
|
||||
|
||||
const popoverContent = chapters.map(({ id, title, image_index }) => (
|
||||
<Dropdown.Item key={id} onClick={() => gotoPage(image_index)}>
|
||||
{" "}
|
||||
{title}
|
||||
{title.length > 0 ? " - #" : "#"}
|
||||
{image_index}
|
||||
</Dropdown.Item>
|
||||
));
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
show={showChapters}
|
||||
onToggle={() => setShowChapters(!showChapters)}
|
||||
>
|
||||
<Dropdown.Toggle className={`minimal ${CLASSNAME_CHAPTER_BUTTON}`}>
|
||||
<Icon icon={showChapters ? faTimes : faBars} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className={`${CLASSNAME_CHAPTERS}`}>
|
||||
{popoverContent}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
// #2451: making OptionsForm an inline component means it
|
||||
// get re-rendered each time. This makes the text
|
||||
// field lose focus on input. Use function instead.
|
||||
|
|
@ -634,6 +707,14 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
const pageHeader =
|
||||
page && pages
|
||||
? intl.formatMessage(
|
||||
{ id: "dialogs.lightbox.page_header" },
|
||||
{ page, total: pages }
|
||||
)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={CLASSNAME}
|
||||
|
|
@ -642,9 +723,11 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
onClick={handleClose}
|
||||
>
|
||||
<div className={CLASSNAME_HEADER}>
|
||||
<div className={CLASSNAME_LEFT_SPACER} />
|
||||
<div className={CLASSNAME_LEFT_SPACER}>{renderChapterMenu()}</div>
|
||||
<div className={CLASSNAME_INDICATOR}>
|
||||
<span>{pageHeader}</span>
|
||||
<span>
|
||||
{chapterHeader()} {pageHeader}
|
||||
</span>
|
||||
{images.length > 1 ? (
|
||||
<b ref={indicatorRef}>{`${currentIndex + 1} / ${images.length}`}</b>
|
||||
) : undefined}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { Suspense, useCallback, useState } from "react";
|
||||
import { lazyComponent } from "src/utils/lazyComponent";
|
||||
import { ILightboxImage } from "./types";
|
||||
import { ILightboxImage, IChapter } from "./types";
|
||||
|
||||
const LightboxComponent = lazyComponent(() => import("./Lightbox"));
|
||||
|
||||
|
|
@ -10,8 +10,11 @@ export interface IState {
|
|||
isLoading: boolean;
|
||||
showNavigation: boolean;
|
||||
initialIndex?: number;
|
||||
pageCallback?: (direction: number) => void;
|
||||
pageHeader?: string;
|
||||
pageCallback?: (props: { direction?: number; page?: number }) => void;
|
||||
chapters?: IChapter[];
|
||||
page?: number;
|
||||
pages?: number;
|
||||
pageSize?: number;
|
||||
slideshowEnabled: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { LightboxContext, IState } from "./context";
|
||||
import { IChapter } from "./types";
|
||||
|
||||
export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
|
||||
export const useLightbox = (
|
||||
state: Partial<Omit<IState, "isVisible">>,
|
||||
chapters: IChapter[] = []
|
||||
) => {
|
||||
const { setLightboxState } = useContext(LightboxContext);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -10,7 +14,9 @@ export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
|
|||
images: state.images,
|
||||
showNavigation: state.showNavigation,
|
||||
pageCallback: state.pageCallback,
|
||||
pageHeader: state.pageHeader,
|
||||
page: state.page,
|
||||
pages: state.pages,
|
||||
pageSize: state.pageSize,
|
||||
slideshowEnabled: state.slideshowEnabled,
|
||||
onClose: state.onClose,
|
||||
});
|
||||
|
|
@ -19,7 +25,9 @@ export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
|
|||
state.images,
|
||||
state.showNavigation,
|
||||
state.pageCallback,
|
||||
state.pageHeader,
|
||||
state.page,
|
||||
state.pages,
|
||||
state.pageSize,
|
||||
state.slideshowEnabled,
|
||||
state.onClose,
|
||||
]);
|
||||
|
|
@ -30,14 +38,18 @@ export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
|
|||
initialIndex: index,
|
||||
isVisible: true,
|
||||
slideshowEnabled,
|
||||
page: state.page,
|
||||
pages: state.pages,
|
||||
pageSize: state.pageSize,
|
||||
chapters: chapters,
|
||||
});
|
||||
},
|
||||
[setLightboxState]
|
||||
[setLightboxState, state.page, state.pages, state.pageSize, chapters]
|
||||
);
|
||||
return show;
|
||||
};
|
||||
|
||||
export const useGalleryLightbox = (id: string) => {
|
||||
export const useGalleryLightbox = (id: string, chapters: IChapter[] = []) => {
|
||||
const { setLightboxState } = useContext(LightboxContext);
|
||||
|
||||
const pageSize = 40;
|
||||
|
|
@ -69,21 +81,27 @@ export const useGalleryLightbox = (id: string) => {
|
|||
}, [data?.findImages.count]);
|
||||
|
||||
const handleLightBoxPage = useCallback(
|
||||
(direction: number) => {
|
||||
if (direction === -1) {
|
||||
(props: { direction?: number; page?: number }) => {
|
||||
const { direction, page: newPage } = props;
|
||||
|
||||
if (direction !== undefined) {
|
||||
if (direction < 0) {
|
||||
if (page === 1) {
|
||||
setPage(pages);
|
||||
} else {
|
||||
setPage(page - 1);
|
||||
setPage(page + direction);
|
||||
}
|
||||
} else if (direction === 1) {
|
||||
} else if (direction > 0) {
|
||||
if (page === pages) {
|
||||
// return to the first page
|
||||
setPage(1);
|
||||
} else {
|
||||
setPage(page + 1);
|
||||
setPage(page + direction);
|
||||
}
|
||||
}
|
||||
} else if (newPage !== undefined) {
|
||||
setPage(newPage);
|
||||
}
|
||||
},
|
||||
[page, pages]
|
||||
);
|
||||
|
|
@ -95,25 +113,39 @@ export const useGalleryLightbox = (id: string) => {
|
|||
isVisible: true,
|
||||
images: data.findImages?.images ?? [],
|
||||
pageCallback: pages > 1 ? handleLightBoxPage : undefined,
|
||||
pageHeader: `Page ${page} / ${pages}`,
|
||||
page,
|
||||
pages,
|
||||
});
|
||||
}, [setLightboxState, data, handleLightBoxPage, page, pages]);
|
||||
|
||||
const show = () => {
|
||||
const show = (index: number = 0) => {
|
||||
if (index > pageSize) {
|
||||
setPage(Math.floor(index / pageSize) + 1);
|
||||
index = index % pageSize;
|
||||
} else {
|
||||
setPage(1);
|
||||
}
|
||||
if (data)
|
||||
setLightboxState({
|
||||
isLoading: false,
|
||||
isVisible: true,
|
||||
initialIndex: index,
|
||||
images: data.findImages?.images ?? [],
|
||||
pageCallback: pages > 1 ? handleLightBoxPage : undefined,
|
||||
pageHeader: `Page ${page} / ${pages}`,
|
||||
page,
|
||||
pages,
|
||||
pageSize,
|
||||
chapters: chapters,
|
||||
});
|
||||
else {
|
||||
setLightboxState({
|
||||
isLoading: true,
|
||||
isVisible: true,
|
||||
initialIndex: index,
|
||||
pageCallback: undefined,
|
||||
pageHeader: undefined,
|
||||
page: undefined,
|
||||
pageSize,
|
||||
chapters: chapters,
|
||||
});
|
||||
fetchGallery();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,10 +30,11 @@
|
|||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
|
||||
@media (max-width: 575px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-chapters {
|
||||
max-height: 90%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&-indicator {
|
||||
|
|
|
|||
|
|
@ -12,3 +12,9 @@ export interface ILightboxImage {
|
|||
o_counter?: GQL.Maybe<number>;
|
||||
paths: IImagePaths;
|
||||
}
|
||||
|
||||
export interface IChapter {
|
||||
id: string;
|
||||
title: string;
|
||||
image_index: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"confirm": "Confirm",
|
||||
"continue": "Continue",
|
||||
"create": "Create",
|
||||
"create_chapters": "Create Chapter",
|
||||
"create_entity": "Create {entityType}",
|
||||
"create_marker": "Create Marker",
|
||||
"created_entity": "Created {entity_type}: {entity_name}",
|
||||
|
|
@ -132,6 +133,7 @@
|
|||
"between_and": "and",
|
||||
"captions": "Captions",
|
||||
"career_length": "Career Length",
|
||||
"chapters": "Chapters",
|
||||
"component_tagger": {
|
||||
"config": {
|
||||
"active_instance": "Active stash-box instance:",
|
||||
|
|
@ -732,6 +734,7 @@
|
|||
"original": "Original"
|
||||
},
|
||||
"options": "Options",
|
||||
"page_header": "Page {page} / {total}",
|
||||
"reset_zoom_on_nav": "Reset zoom level when changing image",
|
||||
"scale_up": {
|
||||
"description": "Scale smaller images up to fill screen",
|
||||
|
|
@ -857,6 +860,7 @@
|
|||
},
|
||||
"empty_server": "Add some scenes to your server to view recommendations on this page.",
|
||||
"errors": {
|
||||
"image_index_greater_than_zero": "Image index must be greater than 0",
|
||||
"something_went_wrong": "Something went wrong.",
|
||||
"lazy_component_error_help": "If you recently upgraded Stash, please reload the page or clear your browser cache."
|
||||
},
|
||||
|
|
@ -908,12 +912,14 @@
|
|||
"uploading": "Uploading script"
|
||||
},
|
||||
"hasMarkers": "Has Markers",
|
||||
"hasChapters": "Has Chapters",
|
||||
"height": "Height",
|
||||
"height_cm": "Height (cm)",
|
||||
"help": "Help",
|
||||
"ignore_auto_tag": "Ignore Auto Tag",
|
||||
"image": "Image",
|
||||
"image_count": "Image Count",
|
||||
"image_index": "Image #",
|
||||
"images": "Images",
|
||||
"include_parent_tags": "Include parent tags",
|
||||
"include_sub_studios": "Include subsidiary studios",
|
||||
|
|
@ -1172,7 +1178,8 @@
|
|||
"started_auto_tagging": "Started auto tagging",
|
||||
"started_generating": "Started generating",
|
||||
"started_importing": "Started importing",
|
||||
"updated_entity": "Updated {entity}"
|
||||
"updated_entity": "Updated {entity}",
|
||||
"image_index_too_large": "Error: Image index is larger than the number of images in the Gallery"
|
||||
},
|
||||
"total": "Total",
|
||||
"true": "True",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import { OrganizedCriterion } from "./organized";
|
||||
import { FavoriteCriterion, PerformerFavoriteCriterion } from "./favorite";
|
||||
import { HasMarkersCriterion } from "./has-markers";
|
||||
import { HasChaptersCriterion } from "./has-chapters";
|
||||
import {
|
||||
PerformerIsMissingCriterionOption,
|
||||
ImageIsMissingCriterionOption,
|
||||
|
|
@ -113,6 +114,8 @@ export function makeCriteria(
|
|||
return new FavoriteCriterion();
|
||||
case "hasMarkers":
|
||||
return new HasMarkersCriterion();
|
||||
case "hasChapters":
|
||||
return new HasChaptersCriterion();
|
||||
case "sceneIsMissing":
|
||||
return new IsMissingCriterion(SceneIsMissingCriterionOption);
|
||||
case "imageIsMissing":
|
||||
|
|
|
|||
18
ui/v2.5/src/models/list-filter/criteria/has-chapters.ts
Normal file
18
ui/v2.5/src/models/list-filter/criteria/has-chapters.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { CriterionOption, StringCriterion } from "./criterion";
|
||||
|
||||
export const HasChaptersCriterionOption = new CriterionOption({
|
||||
messageID: "hasChapters",
|
||||
type: "hasChapters",
|
||||
parameterName: "has_chapters",
|
||||
options: [true.toString(), false.toString()],
|
||||
});
|
||||
|
||||
export class HasChaptersCriterion extends StringCriterion {
|
||||
constructor() {
|
||||
super(HasChaptersCriterionOption);
|
||||
}
|
||||
|
||||
protected toCriterionInput(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import { PerformerFavoriteCriterionOption } from "./criteria/favorite";
|
||||
import { GalleryIsMissingCriterionOption } from "./criteria/is-missing";
|
||||
import { OrganizedCriterionOption } from "./criteria/organized";
|
||||
import { HasChaptersCriterionOption } from "./criteria/has-chapters";
|
||||
import { PerformersCriterionOption } from "./criteria/performers";
|
||||
import { AverageResolutionCriterionOption } from "./criteria/resolution";
|
||||
import { StudiosCriterionOption } from "./criteria/studios";
|
||||
|
|
@ -53,6 +54,7 @@ const criterionOptions = [
|
|||
AverageResolutionCriterionOption,
|
||||
GalleryIsMissingCriterionOption,
|
||||
TagsCriterionOption,
|
||||
HasChaptersCriterionOption,
|
||||
createStringCriterionOption("tag_count"),
|
||||
PerformerTagsCriterionOption,
|
||||
PerformersCriterionOption,
|
||||
|
|
|
|||
|
|
@ -177,4 +177,5 @@ export type CriterionType =
|
|||
| "scene_updated_at"
|
||||
| "description"
|
||||
| "scene_code"
|
||||
| "disambiguation";
|
||||
| "disambiguation"
|
||||
| "hasChapters";
|
||||
|
|
|
|||
Loading…
Reference in a new issue