mirror of
https://github.com/stashapp/stash.git
synced 2026-06-11 18:35:26 -05:00
Compare commits
13 Commits
localisati
...
localizati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a1884eb32 | ||
|
|
c874bd560e | ||
|
|
c7e1c3da69 | ||
|
|
3b8f6bd94c | ||
|
|
d8448ba37e | ||
|
|
ead0c7fe07 | ||
|
|
660feabced | ||
|
|
e52ac14d56 | ||
|
|
b77abd64e2 | ||
|
|
ed58d18334 | ||
|
|
c522e54805 | ||
|
|
5734ee43ff | ||
|
|
c9f0dba62f |
@@ -6,11 +6,14 @@ type Fingerprint {
|
||||
type Folder {
|
||||
id: ID!
|
||||
path: String!
|
||||
basename: String!
|
||||
|
||||
parent_folder_id: ID @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder
|
||||
"Returns all parent folders in order from immediate parent to top-level"
|
||||
parent_folders: [Folder!]!
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
|
||||
@@ -177,6 +177,8 @@ input PerformerFilterType {
|
||||
tag_count: IntCriterionInput
|
||||
"Filter by scene count"
|
||||
scene_count: IntCriterionInput
|
||||
"Filter by marker count (via scene)"
|
||||
marker_count: IntCriterionInput
|
||||
"Filter by image count"
|
||||
image_count: IntCriterionInput
|
||||
"Filter by gallery count"
|
||||
@@ -220,6 +222,8 @@ input PerformerFilterType {
|
||||
galleries_filter: GalleryFilterType
|
||||
"Filter by related tags that meet this criteria"
|
||||
tags_filter: TagFilterType
|
||||
"Filter by related scene markers (via scene) that meet this criteria"
|
||||
markers_filter: SceneMarkerFilterType
|
||||
"Filter by creation time"
|
||||
created_at: TimestampCriterionInput
|
||||
"Filter by last update time"
|
||||
@@ -684,6 +688,8 @@ input TagFilterType {
|
||||
performers_filter: PerformerFilterType
|
||||
"Filter by related studios that meet this criteria"
|
||||
studios_filter: StudioFilterType
|
||||
"Filter by related scene markers that meet this criteria"
|
||||
markers_filter: SceneMarkerFilterType
|
||||
|
||||
"Filter by creation time"
|
||||
created_at: TimestampCriterionInput
|
||||
@@ -816,6 +822,7 @@ input FolderFilterType {
|
||||
NOT: FolderFilterType
|
||||
|
||||
path: StringCriterionInput
|
||||
basename: StringCriterionInput
|
||||
|
||||
parent_folder: HierarchicalMultiCriterionInput
|
||||
zip_file: MultiCriterionInput
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group
|
||||
//go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File
|
||||
//go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder
|
||||
//go:generate go run github.com/vektah/dataloaden FolderParentFolderIDsLoader github.com/stashapp/stash/pkg/models.FolderID []github.com/stashapp/stash/pkg/models.FolderID
|
||||
//go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
|
||||
//go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
|
||||
//go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
|
||||
@@ -65,12 +66,16 @@ type Loaders struct {
|
||||
StudioByID *StudioLoader
|
||||
StudioCustomFields *CustomFieldsLoader
|
||||
|
||||
TagByID *TagLoader
|
||||
TagCustomFields *CustomFieldsLoader
|
||||
TagByID *TagLoader
|
||||
TagCustomFields *CustomFieldsLoader
|
||||
|
||||
GroupByID *GroupLoader
|
||||
GroupCustomFields *CustomFieldsLoader
|
||||
FileByID *FileLoader
|
||||
FolderByID *FolderLoader
|
||||
|
||||
FileByID *FileLoader
|
||||
|
||||
FolderByID *FolderLoader
|
||||
FolderParentFolderIDs *FolderParentFolderIDsLoader
|
||||
}
|
||||
|
||||
type Middleware struct {
|
||||
@@ -161,6 +166,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
|
||||
maxBatch: maxBatch,
|
||||
fetch: m.fetchFolders(ctx),
|
||||
},
|
||||
FolderParentFolderIDs: &FolderParentFolderIDsLoader{
|
||||
wait: wait,
|
||||
maxBatch: maxBatch,
|
||||
fetch: m.fetchFoldersParentFolderIDs(ctx),
|
||||
},
|
||||
SceneFiles: &SceneFileIDsLoader{
|
||||
wait: wait,
|
||||
maxBatch: maxBatch,
|
||||
@@ -406,6 +416,17 @@ func (m Middleware) fetchFolders(ctx context.Context) func(keys []models.FolderI
|
||||
}
|
||||
}
|
||||
|
||||
func (m Middleware) fetchFoldersParentFolderIDs(ctx context.Context) func(keys []models.FolderID) ([][]models.FolderID, []error) {
|
||||
return func(keys []models.FolderID) (ret [][]models.FolderID, errs []error) {
|
||||
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
ret, err = m.Repository.Folder.GetManyParentFolderIDs(ctx, keys)
|
||||
return err
|
||||
})
|
||||
return ret, toErrorSlice(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) {
|
||||
return func(keys []int) (ret [][]models.FileID, errs []error) {
|
||||
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
|
||||
225
internal/api/loaders/folderparentfolderidsloader_gen.go
Normal file
225
internal/api/loaders/folderparentfolderidsloader_gen.go
Normal file
@@ -0,0 +1,225 @@
|
||||
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
|
||||
|
||||
package loaders
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
// FolderParentFolderIDsLoaderConfig captures the config to create a new FolderParentFolderIDsLoader
|
||||
type FolderParentFolderIDsLoaderConfig struct {
|
||||
// Fetch is a method that provides the data for the loader
|
||||
Fetch func(keys []models.FolderID) ([][]models.FolderID, []error)
|
||||
|
||||
// Wait is how long wait before sending a batch
|
||||
Wait time.Duration
|
||||
|
||||
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
|
||||
MaxBatch int
|
||||
}
|
||||
|
||||
// NewFolderParentFolderIDsLoader creates a new FolderParentFolderIDsLoader given a fetch, wait, and maxBatch
|
||||
func NewFolderParentFolderIDsLoader(config FolderParentFolderIDsLoaderConfig) *FolderParentFolderIDsLoader {
|
||||
return &FolderParentFolderIDsLoader{
|
||||
fetch: config.Fetch,
|
||||
wait: config.Wait,
|
||||
maxBatch: config.MaxBatch,
|
||||
}
|
||||
}
|
||||
|
||||
// FolderParentFolderIDsLoader batches and caches requests
|
||||
type FolderParentFolderIDsLoader struct {
|
||||
// this method provides the data for the loader
|
||||
fetch func(keys []models.FolderID) ([][]models.FolderID, []error)
|
||||
|
||||
// how long to done before sending a batch
|
||||
wait time.Duration
|
||||
|
||||
// this will limit the maximum number of keys to send in one batch, 0 = no limit
|
||||
maxBatch int
|
||||
|
||||
// INTERNAL
|
||||
|
||||
// lazily created cache
|
||||
cache map[models.FolderID][]models.FolderID
|
||||
|
||||
// the current batch. keys will continue to be collected until timeout is hit,
|
||||
// then everything will be sent to the fetch method and out to the listeners
|
||||
batch *folderParentFolderIDsLoaderBatch
|
||||
|
||||
// mutex to prevent races
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type folderParentFolderIDsLoaderBatch struct {
|
||||
keys []models.FolderID
|
||||
data [][]models.FolderID
|
||||
error []error
|
||||
closing bool
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Load a FolderID by key, batching and caching will be applied automatically
|
||||
func (l *FolderParentFolderIDsLoader) Load(key models.FolderID) ([]models.FolderID, error) {
|
||||
return l.LoadThunk(key)()
|
||||
}
|
||||
|
||||
// LoadThunk returns a function that when called will block waiting for a FolderID.
|
||||
// This method should be used if you want one goroutine to make requests to many
|
||||
// different data loaders without blocking until the thunk is called.
|
||||
func (l *FolderParentFolderIDsLoader) LoadThunk(key models.FolderID) func() ([]models.FolderID, error) {
|
||||
l.mu.Lock()
|
||||
if it, ok := l.cache[key]; ok {
|
||||
l.mu.Unlock()
|
||||
return func() ([]models.FolderID, error) {
|
||||
return it, nil
|
||||
}
|
||||
}
|
||||
if l.batch == nil {
|
||||
l.batch = &folderParentFolderIDsLoaderBatch{done: make(chan struct{})}
|
||||
}
|
||||
batch := l.batch
|
||||
pos := batch.keyIndex(l, key)
|
||||
l.mu.Unlock()
|
||||
|
||||
return func() ([]models.FolderID, error) {
|
||||
<-batch.done
|
||||
|
||||
var data []models.FolderID
|
||||
if pos < len(batch.data) {
|
||||
data = batch.data[pos]
|
||||
}
|
||||
|
||||
var err error
|
||||
// its convenient to be able to return a single error for everything
|
||||
if len(batch.error) == 1 {
|
||||
err = batch.error[0]
|
||||
} else if batch.error != nil {
|
||||
err = batch.error[pos]
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
l.mu.Lock()
|
||||
l.unsafeSet(key, data)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAll fetches many keys at once. It will be broken into appropriate sized
|
||||
// sub batches depending on how the loader is configured
|
||||
func (l *FolderParentFolderIDsLoader) LoadAll(keys []models.FolderID) ([][]models.FolderID, []error) {
|
||||
results := make([]func() ([]models.FolderID, error), len(keys))
|
||||
|
||||
for i, key := range keys {
|
||||
results[i] = l.LoadThunk(key)
|
||||
}
|
||||
|
||||
folderIDs := make([][]models.FolderID, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
folderIDs[i], errors[i] = thunk()
|
||||
}
|
||||
return folderIDs, errors
|
||||
}
|
||||
|
||||
// LoadAllThunk returns a function that when called will block waiting for a FolderIDs.
|
||||
// This method should be used if you want one goroutine to make requests to many
|
||||
// different data loaders without blocking until the thunk is called.
|
||||
func (l *FolderParentFolderIDsLoader) LoadAllThunk(keys []models.FolderID) func() ([][]models.FolderID, []error) {
|
||||
results := make([]func() ([]models.FolderID, error), len(keys))
|
||||
for i, key := range keys {
|
||||
results[i] = l.LoadThunk(key)
|
||||
}
|
||||
return func() ([][]models.FolderID, []error) {
|
||||
folderIDs := make([][]models.FolderID, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
folderIDs[i], errors[i] = thunk()
|
||||
}
|
||||
return folderIDs, errors
|
||||
}
|
||||
}
|
||||
|
||||
// Prime the cache with the provided key and value. If the key already exists, no change is made
|
||||
// and false is returned.
|
||||
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
|
||||
func (l *FolderParentFolderIDsLoader) Prime(key models.FolderID, value []models.FolderID) bool {
|
||||
l.mu.Lock()
|
||||
var found bool
|
||||
if _, found = l.cache[key]; !found {
|
||||
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var
|
||||
// and end up with the whole cache pointing to the same value.
|
||||
cpy := make([]models.FolderID, len(value))
|
||||
copy(cpy, value)
|
||||
l.unsafeSet(key, cpy)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
return !found
|
||||
}
|
||||
|
||||
// Clear the value at key from the cache, if it exists
|
||||
func (l *FolderParentFolderIDsLoader) Clear(key models.FolderID) {
|
||||
l.mu.Lock()
|
||||
delete(l.cache, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *FolderParentFolderIDsLoader) unsafeSet(key models.FolderID, value []models.FolderID) {
|
||||
if l.cache == nil {
|
||||
l.cache = map[models.FolderID][]models.FolderID{}
|
||||
}
|
||||
l.cache[key] = value
|
||||
}
|
||||
|
||||
// keyIndex will return the location of the key in the batch, if its not found
|
||||
// it will add the key to the batch
|
||||
func (b *folderParentFolderIDsLoaderBatch) keyIndex(l *FolderParentFolderIDsLoader, key models.FolderID) int {
|
||||
for i, existingKey := range b.keys {
|
||||
if key == existingKey {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
pos := len(b.keys)
|
||||
b.keys = append(b.keys, key)
|
||||
if pos == 0 {
|
||||
go b.startTimer(l)
|
||||
}
|
||||
|
||||
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
|
||||
if !b.closing {
|
||||
b.closing = true
|
||||
l.batch = nil
|
||||
go b.end(l)
|
||||
}
|
||||
}
|
||||
|
||||
return pos
|
||||
}
|
||||
|
||||
func (b *folderParentFolderIDsLoaderBatch) startTimer(l *FolderParentFolderIDsLoader) {
|
||||
time.Sleep(l.wait)
|
||||
l.mu.Lock()
|
||||
|
||||
// we must have hit a batch limit and are already finalizing this batch
|
||||
if b.closing {
|
||||
l.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
l.batch = nil
|
||||
l.mu.Unlock()
|
||||
|
||||
b.end(l)
|
||||
}
|
||||
|
||||
func (b *folderParentFolderIDsLoaderBatch) end(l *FolderParentFolderIDsLoader) {
|
||||
b.data, b.error = l.fetch(b.keys)
|
||||
close(b.done)
|
||||
}
|
||||
@@ -2,11 +2,16 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/stashapp/stash/internal/api/loaders"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func (r *folderResolver) Basename(ctx context.Context, obj *models.Folder) (string, error) {
|
||||
return filepath.Base(obj.Path), nil
|
||||
}
|
||||
|
||||
func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (*models.Folder, error) {
|
||||
if obj.ParentFolderID == nil {
|
||||
return nil, nil
|
||||
@@ -15,6 +20,17 @@ func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (
|
||||
return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID)
|
||||
}
|
||||
|
||||
func (r *folderResolver) ParentFolders(ctx context.Context, obj *models.Folder) ([]*models.Folder, error) {
|
||||
ids, err := loaders.From(ctx).FolderParentFolderIDs.Load(obj.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids)
|
||||
return ret, firstError(errs)
|
||||
}
|
||||
|
||||
func (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) {
|
||||
return zipFileResolver(ctx, obj.ZipFileID)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/stashapp/stash/internal/desktop"
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
@@ -19,7 +20,7 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
fileStore := r.repository.File
|
||||
folderStore := r.repository.Folder
|
||||
mover := file.NewMover(fileStore, folderStore)
|
||||
mover := file.NewMover(fileStore, folderStore, manager.GetInstance().Config.GetStashPaths().Paths())
|
||||
mover.RegisterHooks(ctx)
|
||||
|
||||
var (
|
||||
@@ -57,13 +58,14 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
|
||||
folderPath := *input.DestinationFolder
|
||||
|
||||
// ensure folder path is within the library
|
||||
if err := r.validateFolderPath(folderPath); err != nil {
|
||||
stashPaths := manager.GetInstance().Config.GetStashPaths()
|
||||
if err := r.validateFolderPath(stashPaths, folderPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get or create folder hierarchy
|
||||
var err error
|
||||
folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath)
|
||||
folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath, stashPaths.Paths())
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting or creating folder hierarchy: %w", err)
|
||||
}
|
||||
@@ -112,8 +114,7 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) validateFolderPath(folderPath string) error {
|
||||
paths := manager.GetInstance().Config.GetStashPaths()
|
||||
func (r *mutationResolver) validateFolderPath(paths config.StashConfigs, folderPath string) error {
|
||||
if l := paths.GetStashFromDirPath(folderPath); l == nil {
|
||||
return fmt.Errorf("folder path %s must be within a stash library path", folderPath)
|
||||
}
|
||||
|
||||
@@ -38,3 +38,12 @@ func (s StashConfigs) GetStashFromDirPath(dirPath string) *StashConfig {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s StashConfigs) Paths() []string {
|
||||
paths := make([]string, len(s))
|
||||
for i, c := range s {
|
||||
// #6618 - clean the path to ensure comparison works correctly
|
||||
paths[i] = filepath.Clean(c.Path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
@@ -123,7 +123,8 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error
|
||||
ZipFileExtensions: cfg.GetGalleryExtensions(),
|
||||
// ScanFilters is set in ScanJob.Execute
|
||||
// HandlerRequiredFilters is set in ScanJob.Execute
|
||||
Rescan: input.Rescan,
|
||||
RootPaths: cfg.GetStashPaths().Paths(),
|
||||
Rescan: input.Rescan,
|
||||
}
|
||||
|
||||
scanJob := ScanJob{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,8 +13,9 @@ import (
|
||||
)
|
||||
|
||||
// GetOrCreateFolderHierarchy gets the folder for the given path, or creates a folder hierarchy for the given path if one if no existing folder is found.
|
||||
// Does not create any folders in the file system
|
||||
func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string) (*models.Folder, error) {
|
||||
// Creates folder entries for each level of the hierarchy that doesn't already exist, up to the provided root paths.
|
||||
// Does not create any folders in the file system.
|
||||
func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string, rootPaths []string) (*models.Folder, error) {
|
||||
// get or create folder hierarchy
|
||||
// assume case sensitive when searching for the folder
|
||||
const caseSensitive = true
|
||||
@@ -23,17 +25,33 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat
|
||||
}
|
||||
|
||||
if folder == nil {
|
||||
parentPath := filepath.Dir(path)
|
||||
parent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var parentID *models.FolderID
|
||||
|
||||
if !slices.Contains(rootPaths, path) {
|
||||
parentPath := filepath.Dir(path)
|
||||
|
||||
// safety check - don't allow parent path to be the same as the current path,
|
||||
// otherwise we could end up in an infinite loop
|
||||
if parentPath == path {
|
||||
// #6618 - log a warning and return nil for the parent ID,
|
||||
// which will cause the folder to be created with no parent
|
||||
logger.Warnf("parent path is the same as the current path: %s", path)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath, rootPaths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentID = &parent.ID
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
folder = &models.Folder{
|
||||
Path: path,
|
||||
ParentFolderID: &parent.ID,
|
||||
ParentFolderID: parentID,
|
||||
DirEntry: models.DirEntry{
|
||||
// leave mod time empty for now - it will be updated when the folder is scanned
|
||||
},
|
||||
@@ -41,6 +59,8 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
logger.Infof("%s doesn't exist. Creating new folder entry...", path)
|
||||
|
||||
if err = fc.Create(ctx, folder); err != nil {
|
||||
return nil, fmt.Errorf("creating folder %s: %w", path, err)
|
||||
}
|
||||
@@ -49,12 +69,18 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat
|
||||
return folder, nil
|
||||
}
|
||||
|
||||
func transferZipHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, files models.FileFinderUpdater, zipFileID models.FileID, oldPath string, newPath string) error {
|
||||
if err := transferZipFolderHierarchy(ctx, folderStore, zipFileID, oldPath, newPath); err != nil {
|
||||
type zipHierarchyMover struct {
|
||||
folderStore models.FolderReaderWriter
|
||||
files models.FileFinderUpdater
|
||||
rootPaths []string
|
||||
}
|
||||
|
||||
func (m zipHierarchyMover) transferZipHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error {
|
||||
if err := m.transferZipFolderHierarchy(ctx, zipFileID, oldPath, newPath); err != nil {
|
||||
return fmt.Errorf("moving folder hierarchy for file %s: %w", oldPath, err)
|
||||
}
|
||||
|
||||
if err := transferZipFileEntries(ctx, folderStore, files, zipFileID, oldPath, newPath); err != nil {
|
||||
if err := m.transferZipFileEntries(ctx, zipFileID, oldPath, newPath); err != nil {
|
||||
return fmt.Errorf("moving zip file contents for file %s: %w", oldPath, err)
|
||||
}
|
||||
|
||||
@@ -63,8 +89,8 @@ func transferZipHierarchy(ctx context.Context, folderStore models.FolderReaderWr
|
||||
|
||||
// transferZipFolderHierarchy creates the folder hierarchy for zipFileID under newPath, and removes
|
||||
// ZipFileID from folders under oldPath.
|
||||
func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, zipFileID models.FileID, oldPath string, newPath string) error {
|
||||
zipFolders, err := folderStore.FindByZipFileID(ctx, zipFileID)
|
||||
func (m zipHierarchyMover) transferZipFolderHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error {
|
||||
zipFolders, err := m.folderStore.FindByZipFileID(ctx, zipFileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -83,7 +109,7 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe
|
||||
}
|
||||
newZfPath := filepath.Join(newPath, relZfPath)
|
||||
|
||||
newFolder, err := GetOrCreateFolderHierarchy(ctx, folderStore, newZfPath)
|
||||
newFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfPath, m.rootPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -91,14 +117,14 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe
|
||||
// add ZipFileID to new folder
|
||||
logger.Debugf("adding zip file %s to folder %s", zipFileID, newFolder.Path)
|
||||
newFolder.ZipFileID = &zipFileID
|
||||
if err = folderStore.Update(ctx, newFolder); err != nil {
|
||||
if err = m.folderStore.Update(ctx, newFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove ZipFileID from old folder
|
||||
logger.Debugf("removing zip file %s from folder %s", zipFileID, oldFolder.Path)
|
||||
oldFolder.ZipFileID = nil
|
||||
if err = folderStore.Update(ctx, oldFolder); err != nil {
|
||||
if err = m.folderStore.Update(ctx, oldFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -106,9 +132,9 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe
|
||||
return nil
|
||||
}
|
||||
|
||||
func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCreator, files models.FileFinderUpdater, zipFileID models.FileID, oldPath, newPath string) error {
|
||||
func (m zipHierarchyMover) transferZipFileEntries(ctx context.Context, zipFileID models.FileID, oldPath, newPath string) error {
|
||||
// move contained files if file is a zip file
|
||||
zipFiles, err := files.FindByZipFileID(ctx, zipFileID)
|
||||
zipFiles, err := m.files.FindByZipFileID(ctx, zipFileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding contained files in file %s: %w", oldPath, err)
|
||||
}
|
||||
@@ -129,7 +155,7 @@ func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCrea
|
||||
newZfDir := filepath.Join(newPath, relZfDir)
|
||||
|
||||
// folder should have been created by transferZipFolderHierarchy
|
||||
newZfFolder, err := GetOrCreateFolderHierarchy(ctx, folders, newZfDir)
|
||||
newZfFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfDir, m.rootPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting or creating folder hierarchy: %w", err)
|
||||
}
|
||||
@@ -137,7 +163,7 @@ func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCrea
|
||||
// update file parent folder
|
||||
zfBase.ParentFolderID = newZfFolder.ID
|
||||
logger.Debugf("moving %s to folder %s", zfBase.Path, newZfFolder.Path)
|
||||
if err := files.Update(ctx, zf); err != nil {
|
||||
if err := m.files.Update(ctx, zf); err != nil {
|
||||
return fmt.Errorf("updating file %s: %w", oldZfPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +45,12 @@ type Mover struct {
|
||||
|
||||
moved map[string]string
|
||||
foldersCreated []string
|
||||
|
||||
// needed for creating folder hierarchy when moving zip file entries
|
||||
rootPaths []string
|
||||
}
|
||||
|
||||
func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReaderWriter) *Mover {
|
||||
func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReaderWriter, rootPaths []string) *Mover {
|
||||
return &Mover{
|
||||
Files: fileStore,
|
||||
Folders: folderStore,
|
||||
@@ -55,6 +58,7 @@ func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReade
|
||||
renamerRemoverImpl: newRenamerRemoverImpl(),
|
||||
mkDirFn: os.Mkdir,
|
||||
},
|
||||
rootPaths: rootPaths,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +91,13 @@ func (m *Mover) Move(ctx context.Context, f models.File, folder *models.Folder,
|
||||
return fmt.Errorf("file %s already exists", newPath)
|
||||
}
|
||||
|
||||
if err := transferZipHierarchy(ctx, m.Folders, m.Files, fBase.ID, oldPath, newPath); err != nil {
|
||||
zipMover := zipHierarchyMover{
|
||||
folderStore: m.Folders,
|
||||
files: m.Files,
|
||||
rootPaths: m.rootPaths,
|
||||
}
|
||||
|
||||
if err := zipMover.transferZipHierarchy(ctx, fBase.ID, oldPath, newPath); err != nil {
|
||||
return fmt.Errorf("moving folder hierarchy for file %s: %w", fBase.Path, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -60,6 +61,10 @@ type Scanner struct {
|
||||
// handlers are called after a file has been scanned.
|
||||
FileHandlers []Handler
|
||||
|
||||
// RootPaths form the top-level paths for the library.
|
||||
// Used to determine the root of the folder hierarchy when creating folders.
|
||||
RootPaths []string
|
||||
|
||||
// Rescan indicates whether files should be rescanned even if they haven't changed.
|
||||
Rescan bool
|
||||
|
||||
@@ -193,6 +198,10 @@ func (s *Scanner) ScanFolder(ctx context.Context, file ScannedFile) (*models.Fol
|
||||
return f, err
|
||||
}
|
||||
|
||||
func (s *Scanner) isRootPath(path string) bool {
|
||||
return path == "." || slices.Contains(s.RootPaths, path)
|
||||
}
|
||||
|
||||
func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) {
|
||||
renamed, err := s.handleFolderRename(ctx, file)
|
||||
if err != nil {
|
||||
@@ -212,18 +221,16 @@ func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Fo
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
dir := filepath.Dir(file.Path)
|
||||
if dir != "." {
|
||||
parentFolderID, err := s.getFolderID(ctx, dir)
|
||||
if !s.isRootPath(file.Path) {
|
||||
dir := filepath.Dir(file.Path)
|
||||
|
||||
// create full folder hierarchy if parent folder doesn't exist, and set parent folder ID
|
||||
parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, dir, s.RootPaths)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting parent folder %q: %w", dir, err)
|
||||
}
|
||||
|
||||
// if parent folder doesn't exist, assume it's a top-level folder
|
||||
// this may not be true if we're using multiple goroutines
|
||||
if parentFolderID != nil {
|
||||
toCreate.ParentFolderID = parentFolderID
|
||||
}
|
||||
toCreate.ParentFolderID = &parentFolder.ID
|
||||
}
|
||||
|
||||
txn.AddPostCommitHook(ctx, func(ctx context.Context) {
|
||||
@@ -312,6 +319,19 @@ func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing
|
||||
}
|
||||
}
|
||||
|
||||
// handle case where parent folder was not previously set
|
||||
if existing.ParentFolderID == nil && !s.isRootPath(existing.Path) {
|
||||
logger.Infof("Existing folder entry %q has no parent folder. Creating folder hierarchy and setting parent ID...", existing.Path)
|
||||
|
||||
// create full folder hierarchy if parent folder doesn't exist, and set parent folder ID
|
||||
parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, filepath.Dir(f.Path), s.RootPaths)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting parent folder for %q: %w", f.Path, err)
|
||||
}
|
||||
existing.ParentFolderID = &parentFolder.ID
|
||||
update = true
|
||||
}
|
||||
|
||||
if update {
|
||||
var err error
|
||||
if err = s.Repository.Folder.Update(ctx, existing); err != nil {
|
||||
@@ -393,13 +413,31 @@ func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult
|
||||
baseFile.UpdatedAt = now
|
||||
|
||||
// find the parent folder
|
||||
parentFolderID, err := s.getFolderID(ctx, filepath.Dir(path))
|
||||
folderPath := filepath.Dir(path)
|
||||
parentFolderID, err := s.getFolderID(ctx, folderPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting parent folder for %q: %w", path, err)
|
||||
}
|
||||
|
||||
if parentFolderID == nil {
|
||||
return nil, fmt.Errorf("parent folder for %q doesn't exist", path)
|
||||
// parent folders should have been created before scanning this file in a recursive scan
|
||||
// assume that we are scanning specifically and only this file,
|
||||
// so we should create the parent folder hierarchy if it doesn't exist
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, folderPath, s.RootPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting parent folder for %q: %w", f.Path, err)
|
||||
}
|
||||
|
||||
parentFolderID = &parentFolder.ID
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if parentFolderID == nil {
|
||||
// shouldn't happen
|
||||
return nil, fmt.Errorf("parent folder ID is nil for %q", path)
|
||||
}
|
||||
|
||||
baseFile.ParentFolderID = *parentFolderID
|
||||
@@ -604,13 +642,19 @@ func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.F
|
||||
fBaseCopy.Fingerprints = updatedBase.Fingerprints
|
||||
*updatedBase = fBaseCopy
|
||||
|
||||
zipMover := zipHierarchyMover{
|
||||
folderStore: s.Repository.Folder,
|
||||
files: s.Repository.File,
|
||||
rootPaths: s.RootPaths,
|
||||
}
|
||||
|
||||
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
|
||||
if err := s.Repository.File.Update(ctx, updated); err != nil {
|
||||
return fmt.Errorf("updating file for rename %q: %w", newPath, err)
|
||||
}
|
||||
|
||||
if s.IsZipFile(updatedBase.Basename) {
|
||||
if err := transferZipHierarchy(ctx, s.Repository.Folder, s.Repository.File, updatedBase.ID, oldPath, newPath); err != nil {
|
||||
if err := zipMover.transferZipHierarchy(ctx, updatedBase.ID, oldPath, newPath); err != nil {
|
||||
return fmt.Errorf("moving zip hierarchy for renamed zip file %q: %w", newPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,9 @@ func (f *zipFS) rel(name string) (string, error) {
|
||||
|
||||
relName, err := filepath.Rel(f.zipPath, name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("internal error getting relative path: %w", err)
|
||||
// if the path is not relative to the zip path, then it's not found in the zip file,
|
||||
// so treat this as a file not found
|
||||
return "", fs.ErrNotExist
|
||||
}
|
||||
|
||||
// convert relName to use slash, since zip files do so regardless
|
||||
|
||||
@@ -18,10 +18,8 @@ type FolderQueryOptions struct {
|
||||
type FolderFilterType struct {
|
||||
OperatorFilter[FolderFilterType]
|
||||
|
||||
Path *StringCriterionInput `json:"path,omitempty"`
|
||||
Basename *StringCriterionInput `json:"basename,omitempty"`
|
||||
// Filter by parent directory path
|
||||
Dir *StringCriterionInput `json:"dir,omitempty"`
|
||||
Path *StringCriterionInput `json:"path,omitempty"`
|
||||
Basename *StringCriterionInput `json:"basename,omitempty"`
|
||||
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"`
|
||||
ZipFile *MultiCriterionInput `json:"zip_file,omitempty"`
|
||||
// Filter by modification time
|
||||
|
||||
@@ -201,6 +201,29 @@ func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetManyParentFolderIDs provides a mock function with given fields: ctx, folderIDs
|
||||
func (_m *FolderReaderWriter) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) {
|
||||
ret := _m.Called(ctx, folderIDs)
|
||||
|
||||
var r0 [][]models.FolderID
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) [][]models.FolderID); ok {
|
||||
r0 = rf(ctx, folderIDs)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([][]models.FolderID)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok {
|
||||
r1 = rf(ctx, folderIDs)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Query provides a mock function with given fields: ctx, options
|
||||
func (_m *FolderReaderWriter) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) {
|
||||
ret := _m.Called(ctx, options)
|
||||
|
||||
@@ -158,6 +158,8 @@ type PerformerFilterType struct {
|
||||
TagCount *IntCriterionInput `json:"tag_count"`
|
||||
// Filter by scene count
|
||||
SceneCount *IntCriterionInput `json:"scene_count"`
|
||||
// Filter by scene marker count (via scene)
|
||||
MarkerCount *IntCriterionInput `json:"marker_count"`
|
||||
// Filter by image count
|
||||
ImageCount *IntCriterionInput `json:"image_count"`
|
||||
// Filter by gallery count
|
||||
@@ -202,6 +204,8 @@ type PerformerFilterType struct {
|
||||
GalleriesFilter *GalleryFilterType `json:"galleries_filter"`
|
||||
// Filter by related tags that meet this criteria
|
||||
TagsFilter *TagFilterType `json:"tags_filter"`
|
||||
// Filter by related scene markers (via scene) that meet this criteria
|
||||
MarkersFilter *SceneMarkerFilterType `json:"markers_filter"`
|
||||
// Filter by created at
|
||||
CreatedAt *TimestampCriterionInput `json:"created_at"`
|
||||
// Filter by updated at
|
||||
|
||||
@@ -15,6 +15,7 @@ type FolderFinder interface {
|
||||
FindByPath(ctx context.Context, path string, caseSensitive bool) (*Folder, error)
|
||||
FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Folder, error)
|
||||
FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error)
|
||||
GetManyParentFolderIDs(ctx context.Context, folderIDs []FolderID) ([][]FolderID, error)
|
||||
}
|
||||
|
||||
type FolderQueryer interface {
|
||||
|
||||
@@ -56,6 +56,8 @@ type TagFilterType struct {
|
||||
PerformersFilter *PerformerFilterType `json:"performers_filter"`
|
||||
// Filter by related studios that meet this criteria
|
||||
StudiosFilter *StudioFilterType `json:"studios_filter"`
|
||||
// Filter by related scene markers that meet this criteria
|
||||
MarkersFilter *SceneMarkerFilterType `json:"markers_filter"`
|
||||
// Filter by created at
|
||||
CreatedAt *TimestampCriterionInput `json:"created_at"`
|
||||
// Filter by updated at
|
||||
|
||||
@@ -1089,11 +1089,16 @@ func (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder)
|
||||
}
|
||||
|
||||
type relatedFilterHandler struct {
|
||||
relatedIDCol string
|
||||
relatedRepo repository
|
||||
// column on the primary table that relates to the related table (eg scene_id)
|
||||
relatedIDCol string
|
||||
// repository for the related table (eg sceneRepository)
|
||||
relatedRepo repository
|
||||
// handler for the filter on the related table
|
||||
relatedHandler criterionHandler
|
||||
joinFn func(f *filterBuilder)
|
||||
directJoin bool
|
||||
// optional function to perform the necessary join(s) to the related table
|
||||
joinFn func(f *filterBuilder)
|
||||
// if true, related filter handler will be run using the existing filterBuilder instead of a subquery.
|
||||
directJoin bool
|
||||
}
|
||||
|
||||
func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
@@ -1124,7 +1129,7 @@ func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
return
|
||||
}
|
||||
|
||||
f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.args...)
|
||||
f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.allArgs()...)
|
||||
}
|
||||
|
||||
type phashDistanceCriterionHandler struct {
|
||||
|
||||
@@ -34,7 +34,7 @@ const (
|
||||
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
|
||||
)
|
||||
|
||||
var appSchemaVersion uint = 83
|
||||
var appSchemaVersion uint = 84
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsBox embed.FS
|
||||
|
||||
@@ -975,7 +975,7 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File
|
||||
Megapixels float64
|
||||
Size int64
|
||||
}{}
|
||||
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
|
||||
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const folderIDColumn = "folder_id"
|
||||
|
||||
type folderRow struct {
|
||||
ID models.FolderID `db:"id" goqu:"skipinsert"`
|
||||
Basename string `db:"basename"`
|
||||
Path string `db:"path"`
|
||||
ZipFileID null.Int `db:"zip_file_id"`
|
||||
ParentFolderID null.Int `db:"parent_folder_id"`
|
||||
@@ -30,6 +31,8 @@ type folderRow struct {
|
||||
|
||||
func (r *folderRow) fromFolder(o models.Folder) {
|
||||
r.ID = o.ID
|
||||
// derive basename from path
|
||||
r.Basename = filepath.Base(o.Path)
|
||||
r.Path = o.Path
|
||||
r.ZipFileID = nullIntFromFileIDPtr(o.ZipFileID)
|
||||
r.ParentFolderID = nullIntFromFolderIDPtr(o.ParentFolderID)
|
||||
@@ -322,6 +325,90 @@ func (qb *FolderStore) FindByParentFolderID(ctx context.Context, parentFolderID
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) {
|
||||
table := qb.table()
|
||||
|
||||
// SQL recursive query to get all parent folder IDs for each folder ID
|
||||
/*
|
||||
WITH RECURSIVE parent_folders AS (
|
||||
SELECT id, parent_folder_id
|
||||
FROM folders
|
||||
WHERE id IN (folderIDs)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT f.id, f.parent_folder_id
|
||||
FROM folders f
|
||||
INNER JOIN parent_folders pf ON f.id = pf.parent_folder_id
|
||||
)
|
||||
SELECT id, parent_folder_id FROM parent_folders;
|
||||
*/
|
||||
const parentFolders = "parent_folders"
|
||||
const parentFolderID = "parent_folder_id"
|
||||
const parentID = "parent_id"
|
||||
const foldersAlias = "f"
|
||||
|
||||
const parentFoldersAlias = "pf"
|
||||
foldersAliasedI := table.As(foldersAlias)
|
||||
parentFoldersI := goqu.T(parentFolders).As(parentFoldersAlias)
|
||||
|
||||
q := dialect.From(parentFolders).Prepared(true).
|
||||
WithRecursive(parentFolders,
|
||||
dialect.From(table).Select(table.Col(idColumn), table.Col(parentFolderID).As(parentID)).
|
||||
Where(table.Col(idColumn).In(folderIDs)).
|
||||
Union(
|
||||
dialect.From(foldersAliasedI).InnerJoin(
|
||||
parentFoldersI,
|
||||
goqu.On(foldersAliasedI.Col(idColumn).Eq(parentFoldersI.Col(parentID))),
|
||||
).Select(foldersAliasedI.Col(idColumn), foldersAliasedI.Col(parentFolderID).As(parentID)),
|
||||
),
|
||||
).Select(idColumn, parentID)
|
||||
|
||||
type resultRow struct {
|
||||
FolderID models.FolderID `db:"id"`
|
||||
ParentFolderID null.Int `db:"parent_id"`
|
||||
}
|
||||
|
||||
folderMap := make(map[models.FolderID]models.FolderID)
|
||||
|
||||
if err := queryFunc(ctx, q, false, func(r *sqlx.Rows) error {
|
||||
var row resultRow
|
||||
if err := r.StructScan(&row); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if row.ParentFolderID.Valid {
|
||||
folderMap[row.FolderID] = models.FolderID(row.ParentFolderID.Int64)
|
||||
} else {
|
||||
folderMap[row.FolderID] = 0
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret := make([][]models.FolderID, len(folderIDs))
|
||||
|
||||
for i, folderID := range folderIDs {
|
||||
var parents []models.FolderID
|
||||
currentID := folderID
|
||||
|
||||
for {
|
||||
parentID, exists := folderMap[currentID]
|
||||
if !exists || parentID == 0 {
|
||||
break
|
||||
}
|
||||
parents = append(parents, parentID)
|
||||
currentID = parentID
|
||||
}
|
||||
|
||||
ret[i] = parents
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectDataset {
|
||||
table := qb.table()
|
||||
|
||||
@@ -513,7 +600,7 @@ func (qb *FolderStore) queryGroupedFields(ctx context.Context, options models.Fo
|
||||
Megapixels float64
|
||||
Size int64
|
||||
}{}
|
||||
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
|
||||
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ func (qb *folderFilterHandler) criterionHandler() criterionHandler {
|
||||
folderFilter := qb.folderFilter
|
||||
return compoundHandler{
|
||||
stringCriterionHandler(folderFilter.Path, qb.table.Col("path")),
|
||||
stringCriterionHandler(folderFilter.Basename, qb.table.Col("basename")),
|
||||
×tampCriterionHandler{folderFilter.ModTime, qb.table.Col("mod_time"), nil},
|
||||
|
||||
qb.parentFolderCriterionHandler(folderFilter.ParentFolder),
|
||||
|
||||
@@ -33,6 +33,17 @@ func TestFolderQuery(t *testing.T) {
|
||||
includeIdxs: []int{folderIdxWithSubFolder, folderIdxWithParentFolder},
|
||||
excludeIdxs: []int{folderIdxInZip},
|
||||
},
|
||||
{
|
||||
name: "basename",
|
||||
filter: &models.FolderFilterType{
|
||||
Basename: &models.StringCriterionInput{
|
||||
Value: getFolderBasename(folderIdxWithParentFolder, nil),
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{folderIdxWithParentFolder},
|
||||
excludeIdxs: []int{folderIdxInZip},
|
||||
},
|
||||
{
|
||||
name: "parent folder",
|
||||
filter: &models.FolderFilterType{
|
||||
|
||||
@@ -186,8 +186,6 @@ func Test_FolderStore_Update(t *testing.T) {
|
||||
}
|
||||
|
||||
assert.Equal(copy, *s)
|
||||
|
||||
return
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -239,3 +237,75 @@ func Test_FolderStore_FindByPath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_FolderStore_GetManyParentFolderIDs(t *testing.T) {
|
||||
var empty []models.FolderID
|
||||
emptyResult := [][]models.FolderID{empty}
|
||||
tests := []struct {
|
||||
name string
|
||||
parentFolderIDs []models.FolderID
|
||||
want [][]models.FolderID
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"valid with parent folders",
|
||||
[]models.FolderID{folderIDs[folderIdxWithParentFolder]},
|
||||
[][]models.FolderID{
|
||||
{
|
||||
folderIDs[folderIdxWithSubFolder],
|
||||
folderIDs[folderIdxRoot],
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"valid multiple folders",
|
||||
[]models.FolderID{
|
||||
folderIDs[folderIdxWithParentFolder],
|
||||
folderIDs[folderIdxWithSceneFiles],
|
||||
},
|
||||
[][]models.FolderID{
|
||||
{
|
||||
folderIDs[folderIdxWithSubFolder],
|
||||
folderIDs[folderIdxRoot],
|
||||
},
|
||||
{
|
||||
folderIDs[folderIdxForObjectFiles],
|
||||
folderIDs[folderIdxRoot],
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"valid without parent folders",
|
||||
[]models.FolderID{folderIDs[folderIdxRoot]},
|
||||
emptyResult,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid folder id",
|
||||
[]models.FolderID{invalidFolderID},
|
||||
emptyResult,
|
||||
// does not error, just returns empty result
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
qb := db.Folder
|
||||
|
||||
for _, tt := range tests {
|
||||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
got, err := qb.GetManyParentFolderIDs(ctx, tt.parentFolderIDs)
|
||||
if (err != nil) != tt.wantErr {
|
||||
assert.Errorf(err, "FolderStore.GetManyParentFolderIDs() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +308,16 @@ func (qb *galleryFilterHandler) missingCriterionHandler(isMissing *string) crite
|
||||
case "tags":
|
||||
galleryRepository.tags.join(f, "tags_join", "galleries.id")
|
||||
f.addWhere("tags_join.gallery_id IS NULL")
|
||||
case "cover":
|
||||
f.addLeftJoin("galleries_images", "cover_join", "cover_join.gallery_id = galleries.id AND cover_join.cover = 1")
|
||||
f.addWhere("cover_join.image_id IS NULL")
|
||||
default:
|
||||
if err := validateIsMissing(*isMissing, []string{
|
||||
"title", "code", "rating", "details", "photographer",
|
||||
}); err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
f.addWhere("(galleries." + *isMissing + " IS NULL OR TRIM(galleries." + *isMissing + ") = '')")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,25 @@ func (qb *groupFilterHandler) missingCriterionHandler(isMissing *string) criteri
|
||||
case "scenes":
|
||||
f.addLeftJoin("groups_scenes", "", "groups_scenes.group_id = groups.id")
|
||||
f.addWhere("groups_scenes.scene_id IS NULL")
|
||||
case "url":
|
||||
groupsURLsTableMgr.join(f, "", "groups.id")
|
||||
f.addWhere("group_urls.url IS NULL")
|
||||
case "studio":
|
||||
f.addWhere("groups.studio_id IS NULL")
|
||||
case "performers":
|
||||
f.addLeftJoin("groups_scenes", "gs_perf", "groups.id = gs_perf.group_id")
|
||||
f.addLeftJoin("performers_scenes", "ps_perf", "gs_perf.scene_id = ps_perf.scene_id")
|
||||
f.addWhere("ps_perf.performer_id IS NULL")
|
||||
case "tags":
|
||||
groupRepository.tags.join(f, "tags_join", "groups.id")
|
||||
f.addWhere("tags_join.group_id IS NULL")
|
||||
default:
|
||||
if err := validateIsMissing(*isMissing, []string{
|
||||
"aliases", "description", "director", "date", "rating",
|
||||
}); err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
f.addWhere("(groups." + *isMissing + " IS NULL OR TRIM(groups." + *isMissing + ") = '')")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -926,7 +926,7 @@ func (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.Ima
|
||||
Megapixels null.Float
|
||||
Size null.Float
|
||||
}{}
|
||||
if err := imageRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
|
||||
if err := imageRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,9 @@ func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criteri
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if isMissing != nil && *isMissing != "" {
|
||||
switch *isMissing {
|
||||
case "url":
|
||||
imagesURLsTableMgr.join(f, "", "images.id")
|
||||
f.addWhere("image_urls.url IS NULL")
|
||||
case "studio":
|
||||
f.addWhere("images.studio_id IS NULL")
|
||||
case "performers":
|
||||
@@ -183,6 +186,12 @@ func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criteri
|
||||
imageRepository.tags.join(f, "tags_join", "images.id")
|
||||
f.addWhere("tags_join.image_id IS NULL")
|
||||
default:
|
||||
if err := validateIsMissing(*isMissing, []string{
|
||||
"title", "details", "photographer", "date", "code", "rating",
|
||||
}); err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
f.addWhere("(images." + *isMissing + " IS NULL OR TRIM(images." + *isMissing + ") = '')")
|
||||
}
|
||||
}
|
||||
|
||||
50
pkg/sqlite/migrations/84_folder_basename.up.sql
Normal file
50
pkg/sqlite/migrations/84_folder_basename.up.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- we cannot add basename column directly because we require it to be NOT NULL
|
||||
-- recreate folders table with basename column
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
CREATE TABLE `folders_new` (
|
||||
`id` integer not null primary key autoincrement,
|
||||
`basename` varchar(255) NOT NULL,
|
||||
`path` varchar(255) NOT NULL,
|
||||
`parent_folder_id` integer,
|
||||
`zip_file_id` integer REFERENCES `files`(`id`),
|
||||
`mod_time` datetime not null,
|
||||
`created_at` datetime not null,
|
||||
`updated_at` datetime not null,
|
||||
foreign key(`parent_folder_id`) references `folders`(`id`) on delete SET NULL
|
||||
);
|
||||
|
||||
-- copy data from old table to new table, setting basename to path temporarily
|
||||
INSERT INTO `folders_new` (
|
||||
`id`,
|
||||
`basename`,
|
||||
`path`,
|
||||
`parent_folder_id`,
|
||||
`zip_file_id`,
|
||||
`mod_time`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
) SELECT
|
||||
`id`,
|
||||
`path`,
|
||||
`path`,
|
||||
`parent_folder_id`,
|
||||
`zip_file_id`,
|
||||
`mod_time`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
FROM `folders`;
|
||||
|
||||
DROP INDEX IF EXISTS `index_folders_on_parent_folder_id`;
|
||||
DROP INDEX IF EXISTS `index_folders_on_path_unique`;
|
||||
DROP INDEX IF EXISTS `index_folders_on_zip_file_id`;
|
||||
DROP TABLE `folders`;
|
||||
|
||||
ALTER TABLE `folders_new` RENAME TO `folders`;
|
||||
|
||||
CREATE UNIQUE INDEX `index_folders_on_path_unique` on `folders` (`path`);
|
||||
CREATE UNIQUE INDEX `index_folders_on_parent_folder_id_basename_unique` on `folders` (`parent_folder_id`, `basename`);
|
||||
CREATE INDEX `index_folders_on_zip_file_id` on `folders` (`zip_file_id`) WHERE `zip_file_id` IS NOT NULL;
|
||||
CREATE INDEX `index_folders_on_basename` on `folders` (`basename`);
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
385
pkg/sqlite/migrations/84_postmigrate.go
Normal file
385
pkg/sqlite/migrations/84_postmigrate.go
Normal file
@@ -0,0 +1,385 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/sqlite"
|
||||
"gopkg.in/guregu/null.v4"
|
||||
)
|
||||
|
||||
func post84(ctx context.Context, db *sqlx.DB) error {
|
||||
logger.Info("Running post-migration for schema version 84")
|
||||
|
||||
m := schema84Migrator{
|
||||
migrator: migrator{
|
||||
db: db,
|
||||
},
|
||||
folderCache: make(map[string]folderInfo),
|
||||
}
|
||||
|
||||
rootPaths := config.GetInstance().GetStashPaths().Paths()
|
||||
|
||||
if err := m.createMissingFolderHierarchies(ctx, rootPaths); err != nil {
|
||||
return fmt.Errorf("creating missing folder hierarchies: %w", err)
|
||||
}
|
||||
|
||||
if err := m.fixIncorrectParents(ctx, rootPaths); err != nil {
|
||||
return fmt.Errorf("fixing incorrect parent folders: %w", err)
|
||||
}
|
||||
|
||||
if err := m.migrateFolders(ctx); err != nil {
|
||||
return fmt.Errorf("migrating folders: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type schema84Migrator struct {
|
||||
migrator
|
||||
folderCache map[string]folderInfo
|
||||
}
|
||||
|
||||
func (m *schema84Migrator) createMissingFolderHierarchies(ctx context.Context, rootPaths []string) error {
|
||||
// before we set the basenames, we need to address any folders that are missing their
|
||||
// parent folders.
|
||||
const (
|
||||
limit = 1000
|
||||
logEvery = 10000
|
||||
)
|
||||
|
||||
lastID := 0
|
||||
count := 0
|
||||
logged := false
|
||||
|
||||
for {
|
||||
gotSome := false
|
||||
|
||||
if err := m.withTxn(ctx, func(tx *sqlx.Tx) error {
|
||||
query := "SELECT `folders`.`id`, `folders`.`path` FROM `folders` WHERE `folders`.`parent_folder_id` IS NULL "
|
||||
|
||||
if lastID != 0 {
|
||||
query += fmt.Sprintf("AND `folders`.`id` > %d ", lastID)
|
||||
}
|
||||
|
||||
query += fmt.Sprintf("ORDER BY `folders`.`id` LIMIT %d", limit)
|
||||
|
||||
rows, err := tx.Query(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
// log once if we find any folders with missing parent folders
|
||||
if !logged {
|
||||
logger.Info("Migrating folders with missing parents...")
|
||||
logged = true
|
||||
}
|
||||
|
||||
var id int
|
||||
var p string
|
||||
|
||||
err := rows.Scan(&id, &p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lastID = id
|
||||
gotSome = true
|
||||
count++
|
||||
|
||||
// don't try to create parent folders for root paths
|
||||
if slices.Contains(rootPaths, p) {
|
||||
continue
|
||||
}
|
||||
|
||||
parentDir := filepath.Dir(p)
|
||||
if parentDir == p {
|
||||
// this can happen if the path is something like "C:\", where the parent directory is the same as the current directory
|
||||
continue
|
||||
}
|
||||
|
||||
parentID, err := m.getOrCreateFolderHierarchy(tx, parentDir, rootPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating parent folder for folder %d %q: %w", id, p, err)
|
||||
}
|
||||
|
||||
if parentID == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// now set the parent folder ID for the current folder
|
||||
logger.Debugf("Migrating folder %d %q: setting parent folder ID to %d", id, p, *parentID)
|
||||
|
||||
_, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?", *parentID, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error setting parent folder for folder %d %q: %w", id, p, err)
|
||||
}
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !gotSome {
|
||||
break
|
||||
}
|
||||
|
||||
if count%logEvery == 0 {
|
||||
logger.Infof("Migrated %d folders", count)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *schema84Migrator) findFolderByPath(tx *sqlx.Tx, path string) (*int, error) {
|
||||
query := "SELECT `folders`.`id` FROM `folders` WHERE `folders`.`path` = ?"
|
||||
|
||||
var id int
|
||||
if err := tx.Get(&id, query, path); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// this is a copy of the GetOrCreateFolderHierarchy function from pkg/file/folder.go,
|
||||
// but modified to use low-level SQL queries instead of the models.FolderFinderCreator interface, to avoid
|
||||
func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string, rootPaths []string) (*int, error) {
|
||||
// get or create folder hierarchy
|
||||
folderID, err := m.findFolderByPath(tx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if folderID == nil {
|
||||
var parentID *int
|
||||
|
||||
if !slices.Contains(rootPaths, path) {
|
||||
parentPath := filepath.Dir(path)
|
||||
|
||||
// it's possible that the parent path is the same as the current path, if there are folders outside
|
||||
// of the root paths. In that case, we should just return nil for the parent ID.
|
||||
if parentPath == path {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parentID, err = m.getOrCreateFolderHierarchy(tx, parentPath, rootPaths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debugf("%s doesn't exist. Creating new folder entry...", path)
|
||||
|
||||
// we need to set basename to path, which will be addressed in the next step
|
||||
const insertSQL = "INSERT INTO `folders` (`path`,`basename`,`parent_folder_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?,?)"
|
||||
|
||||
var parentFolderID null.Int
|
||||
if parentID != nil {
|
||||
parentFolderID = null.IntFrom(int64(*parentID))
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
result, err := tx.Exec(insertSQL, path, path, parentFolderID, time.Time{}, now, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating folder %s: %w", path, err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating folder %s: %w", path, err)
|
||||
}
|
||||
|
||||
idInt := int(id)
|
||||
folderID = &idInt
|
||||
}
|
||||
|
||||
return folderID, nil
|
||||
}
|
||||
|
||||
func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []string) error {
|
||||
const (
|
||||
limit = 1000
|
||||
logEvery = 10000
|
||||
)
|
||||
|
||||
lastID := 0
|
||||
count := 0
|
||||
fixed := 0
|
||||
logged := false
|
||||
|
||||
for {
|
||||
gotSome := false
|
||||
|
||||
if err := m.withTxn(ctx, func(tx *sqlx.Tx) error {
|
||||
query := "SELECT f.id, f.path, f.parent_folder_id, pf.path AS parent_path " +
|
||||
"FROM folders f " +
|
||||
"JOIN folders pf ON f.parent_folder_id = pf.id "
|
||||
|
||||
if lastID != 0 {
|
||||
query += fmt.Sprintf("WHERE f.id > %d ", lastID)
|
||||
}
|
||||
|
||||
query += fmt.Sprintf("ORDER BY f.id LIMIT %d", limit)
|
||||
|
||||
rows, err := tx.Query(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var p string
|
||||
var parentFolderID int
|
||||
var parentPath string
|
||||
|
||||
err := rows.Scan(&id, &p, &parentFolderID, &parentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lastID = id
|
||||
gotSome = true
|
||||
count++
|
||||
|
||||
expectedParent := filepath.Dir(p)
|
||||
if expectedParent == parentPath {
|
||||
continue
|
||||
}
|
||||
|
||||
if !logged {
|
||||
logger.Info("Fixing folders with incorrect parent folder assignments...")
|
||||
logged = true
|
||||
}
|
||||
|
||||
correctParentID, err := m.getOrCreateFolderHierarchy(tx, expectedParent, rootPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting/creating correct parent for folder %d %q: %w", id, p, err)
|
||||
}
|
||||
|
||||
if correctParentID == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debugf("Fixing folder %d %q: changing parent_folder_id from %d to %d", id, p, parentFolderID, *correctParentID)
|
||||
|
||||
_, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?", *correctParentID, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error fixing parent folder for folder %d %q: %w", id, p, err)
|
||||
}
|
||||
|
||||
fixed++
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !gotSome {
|
||||
break
|
||||
}
|
||||
|
||||
if count%logEvery == 0 {
|
||||
logger.Infof("Checked %d folders", count)
|
||||
}
|
||||
}
|
||||
|
||||
if fixed > 0 {
|
||||
logger.Infof("Fixed %d folders with incorrect parent assignments", fixed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *schema84Migrator) migrateFolders(ctx context.Context) error {
|
||||
const (
|
||||
limit = 1000
|
||||
logEvery = 10000
|
||||
)
|
||||
|
||||
lastID := 0
|
||||
count := 0
|
||||
logged := false
|
||||
|
||||
for {
|
||||
gotSome := false
|
||||
|
||||
if err := m.withTxn(ctx, func(tx *sqlx.Tx) error {
|
||||
query := "SELECT `folders`.`id`, `folders`.`path` FROM `folders` "
|
||||
|
||||
if lastID != 0 {
|
||||
query += fmt.Sprintf("WHERE `folders`.`id` > %d ", lastID)
|
||||
}
|
||||
|
||||
query += fmt.Sprintf("ORDER BY `folders`.`id` LIMIT %d", limit)
|
||||
|
||||
rows, err := tx.Query(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
if !logged {
|
||||
logger.Infof("Migrating folders to set basenames...")
|
||||
logged = true
|
||||
}
|
||||
|
||||
var id int
|
||||
var p string
|
||||
|
||||
err := rows.Scan(&id, &p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lastID = id
|
||||
gotSome = true
|
||||
count++
|
||||
|
||||
basename := filepath.Base(p)
|
||||
logger.Debugf("Migrating folder %d %q: setting basename to %q", id, p, basename)
|
||||
_, err = tx.Exec("UPDATE `folders` SET `basename` = ? WHERE `id` = ?", basename, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error migrating folder %d %q: %w", id, p, err)
|
||||
}
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !gotSome {
|
||||
break
|
||||
}
|
||||
|
||||
if count%logEvery == 0 {
|
||||
logger.Infof("Migrated %d folders", count)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
sqlite.RegisterPostMigration(84, post84)
|
||||
}
|
||||
@@ -195,6 +195,7 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler {
|
||||
|
||||
qb.tagCountCriterionHandler(filter.TagCount),
|
||||
qb.sceneCountCriterionHandler(filter.SceneCount),
|
||||
qb.markerCountCriterionHandler(filter.MarkerCount),
|
||||
qb.imageCountCriterionHandler(filter.ImageCount),
|
||||
qb.galleryCountCriterionHandler(filter.GalleryCount),
|
||||
qb.playCounterCriterionHandler(filter.PlayCount),
|
||||
@@ -204,6 +205,16 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler {
|
||||
×tampCriterionHandler{filter.CreatedAt, tableName + ".created_at", nil},
|
||||
×tampCriterionHandler{filter.UpdatedAt, tableName + ".updated_at", nil},
|
||||
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "scene_markers.id",
|
||||
relatedRepo: sceneMarkerRepository.repository,
|
||||
relatedHandler: &sceneMarkerFilterHandler{filter.MarkersFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
performerRepository.scenes.innerJoin(f, "", "performers.id")
|
||||
f.addInnerJoin(sceneMarkerTable, "", "scene_markers.scene_id = performers_scenes.scene_id")
|
||||
},
|
||||
},
|
||||
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "performers_scenes.scene_id",
|
||||
relatedRepo: sceneRepository.repository,
|
||||
@@ -305,7 +316,19 @@ func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing *
|
||||
case "aliases":
|
||||
performersAliasesTableMgr.join(f, "", "performers.id")
|
||||
f.addWhere("performer_aliases.alias IS NULL")
|
||||
case "tags":
|
||||
f.addLeftJoin(performersTagsTable, "tags_join", "tags_join.performer_id = performers.id")
|
||||
f.addWhere("tags_join.performer_id IS NULL")
|
||||
default:
|
||||
if err := validateIsMissing(*isMissing, []string{
|
||||
"disambiguation", "gender", "birthdate", "death_date",
|
||||
"ethnicity", "country", "hair_color", "eye_color", "height", "weight",
|
||||
"measurements", "fake_tits", "penis_length", "circumcised",
|
||||
"career_start", "career_end", "tattoos", "piercings", "details", "rating",
|
||||
}); err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
f.addWhere("(performers." + *isMissing + " IS NULL OR TRIM(performers." + *isMissing + ") = '')")
|
||||
}
|
||||
}
|
||||
@@ -387,6 +410,22 @@ func (qb *performerFilterHandler) sceneCountCriterionHandler(count *models.IntCr
|
||||
return h.handler(count)
|
||||
}
|
||||
|
||||
func (qb *performerFilterHandler) markerCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if count != nil {
|
||||
performerRepository.scenes.innerJoin(f, "", "performers.id")
|
||||
|
||||
const query = `(SELECT COUNT(*) FROM scene_markers
|
||||
INNER JOIN scenes ON scene_markers.scene_id = scenes.id
|
||||
INNER JOIN performers_scenes ON performers_scenes.scene_id = scenes.id
|
||||
WHERE performers_scenes.performer_id = performers.id)`
|
||||
|
||||
clause, args := getIntCriterionWhereClause(query, *count)
|
||||
f.addWhere(clause, args...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *performerFilterHandler) imageCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: performerTable,
|
||||
|
||||
@@ -17,13 +17,26 @@ type queryBuilder struct {
|
||||
joins joins
|
||||
whereClauses []string
|
||||
havingClauses []string
|
||||
args []interface{}
|
||||
withClauses []string
|
||||
recursiveWith bool
|
||||
|
||||
withArgs []interface{}
|
||||
joinArgs []interface{}
|
||||
whereArgs []interface{}
|
||||
havingArgs []interface{}
|
||||
|
||||
sortAndPagination string
|
||||
}
|
||||
|
||||
func (qb queryBuilder) allArgs() []interface{} {
|
||||
var args []interface{}
|
||||
args = append(args, qb.withArgs...)
|
||||
args = append(args, qb.joinArgs...)
|
||||
args = append(args, qb.whereArgs...)
|
||||
args = append(args, qb.havingArgs...)
|
||||
return args
|
||||
}
|
||||
|
||||
func (qb queryBuilder) body(includeSortPagination bool) string {
|
||||
return fmt.Sprintf("SELECT %s FROM %s%s", strings.Join(qb.columns, ", "), qb.from, qb.joins.toSQL(includeSortPagination))
|
||||
}
|
||||
@@ -55,13 +68,13 @@ func (qb queryBuilder) toSQL(includeSortPagination bool) string {
|
||||
func (qb queryBuilder) findIDs(ctx context.Context) ([]int, error) {
|
||||
const includeSortPagination = true
|
||||
sql := qb.toSQL(includeSortPagination)
|
||||
return qb.repository.runIdsQuery(ctx, sql, qb.args)
|
||||
return qb.repository.runIdsQuery(ctx, sql, qb.allArgs())
|
||||
}
|
||||
|
||||
func (qb queryBuilder) executeFind(ctx context.Context) ([]int, int, error) {
|
||||
const includeSortPagination = true
|
||||
body := qb.body(includeSortPagination)
|
||||
return qb.repository.executeFindQuery(ctx, body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses, qb.recursiveWith)
|
||||
return qb.repository.executeFindQuery(ctx, body, qb.allArgs(), qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses, qb.recursiveWith)
|
||||
}
|
||||
|
||||
func (qb queryBuilder) executeCount(ctx context.Context) (int, error) {
|
||||
@@ -79,7 +92,7 @@ func (qb queryBuilder) executeCount(ctx context.Context) (int, error) {
|
||||
|
||||
body = qb.repository.buildQueryBody(body, qb.whereClauses, qb.havingClauses)
|
||||
countQuery := withClause + qb.repository.buildCountQuery(body)
|
||||
return qb.repository.runCountQuery(ctx, countQuery, qb.args)
|
||||
return qb.repository.runCountQuery(ctx, countQuery, qb.allArgs())
|
||||
}
|
||||
|
||||
func (qb *queryBuilder) addWhere(clauses ...string) {
|
||||
@@ -109,7 +122,11 @@ func (qb *queryBuilder) addWith(recursive bool, clauses ...string) {
|
||||
}
|
||||
|
||||
func (qb *queryBuilder) addArg(args ...interface{}) {
|
||||
qb.args = append(qb.args, args...)
|
||||
qb.whereArgs = append(qb.whereArgs, args...)
|
||||
}
|
||||
|
||||
func (qb *queryBuilder) addHavingArg(args ...interface{}) {
|
||||
qb.havingArgs = append(qb.havingArgs, args...)
|
||||
}
|
||||
|
||||
func (qb *queryBuilder) hasJoin(alias string) bool {
|
||||
@@ -148,7 +165,7 @@ func (qb *queryBuilder) joinSort(table, as, onClause string) {
|
||||
func (qb *queryBuilder) addJoins(joins ...join) {
|
||||
for _, j := range joins {
|
||||
if qb.joins.addUnique(j) {
|
||||
qb.args = append(qb.args, j.args...)
|
||||
qb.joinArgs = append(qb.joinArgs, j.args...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,20 +180,16 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) error {
|
||||
if len(clause) > 0 {
|
||||
qb.addWith(f.recursiveWith, clause)
|
||||
}
|
||||
|
||||
if len(args) > 0 {
|
||||
// WITH clause always comes first and thus precedes alk args
|
||||
qb.args = append(args, qb.args...)
|
||||
qb.withArgs = append(qb.withArgs, args...)
|
||||
}
|
||||
|
||||
// add joins here to insert args
|
||||
qb.addJoins(f.getAllJoins()...)
|
||||
|
||||
clause, args = f.generateWhereClauses()
|
||||
if len(clause) > 0 {
|
||||
qb.addWhere(clause)
|
||||
}
|
||||
|
||||
if len(args) > 0 {
|
||||
qb.addArg(args...)
|
||||
}
|
||||
@@ -185,9 +198,8 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) error {
|
||||
if len(clause) > 0 {
|
||||
qb.addHaving(clause)
|
||||
}
|
||||
|
||||
if len(args) > 0 {
|
||||
qb.addArg(args...)
|
||||
qb.addHavingArg(args...)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1097,7 +1097,7 @@ func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.Sce
|
||||
Duration null.Float
|
||||
Size null.Float
|
||||
}{}
|
||||
if err := sceneRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
|
||||
if err := sceneRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -426,6 +426,12 @@ func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) crite
|
||||
case "cover":
|
||||
f.addWhere("scenes.cover_blob IS NULL")
|
||||
default:
|
||||
if err := validateIsMissing(*isMissing, []string{
|
||||
"title", "code", "details", "director", "rating",
|
||||
}); err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
f.addWhere("(scenes." + *isMissing + " IS NULL OR TRIM(scenes." + *isMissing + ") = '')")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
folderIdxWithSubFolder = iota
|
||||
folderIdxRoot = iota
|
||||
folderIdxWithSubFolder
|
||||
folderIdxWithParentFolder
|
||||
folderIdxWithFiles
|
||||
folderIdxInZip
|
||||
@@ -359,6 +360,8 @@ func (m linkMap) reverseLookup(idx int) []int {
|
||||
|
||||
var (
|
||||
folderParentFolders = map[int]int{
|
||||
folderIdxWithSubFolder: folderIdxRoot,
|
||||
folderIdxForObjectFiles: folderIdxRoot,
|
||||
folderIdxWithParentFolder: folderIdxWithSubFolder,
|
||||
folderIdxWithSceneFiles: folderIdxForObjectFiles,
|
||||
folderIdxWithImageFiles: folderIdxForObjectFiles,
|
||||
@@ -785,6 +788,10 @@ func getFolderPath(index int, parentFolderIdx *int) string {
|
||||
return path
|
||||
}
|
||||
|
||||
func getFolderBasename(index int, parentFolderIdx *int) string {
|
||||
return filepath.Base(getFolderPath(index, parentFolderIdx))
|
||||
}
|
||||
|
||||
func getFolderModTime(index int) time.Time {
|
||||
return time.Date(2000, 1, (index%10)+1, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,16 @@ func (o sortOptions) validateSort(sort string) error {
|
||||
return fmt.Errorf("invalid sort: %s", sort)
|
||||
}
|
||||
|
||||
func validateIsMissing(isMissing string, allowed []string) error {
|
||||
for _, v := range allowed {
|
||||
if v == isMissing {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid is_missing field: %s", isMissing)
|
||||
}
|
||||
|
||||
func getSortDirection(direction string) string {
|
||||
if direction != "ASC" && direction != "DESC" {
|
||||
return "ASC"
|
||||
|
||||
@@ -150,7 +150,19 @@ func (qb *studioFilterHandler) isMissingCriterionHandler(isMissing *string) crit
|
||||
case "stash_id":
|
||||
studioRepository.stashIDs.join(f, "studio_stash_ids", "studios.id")
|
||||
f.addWhere("studio_stash_ids.studio_id IS NULL")
|
||||
case "aliases":
|
||||
studiosAliasesTableMgr.join(f, "", "studios.id")
|
||||
f.addWhere("studio_aliases.alias IS NULL")
|
||||
case "tags":
|
||||
f.addLeftJoin(studiosTagsTable, "tags_join", "tags_join.studio_id = studios.id")
|
||||
f.addWhere("tags_join.studio_id IS NULL")
|
||||
default:
|
||||
if err := validateIsMissing(*isMissing, []string{
|
||||
"details", "rating",
|
||||
}); err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
f.addWhere("(studios." + *isMissing + " IS NULL OR TRIM(studios." + *isMissing + ") = '')")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,20 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler {
|
||||
tagRepository.studios.innerJoin(f, "", "tags.id")
|
||||
},
|
||||
},
|
||||
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "markers_tags.marker_id",
|
||||
relatedRepo: sceneMarkerRepository.repository,
|
||||
relatedHandler: &sceneMarkerFilterHandler{tagFilter.MarkersFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
f.addWith(`markers_tags AS (
|
||||
SELECT mt.scene_marker_id AS marker_id, mt.tag_id AS tag_id FROM scene_markers_tags mt
|
||||
UNION
|
||||
SELECT m.id, m.primary_tag_id FROM scene_markers m
|
||||
)`)
|
||||
f.addInnerJoin("markers_tags", "", "markers_tags.tag_id = tags.id")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +198,19 @@ func (qb *tagFilterHandler) isMissingCriterionHandler(isMissing *string) criteri
|
||||
switch *isMissing {
|
||||
case "image":
|
||||
f.addWhere("tags.image_blob IS NULL")
|
||||
case "aliases":
|
||||
tagRepository.aliases.join(f, "", "tags.id")
|
||||
f.addWhere("tag_aliases.alias IS NULL")
|
||||
case "stash_id":
|
||||
tagRepository.stashIDs.join(f, "tag_stash_ids", "tags.id")
|
||||
f.addWhere("tag_stash_ids.tag_id IS NULL")
|
||||
default:
|
||||
if err := validateIsMissing(*isMissing, []string{
|
||||
"description",
|
||||
}); err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
f.addWhere("(tags." + *isMissing + " IS NULL OR TRIM(tags." + *isMissing + ") = '')")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1889,6 +1889,65 @@ func TestTagQueryCustomFields(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test combining text search (findFilter.Q) with custom field filters.
|
||||
// This verifies that positional args are bound in the correct order
|
||||
// when JOINs (from custom fields) and WHERE (from text search) both
|
||||
// have parameterized placeholders.
|
||||
runWithRollbackTxn(t, "equals with text search", func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
tagName := getTagStringValue(tagIdxWithGallery, "Name")
|
||||
q := tagName
|
||||
findFilter := &models.FindFilterType{Q: &q}
|
||||
|
||||
tagFilter := &models.TagFilterType{
|
||||
CustomFields: []models.CustomFieldCriterionInput{
|
||||
{
|
||||
Field: "string",
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
Value: []any{getTagStringValue(tagIdxWithGallery, "custom")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tags, _, err := db.Tag.Query(ctx, tagFilter, findFilter)
|
||||
if err != nil {
|
||||
t.Errorf("TagStore.Query() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ids := tagsToIDs(tags)
|
||||
assert.Contains(ids, tagIDs[tagIdxWithGallery])
|
||||
assert.Len(tags, 1)
|
||||
})
|
||||
|
||||
runWithRollbackTxn(t, "is_null with text search", func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
tagName := getTagStringValue(tagIdxWithGallery, "Name")
|
||||
q := tagName
|
||||
findFilter := &models.FindFilterType{Q: &q}
|
||||
|
||||
tagFilter := &models.TagFilterType{
|
||||
CustomFields: []models.CustomFieldCriterionInput{
|
||||
{
|
||||
Field: "not existing",
|
||||
Modifier: models.CriterionModifierIsNull,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tags, _, err := db.Tag.Query(ctx, tagFilter, findFilter)
|
||||
if err != nil {
|
||||
t.Errorf("TagStore.Query() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ids := tagsToIDs(tags)
|
||||
assert.Contains(ids, tagIDs[tagIdxWithGallery])
|
||||
assert.Len(tags, 1)
|
||||
})
|
||||
}
|
||||
|
||||
// TODO Destroy
|
||||
|
||||
88
ui/v2.5/pnpm-lock.yaml
generated
88
ui/v2.5/pnpm-lock.yaml
generated
@@ -1660,36 +1660,42 @@ packages:
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||
@@ -1781,56 +1787,67 @@ packages:
|
||||
resolution: {integrity: sha512-JzWRR41o2U3/KMNKRuZNsDUAcAVUYhsPuMlx5RUldw0E4lvSIXFUwejtYz1HJXohUmqs/M6BBJAUBzKXZVddbg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.53.1':
|
||||
resolution: {integrity: sha512-L8kRIrnfMrEoHLHtHn+4uYA52fiLDEDyezgxZtGUTiII/yb04Krq+vk3P2Try+Vya9LeCE9ZHU8CXD6J9EhzHQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.53.1':
|
||||
resolution: {integrity: sha512-ysAc0MFRV+WtQ8li8hi3EoFi7us6d1UzaS/+Dp7FYZfg3NdDljGMoVyiIp6Ucz7uhlYDBZ/zt6XI0YEZbUO11Q==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.53.1':
|
||||
resolution: {integrity: sha512-UV6l9MJpDbDZZ/fJvqNcvO1PcivGEf1AvKuTcHoLjVZVFeAMygnamCTDikCVMRnA+qJe+B3pSbgX2+lBMqgBhA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.53.1':
|
||||
resolution: {integrity: sha512-UDUtelEprkA85g95Q+nj3Xf0M4hHa4DiJ+3P3h4BuGliY4NReYYqwlc0Y8ICLjN4+uIgCEvaygYlpf0hUj90Yg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.53.1':
|
||||
resolution: {integrity: sha512-vrRn+BYhEtNOte/zbc2wAUQReJXxEx2URfTol6OEfY2zFEUK92pkFBSXRylDM7aHi+YqEPJt9/ABYzmcrS4SgQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.53.1':
|
||||
resolution: {integrity: sha512-gto/1CxHyi4A7YqZZNznQYrVlPSaodOBPKM+6xcDSCMVZN/Fzb4K+AIkNz/1yAYz9h3Ng+e2fY9H6bgawVq17w==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.53.1':
|
||||
resolution: {integrity: sha512-KZ6Vx7jAw3aLNjFR8eYVcQVdFa/cvBzDNRFM3z7XhNNunWjA03eUrEwJYPk0G8V7Gs08IThFKcAPS4WY/ybIrQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.53.1':
|
||||
resolution: {integrity: sha512-HvEixy2s/rWNgpwyKpXJcHmE7om1M89hxBTBi9Fs6zVuLU4gOrEMQNbNsN/tBVIMbLyysz/iwNiGtMOpLAOlvA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.53.1':
|
||||
resolution: {integrity: sha512-E/n8x2MSjAQgjj9IixO4UeEUeqXLtiA7pyoXCFYLuXpBA/t2hnbIdxHfA7kK9BFsYAoNU4st1rHYdldl8dTqGA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.53.1':
|
||||
resolution: {integrity: sha512-IhJ087PbLOQXCN6Ui/3FUkI9pWNZe/Z7rEIVOzMsOs1/HSAECCvSZ7PkIbkNqL/AZn6WbZvnoVZw/qwqYMo4/w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.53.1':
|
||||
resolution: {integrity: sha512-0++oPNgLJHBblreu0SFM7b3mAsBJBTY0Ksrmu9N6ZVrPiTkRgda52mWR7TKhHAsUb9noCjFvAw9l6ZO1yzaVbA==}
|
||||
@@ -2195,11 +2212,11 @@ packages:
|
||||
peerDependencies:
|
||||
ajv: ^6.9.1
|
||||
|
||||
ajv@6.12.6:
|
||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||
ajv@6.14.0:
|
||||
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
||||
|
||||
ajv@8.17.1:
|
||||
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
|
||||
ajv@8.18.0:
|
||||
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
|
||||
|
||||
ansi-escapes@4.3.2:
|
||||
resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
|
||||
@@ -2350,6 +2367,10 @@ packages:
|
||||
balanced-match@2.0.0:
|
||||
resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==}
|
||||
|
||||
balanced-match@4.0.4:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
base64-blob@1.4.1:
|
||||
resolution: {integrity: sha512-n5Ov4cPTbLBTX1PiFbaB5AmK7LMigO9HWh5Lzx+Kcx/yx1MppeeLYtAH8aLv1m++WNoHQnr+xbGSqcZinopwlw==}
|
||||
|
||||
@@ -2382,8 +2403,9 @@ packages:
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
|
||||
brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
brace-expansion@5.0.3:
|
||||
resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
@@ -3895,11 +3917,11 @@ packages:
|
||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
|
||||
minimatch@9.0.5:
|
||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
||||
minimatch@9.0.8:
|
||||
resolution: {integrity: sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minimist-options@4.1.0:
|
||||
@@ -6336,14 +6358,14 @@ snapshots:
|
||||
|
||||
'@eslint/eslintrc@2.1.4':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
ajv: 6.14.0
|
||||
debug: 4.4.3
|
||||
espree: 9.6.1
|
||||
globals: 13.24.0
|
||||
ignore: 5.3.2
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.1.0
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -7037,7 +7059,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
debug: 4.4.3
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7663,18 +7685,18 @@ snapshots:
|
||||
clean-stack: 2.2.0
|
||||
indent-string: 4.0.0
|
||||
|
||||
ajv-keywords@3.5.2(ajv@6.12.6):
|
||||
ajv-keywords@3.5.2(ajv@6.14.0):
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
ajv: 6.14.0
|
||||
|
||||
ajv@6.12.6:
|
||||
ajv@6.14.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
json-schema-traverse: 0.4.1
|
||||
uri-js: 4.4.1
|
||||
|
||||
ajv@8.17.1:
|
||||
ajv@8.18.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.0
|
||||
@@ -7887,6 +7909,8 @@ snapshots:
|
||||
|
||||
balanced-match@2.0.0: {}
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
base64-blob@1.4.1:
|
||||
dependencies:
|
||||
b64-to-blob: 1.2.19
|
||||
@@ -7924,9 +7948,9 @@ snapshots:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.0.2:
|
||||
brace-expansion@5.0.3:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
balanced-match: 4.0.4
|
||||
|
||||
braces@3.0.3:
|
||||
dependencies:
|
||||
@@ -8520,7 +8544,7 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
object.fromentries: 2.0.8
|
||||
object.groupby: 1.0.3
|
||||
object.values: 1.2.1
|
||||
@@ -8548,7 +8572,7 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
jsx-ast-utils: 3.3.5
|
||||
language-tags: 1.0.9
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
object.fromentries: 2.0.8
|
||||
safe-regex-test: 1.1.0
|
||||
string.prototype.includes: 2.0.1
|
||||
@@ -8569,7 +8593,7 @@ snapshots:
|
||||
estraverse: 5.3.0
|
||||
hasown: 2.0.2
|
||||
jsx-ast-utils: 3.3.5
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
object.entries: 1.1.9
|
||||
object.fromentries: 2.0.8
|
||||
object.values: 1.2.1
|
||||
@@ -8601,7 +8625,7 @@ snapshots:
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@nodelib/fs.walk': 1.2.8
|
||||
'@ungap/structured-clone': 1.3.0
|
||||
ajv: 6.12.6
|
||||
ajv: 6.14.0
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3
|
||||
@@ -8626,7 +8650,7 @@ snapshots:
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
levn: 0.4.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
strip-ansi: 6.0.1
|
||||
@@ -8874,7 +8898,7 @@ snapshots:
|
||||
fs.realpath: 1.0.0
|
||||
inflight: 1.0.6
|
||||
inherits: 2.0.4
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
|
||||
@@ -8932,7 +8956,7 @@ snapshots:
|
||||
cosmiconfig: 8.3.6(typescript@4.8.4)
|
||||
graphql: 16.11.0
|
||||
jiti: 2.6.1
|
||||
minimatch: 9.0.5
|
||||
minimatch: 9.0.8
|
||||
string-env-interpolation: 1.0.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
@@ -9700,13 +9724,13 @@ snapshots:
|
||||
|
||||
min-indent@1.0.1: {}
|
||||
|
||||
minimatch@3.1.2:
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
|
||||
minimatch@9.0.5:
|
||||
minimatch@9.0.8:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
brace-expansion: 5.0.3
|
||||
|
||||
minimist-options@4.1.0:
|
||||
dependencies:
|
||||
@@ -10497,8 +10521,8 @@ snapshots:
|
||||
schema-utils@2.7.1:
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
ajv: 6.12.6
|
||||
ajv-keywords: 3.5.2(ajv@6.12.6)
|
||||
ajv: 6.14.0
|
||||
ajv-keywords: 3.5.2(ajv@6.14.0)
|
||||
|
||||
scuid@1.1.0: {}
|
||||
|
||||
@@ -10827,7 +10851,7 @@ snapshots:
|
||||
|
||||
table@6.9.0:
|
||||
dependencies:
|
||||
ajv: 8.17.1
|
||||
ajv: 8.18.0
|
||||
lodash.truncate: 4.4.2
|
||||
slice-ansi: 4.0.0
|
||||
string-width: 4.2.3
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import Slider from "@ant-design/react-slick";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { UnsupportedCriterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { PopoverCard, WarningHoverPopover } from "../Shared/HoverPopover";
|
||||
|
||||
interface IProps {
|
||||
className?: string;
|
||||
isTouch: boolean;
|
||||
filter: ListFilterModel;
|
||||
heading: string;
|
||||
count: number;
|
||||
loading: boolean;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const FilteredRecommendationRow: React.FC<IProps> = PatchComponent(
|
||||
"FilteredRecommendationRow",
|
||||
(props) => {
|
||||
const cardCount = props.count;
|
||||
|
||||
const unsupportedCriteria = props.filter.criteria.filter(
|
||||
(criterion) => criterion instanceof UnsupportedCriterion
|
||||
);
|
||||
|
||||
const header = unsupportedCriteria.length ? (
|
||||
<div>
|
||||
<span>{props.heading}</span>
|
||||
<WarningHoverPopover
|
||||
placement="top"
|
||||
content={
|
||||
<PopoverCard>
|
||||
<FormattedMessage
|
||||
id="unsupported_criteria"
|
||||
values={{
|
||||
criteria: unsupportedCriteria
|
||||
.map((c) => c.criterionOption.type)
|
||||
.join(", "),
|
||||
}}
|
||||
/>
|
||||
</PopoverCard>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
props.heading
|
||||
);
|
||||
|
||||
if (!props.loading && !cardCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RecommendationRow
|
||||
className={props.className}
|
||||
header={header}
|
||||
link={
|
||||
<Link to={props.url}>
|
||||
<FormattedMessage id="view_all" />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<Slider
|
||||
{...getSlickSliderSettings(
|
||||
cardCount ? cardCount : props.filter.itemsPerPage,
|
||||
props.isTouch
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</Slider>
|
||||
</RecommendationRow>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -3,7 +3,7 @@ import { PatchComponent } from "src/patch";
|
||||
|
||||
interface IProps {
|
||||
className?: string;
|
||||
header: string;
|
||||
header: React.ReactNode;
|
||||
link: JSX.Element;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { ImageList } from "src/components/Images/ImageList";
|
||||
import { FilteredImageList } from "src/components/Images/ImageList";
|
||||
import { showWhenSelected } from "src/components/List/ItemList";
|
||||
import { mutateAddGalleryImages } from "src/core/StashService";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
@@ -100,7 +100,7 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = PatchComponent(
|
||||
];
|
||||
|
||||
return (
|
||||
<ImageList
|
||||
<FilteredImageList
|
||||
filterHook={filterHook}
|
||||
extraOperations={otherOperations}
|
||||
alterQuery={active}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { ImageList } from "src/components/Images/ImageList";
|
||||
import { FilteredImageList } from "src/components/Images/ImageList";
|
||||
import {
|
||||
mutateRemoveGalleryImages,
|
||||
mutateSetGalleryCover,
|
||||
@@ -142,7 +142,7 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> =
|
||||
];
|
||||
|
||||
return (
|
||||
<ImageList
|
||||
<FilteredImageList
|
||||
filterHook={filterHook}
|
||||
alterQuery={active}
|
||||
extraOperations={otherOperations}
|
||||
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
IItemListOperation,
|
||||
} from "../List/FilteredListToolbar";
|
||||
import { FilterTags } from "../List/FilterTags";
|
||||
import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter";
|
||||
import { PerformerAgeCriterionOption } from "src/models/list-filter/galleries";
|
||||
|
||||
const GalleryList: React.FC<{
|
||||
galleries: GQL.SlimGalleryDataFragment[];
|
||||
@@ -169,6 +171,14 @@ const SidebarContent: React.FC<{
|
||||
option={OrganizedCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sectionID="organized"
|
||||
/>
|
||||
<SidebarAgeFilter
|
||||
title={<FormattedMessage id="performer_age" />}
|
||||
option={PerformerAgeCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sectionID="performer_age"
|
||||
/>
|
||||
</GalleryFilterSidebarSections>
|
||||
|
||||
@@ -282,7 +292,7 @@ export const FilteredGalleryList = PatchComponent(
|
||||
setFilter,
|
||||
});
|
||||
|
||||
useAddKeybinds(filter, totalCount);
|
||||
useAddKeybinds(effectiveFilter, totalCount);
|
||||
useFilteredSidebarKeybinds({
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
@@ -313,7 +323,7 @@ export const FilteredGalleryList = PatchComponent(
|
||||
result,
|
||||
});
|
||||
|
||||
const viewRandom = useViewRandom(filter, totalCount);
|
||||
const viewRandom = useViewRandom(effectiveFilter, totalCount);
|
||||
|
||||
function onExport(all: boolean) {
|
||||
showModal(
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useFindGalleries } from "src/core/StashService";
|
||||
import Slider from "@ant-design/react-slick";
|
||||
import { GalleryCard } from "./GalleryCard";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
@@ -19,40 +15,29 @@ export const GalleryRecommendationRow: React.FC<IProps> = PatchComponent(
|
||||
"GalleryRecommendationRow",
|
||||
(props) => {
|
||||
const result = useFindGalleries(props.filter);
|
||||
const cardCount = result.data?.findGalleries.count;
|
||||
|
||||
if (!result.loading && !cardCount) {
|
||||
return null;
|
||||
}
|
||||
const count = result.data?.findGalleries.count ?? 0;
|
||||
|
||||
return (
|
||||
<RecommendationRow
|
||||
<FilteredRecommendationRow
|
||||
className="gallery-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<Link to={`/galleries?${props.filter.makeQueryParameters()}`}>
|
||||
<FormattedMessage id="view_all" />
|
||||
</Link>
|
||||
}
|
||||
heading={props.header}
|
||||
url={`/galleries?${props.filter.makeQueryParameters()}`}
|
||||
count={count}
|
||||
loading={result.loading}
|
||||
isTouch={props.isTouch}
|
||||
filter={props.filter}
|
||||
>
|
||||
<Slider
|
||||
{...getSlickSliderSettings(
|
||||
cardCount ? cardCount : props.filter.itemsPerPage,
|
||||
props.isTouch
|
||||
)}
|
||||
>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div
|
||||
key={`_${i}`}
|
||||
className="gallery-skeleton skeleton-card"
|
||||
></div>
|
||||
))
|
||||
: result.data?.findGalleries.galleries.map((g) => (
|
||||
<GalleryCard key={g.id} gallery={g} zoomIndex={1} />
|
||||
))}
|
||||
</Slider>
|
||||
</RecommendationRow>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div
|
||||
key={`_${i}`}
|
||||
className="gallery-skeleton skeleton-card"
|
||||
></div>
|
||||
))
|
||||
: result.data?.findGalleries.galleries.map((g) => (
|
||||
<GalleryCard key={g.id} gallery={g} zoomIndex={1} />
|
||||
))}
|
||||
</FilteredRecommendationRow>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -265,7 +265,7 @@ export const FilteredGroupList = PatchComponent(
|
||||
setFilter,
|
||||
});
|
||||
|
||||
useAddKeybinds(filter, totalCount);
|
||||
useAddKeybinds(effectiveFilter, totalCount);
|
||||
useFilteredSidebarKeybinds({
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
@@ -296,7 +296,7 @@ export const FilteredGroupList = PatchComponent(
|
||||
result,
|
||||
});
|
||||
|
||||
const viewRandom = useViewRandom(filter, totalCount);
|
||||
const viewRandom = useViewRandom(effectiveFilter, totalCount);
|
||||
|
||||
function onExport(all: boolean) {
|
||||
showModal(
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useFindGroups } from "src/core/StashService";
|
||||
import Slider from "@ant-design/react-slick";
|
||||
import { GroupCard } from "./GroupCard";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
@@ -19,40 +15,26 @@ export const GroupRecommendationRow: React.FC<IProps> = PatchComponent(
|
||||
"GroupRecommendationRow",
|
||||
(props: IProps) => {
|
||||
const result = useFindGroups(props.filter);
|
||||
const cardCount = result.data?.findGroups.count;
|
||||
|
||||
if (!result.loading && !cardCount) {
|
||||
return null;
|
||||
}
|
||||
const count = result.data?.findGroups.count ?? 0;
|
||||
|
||||
return (
|
||||
<RecommendationRow
|
||||
<FilteredRecommendationRow
|
||||
className="group-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<Link to={`/groups?${props.filter.makeQueryParameters()}`}>
|
||||
<FormattedMessage id="view_all" />
|
||||
</Link>
|
||||
}
|
||||
heading={props.header}
|
||||
url={`/groups?${props.filter.makeQueryParameters()}`}
|
||||
count={count}
|
||||
loading={result.loading}
|
||||
isTouch={props.isTouch}
|
||||
filter={props.filter}
|
||||
>
|
||||
<Slider
|
||||
{...getSlickSliderSettings(
|
||||
cardCount ? cardCount : props.filter.itemsPerPage,
|
||||
props.isTouch
|
||||
)}
|
||||
>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div
|
||||
key={`_${i}`}
|
||||
className="group-skeleton skeleton-card"
|
||||
></div>
|
||||
))
|
||||
: result.data?.findGroups.groups.map((g) => (
|
||||
<GroupCard key={g.id} group={g} />
|
||||
))}
|
||||
</Slider>
|
||||
</RecommendationRow>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div key={`_${i}`} className="group-skeleton skeleton-card"></div>
|
||||
))
|
||||
: result.data?.findGroups.groups.map((g) => (
|
||||
<GroupCard key={g.id} group={g} />
|
||||
))}
|
||||
</FilteredRecommendationRow>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import React, { useCallback, useState, useMemo, MouseEvent } from "react";
|
||||
import { FormattedNumber, useIntl } from "react-intl";
|
||||
import React, {
|
||||
useCallback,
|
||||
useState,
|
||||
useMemo,
|
||||
MouseEvent,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import Mousetrap from "mousetrap";
|
||||
@@ -9,11 +15,10 @@ import {
|
||||
useFindImages,
|
||||
useFindImagesMetadata,
|
||||
} from "src/core/StashService";
|
||||
import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
|
||||
import { useFilteredItemList } from "../List/ItemList";
|
||||
import { useLightbox } from "src/hooks/Lightbox/hooks";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
|
||||
import { ImageWallItem } from "./ImageWallItem";
|
||||
import { EditImagesDialog } from "./EditImagesDialog";
|
||||
import { DeleteImagesDialog } from "./DeleteImagesDialog";
|
||||
@@ -24,11 +29,43 @@ import { objectTitle } from "src/core/files";
|
||||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
import { ImageCardGrid } from "./ImageCardGrid";
|
||||
import { View } from "../List/views";
|
||||
import { IItemListOperation } from "../List/FilteredListToolbar";
|
||||
import {
|
||||
FilteredListToolbar,
|
||||
IItemListOperation,
|
||||
} from "../List/FilteredListToolbar";
|
||||
import { FileSize } from "../Shared/FileSize";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { PatchComponent, PatchContainerComponent } from "src/patch";
|
||||
import { GenerateDialog } from "../Dialogs/GenerateDialog";
|
||||
import { useModal } from "src/hooks/modal";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarPane,
|
||||
SidebarPaneContent,
|
||||
SidebarStateContext,
|
||||
useSidebarState,
|
||||
} from "../Shared/Sidebar";
|
||||
import { useCloseEditDelete, useFilterOperations } from "../List/util";
|
||||
import {
|
||||
FilteredSidebarHeader,
|
||||
useFilteredSidebarKeybinds,
|
||||
} from "../List/Filters/FilterSidebar";
|
||||
import {
|
||||
IListFilterOperation,
|
||||
ListOperations,
|
||||
} from "../List/ListOperationButtons";
|
||||
import { FilterTags } from "../List/FilterTags";
|
||||
import { Pagination, PaginationIndex } from "../List/Pagination";
|
||||
import { LoadedContent } from "../List/PagedList";
|
||||
import useFocus from "src/utils/focus";
|
||||
import cx from "classnames";
|
||||
import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter";
|
||||
import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter";
|
||||
import { SidebarTagsFilter } from "../List/Filters/TagsFilter";
|
||||
import { SidebarRatingFilter } from "../List/Filters/RatingFilter";
|
||||
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized";
|
||||
import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter";
|
||||
import { PerformerAgeCriterionOption } from "src/models/list-filter/images";
|
||||
|
||||
interface IImageWallProps {
|
||||
images: GQL.SlimImageDataFragment[];
|
||||
@@ -180,131 +217,125 @@ interface IImageListImages {
|
||||
chapters?: GQL.GalleryChapterDataFragment[];
|
||||
}
|
||||
|
||||
const ImageListImages: React.FC<IImageListImages> = ({
|
||||
images,
|
||||
filter,
|
||||
selectedIds,
|
||||
onChangePage,
|
||||
pageCount,
|
||||
onSelectChange,
|
||||
slideshowRunning,
|
||||
setSlideshowRunning,
|
||||
chapters = [],
|
||||
}) => {
|
||||
const handleLightBoxPage = useCallback(
|
||||
(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 + direction);
|
||||
}
|
||||
} else if (direction > 0) {
|
||||
if (filter.currentPage === pageCount) {
|
||||
// return to the first page
|
||||
onChangePage(1);
|
||||
} else {
|
||||
onChangePage(filter.currentPage + direction);
|
||||
}
|
||||
}
|
||||
} else if (newPage !== undefined) {
|
||||
onChangePage(newPage);
|
||||
}
|
||||
},
|
||||
[onChangePage, filter.currentPage, pageCount]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSlideshowRunning(false);
|
||||
}, [setSlideshowRunning]);
|
||||
|
||||
const lightboxState = useMemo(() => {
|
||||
return {
|
||||
images,
|
||||
showNavigation: false,
|
||||
pageCallback: pageCount > 1 ? handleLightBoxPage : undefined,
|
||||
page: filter.currentPage,
|
||||
pages: pageCount,
|
||||
pageSize: filter.itemsPerPage,
|
||||
slideshowEnabled: slideshowRunning,
|
||||
onClose: handleClose,
|
||||
};
|
||||
}, [
|
||||
const ImageList: React.FC<IImageListImages> = PatchComponent(
|
||||
"ImageList",
|
||||
({
|
||||
images,
|
||||
filter,
|
||||
selectedIds,
|
||||
onChangePage,
|
||||
pageCount,
|
||||
filter.currentPage,
|
||||
filter.itemsPerPage,
|
||||
onSelectChange,
|
||||
slideshowRunning,
|
||||
handleClose,
|
||||
handleLightBoxPage,
|
||||
]);
|
||||
setSlideshowRunning,
|
||||
chapters = [],
|
||||
}) => {
|
||||
const handleLightBoxPage = useCallback(
|
||||
(props: { direction?: number; page?: number }) => {
|
||||
const { direction, page: newPage } = props;
|
||||
|
||||
const showLightbox = useLightbox(
|
||||
lightboxState,
|
||||
filter.sortBy === "path" &&
|
||||
filter.sortDirection === GQL.SortDirectionEnum.Asc
|
||||
? chapters
|
||||
: []
|
||||
);
|
||||
|
||||
const handleImageOpen = useCallback(
|
||||
(index) => {
|
||||
setSlideshowRunning(true);
|
||||
showLightbox({ initialIndex: index, slideshowEnabled: true });
|
||||
},
|
||||
[showLightbox, setSlideshowRunning]
|
||||
);
|
||||
|
||||
function onPreview(index: number, ev: MouseEvent) {
|
||||
handleImageOpen(index);
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<ImageCardGrid
|
||||
images={images}
|
||||
selectedIds={selectedIds}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
onSelectChange={onSelectChange}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
if (direction !== undefined) {
|
||||
if (direction < 0) {
|
||||
if (filter.currentPage === 1) {
|
||||
onChangePage(pageCount);
|
||||
} else {
|
||||
onChangePage(filter.currentPage + direction);
|
||||
}
|
||||
} else if (direction > 0) {
|
||||
if (filter.currentPage === pageCount) {
|
||||
// return to the first page
|
||||
onChangePage(1);
|
||||
} else {
|
||||
onChangePage(filter.currentPage + direction);
|
||||
}
|
||||
}
|
||||
} else if (newPage !== undefined) {
|
||||
onChangePage(newPage);
|
||||
}
|
||||
},
|
||||
[onChangePage, filter.currentPage, pageCount]
|
||||
);
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Wall) {
|
||||
return (
|
||||
<ImageWall
|
||||
images={images}
|
||||
onChangePage={onChangePage}
|
||||
currentPage={filter.currentPage}
|
||||
pageCount={pageCount}
|
||||
handleImageOpen={handleImageOpen}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
selecting={!!selectedIds && selectedIds.size > 0}
|
||||
/>
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSlideshowRunning(false);
|
||||
}, [setSlideshowRunning]);
|
||||
|
||||
const lightboxState = useMemo(() => {
|
||||
return {
|
||||
images,
|
||||
showNavigation: false,
|
||||
pageCallback: pageCount > 1 ? handleLightBoxPage : undefined,
|
||||
page: filter.currentPage,
|
||||
pages: pageCount,
|
||||
pageSize: filter.itemsPerPage,
|
||||
slideshowEnabled: slideshowRunning,
|
||||
onClose: handleClose,
|
||||
};
|
||||
}, [
|
||||
images,
|
||||
pageCount,
|
||||
filter.currentPage,
|
||||
filter.itemsPerPage,
|
||||
slideshowRunning,
|
||||
handleClose,
|
||||
handleLightBoxPage,
|
||||
]);
|
||||
|
||||
const showLightbox = useLightbox(
|
||||
lightboxState,
|
||||
filter.sortBy === "path" &&
|
||||
filter.sortDirection === GQL.SortDirectionEnum.Asc
|
||||
? chapters
|
||||
: []
|
||||
);
|
||||
|
||||
const handleImageOpen = useCallback(
|
||||
(index) => {
|
||||
setSlideshowRunning(true);
|
||||
showLightbox({ initialIndex: index, slideshowEnabled: true });
|
||||
},
|
||||
[showLightbox, setSlideshowRunning]
|
||||
);
|
||||
|
||||
function onPreview(index: number, ev: MouseEvent) {
|
||||
handleImageOpen(index);
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<ImageCardGrid
|
||||
images={images}
|
||||
selectedIds={selectedIds}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
onSelectChange={onSelectChange}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Wall) {
|
||||
return (
|
||||
<ImageWall
|
||||
images={images}
|
||||
onChangePage={onChangePage}
|
||||
currentPage={filter.currentPage}
|
||||
pageCount={pageCount}
|
||||
handleImageOpen={handleImageOpen}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
selecting={!!selectedIds && selectedIds.size > 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// should not happen
|
||||
return <></>;
|
||||
}
|
||||
|
||||
// should not happen
|
||||
return <></>;
|
||||
};
|
||||
|
||||
function getItems(result: GQL.FindImagesQueryResult) {
|
||||
return result?.data?.findImages?.images ?? [];
|
||||
}
|
||||
|
||||
function getCount(result: GQL.FindImagesQueryResult) {
|
||||
return result?.data?.findImages?.count ?? 0;
|
||||
}
|
||||
);
|
||||
|
||||
function renderMetadataByline(
|
||||
result: GQL.FindImagesQueryResult,
|
||||
metadataInfo?: GQL.FindImagesMetadataQueryResult
|
||||
metadataInfo: GQL.FindImagesMetadataQueryResult | undefined
|
||||
) {
|
||||
const megapixels = metadataInfo?.data?.findImages?.megapixels;
|
||||
const size = metadataInfo?.data?.findImages?.filesize;
|
||||
@@ -339,6 +370,130 @@ function renderMetadataByline(
|
||||
);
|
||||
}
|
||||
|
||||
const ImageFilterSidebarSections = PatchContainerComponent(
|
||||
"FilteredImageList.SidebarSections"
|
||||
);
|
||||
|
||||
const SidebarContent: React.FC<{
|
||||
filter: ListFilterModel;
|
||||
setFilter: (filter: ListFilterModel) => void;
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
view?: View;
|
||||
sidebarOpen: boolean;
|
||||
onClose?: () => void;
|
||||
showEditFilter: (editingCriterion?: string) => void;
|
||||
count?: number;
|
||||
focus?: ReturnType<typeof useFocus>;
|
||||
}> = ({
|
||||
filter,
|
||||
setFilter,
|
||||
filterHook,
|
||||
view,
|
||||
showEditFilter,
|
||||
sidebarOpen,
|
||||
onClose,
|
||||
count,
|
||||
focus,
|
||||
}) => {
|
||||
const showResultsId =
|
||||
count !== undefined ? "actions.show_count_results" : "actions.show_results";
|
||||
|
||||
const hideStudios = view === View.StudioScenes;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilteredSidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
showEditFilter={showEditFilter}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
view={view}
|
||||
focus={focus}
|
||||
/>
|
||||
|
||||
<ImageFilterSidebarSections>
|
||||
{!hideStudios && (
|
||||
<SidebarStudiosFilter
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
/>
|
||||
)}
|
||||
<SidebarPerformersFilter
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
/>
|
||||
<SidebarTagsFilter
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
/>
|
||||
<SidebarRatingFilter filter={filter} setFilter={setFilter} />
|
||||
<SidebarBooleanFilter
|
||||
title={<FormattedMessage id="organized" />}
|
||||
data-type={OrganizedCriterionOption.type}
|
||||
option={OrganizedCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sectionID="organized"
|
||||
/>
|
||||
<SidebarAgeFilter
|
||||
title={<FormattedMessage id="performer_age" />}
|
||||
option={PerformerAgeCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sectionID="performer_age"
|
||||
/>
|
||||
</ImageFilterSidebarSections>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<Button className="sidebar-close-button" onClick={onClose}>
|
||||
<FormattedMessage id={showResultsId} values={{ count }} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function useViewRandom(filter: ListFilterModel, count: number) {
|
||||
const history = useHistory();
|
||||
|
||||
const viewRandom = useCallback(async () => {
|
||||
// query for a random image
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = Math.floor(Math.random() * count);
|
||||
const filterCopy = cloneDeep(filter);
|
||||
filterCopy.itemsPerPage = 1;
|
||||
filterCopy.currentPage = index + 1;
|
||||
const singleResult = await queryFindImages(filterCopy);
|
||||
if (singleResult.data.findImages.images.length === 1) {
|
||||
const { id } = singleResult.data.findImages.images[0];
|
||||
// navigate to the image player page
|
||||
history.push(`/images/${id}`);
|
||||
}
|
||||
}, [history, filter, count]);
|
||||
|
||||
return viewRandom;
|
||||
}
|
||||
|
||||
function useAddKeybinds(filter: ListFilterModel, count: number) {
|
||||
const viewRandom = useViewRandom(filter, count);
|
||||
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("p r", () => {
|
||||
viewRandom();
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("p r");
|
||||
};
|
||||
}, [viewRandom]);
|
||||
}
|
||||
|
||||
interface IImageList {
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
view?: View;
|
||||
@@ -347,28 +502,185 @@ interface IImageList {
|
||||
chapters?: GQL.GalleryChapterDataFragment[];
|
||||
}
|
||||
|
||||
export const ImageList: React.FC<IImageList> = PatchComponent(
|
||||
"ImageList",
|
||||
({ filterHook, view, alterQuery, extraOperations = [], chapters = [] }) => {
|
||||
export const FilteredImageList = PatchComponent(
|
||||
"FilteredImageList",
|
||||
(props: IImageList) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||
const [isExportAll, setIsExportAll] = useState(false);
|
||||
|
||||
const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false);
|
||||
|
||||
const filterMode = GQL.FilterMode.Images;
|
||||
const searchFocus = useFocus();
|
||||
|
||||
const { modal, showModal, closeModal } = useModal();
|
||||
const withSidebar = props.view !== View.GalleryImages;
|
||||
|
||||
const otherOperations: IItemListOperation<GQL.FindImagesQueryResult>[] = [
|
||||
...extraOperations,
|
||||
const {
|
||||
filterHook,
|
||||
view,
|
||||
alterQuery,
|
||||
extraOperations: providedOperations = [],
|
||||
chapters,
|
||||
} = props;
|
||||
|
||||
// States
|
||||
const {
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
sectionOpen,
|
||||
setSectionOpen,
|
||||
loading: sidebarStateLoading,
|
||||
} = useSidebarState(view);
|
||||
|
||||
const {
|
||||
filterState,
|
||||
queryResult,
|
||||
metadataInfo,
|
||||
modalState,
|
||||
listSelect,
|
||||
showEditFilter,
|
||||
} = useFilteredItemList({
|
||||
filterStateProps: {
|
||||
filterMode: GQL.FilterMode.Images,
|
||||
view,
|
||||
useURL: alterQuery,
|
||||
},
|
||||
queryResultProps: {
|
||||
useResult: useFindImages,
|
||||
useMetadataInfo: useFindImagesMetadata,
|
||||
getCount: (r) => r.data?.findImages.count ?? 0,
|
||||
getItems: (r) => r.data?.findImages.images ?? [],
|
||||
filterHook,
|
||||
},
|
||||
});
|
||||
|
||||
const { filter, setFilter } = filterState;
|
||||
|
||||
const { effectiveFilter, result, cachedResult, items, totalCount } =
|
||||
queryResult;
|
||||
|
||||
const metadataByline = useMemo(() => {
|
||||
if (cachedResult.loading) return null;
|
||||
|
||||
return renderMetadataByline(metadataInfo) ?? null;
|
||||
}, [cachedResult.loading, metadataInfo]);
|
||||
|
||||
const {
|
||||
selectedIds,
|
||||
selectedItems,
|
||||
onSelectChange,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
onInvertSelection,
|
||||
hasSelection,
|
||||
} = listSelect;
|
||||
|
||||
const { modal, showModal, closeModal } = modalState;
|
||||
|
||||
// Utility hooks
|
||||
const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({
|
||||
filter,
|
||||
setFilter,
|
||||
});
|
||||
|
||||
useAddKeybinds(filter, totalCount);
|
||||
useFilteredSidebarKeybinds({
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("e", () => {
|
||||
if (hasSelection) {
|
||||
onEdit?.();
|
||||
}
|
||||
});
|
||||
|
||||
Mousetrap.bind("d d", () => {
|
||||
if (hasSelection) {
|
||||
onDelete?.();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("e");
|
||||
Mousetrap.unbind("d d");
|
||||
};
|
||||
});
|
||||
|
||||
const onCloseEditDelete = useCloseEditDelete({
|
||||
closeModal,
|
||||
onSelectNone,
|
||||
result,
|
||||
});
|
||||
|
||||
const viewRandom = useViewRandom(effectiveFilter, totalCount);
|
||||
|
||||
function onExport(all: boolean) {
|
||||
showModal(
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
images: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: all,
|
||||
},
|
||||
}}
|
||||
onClose={() => closeModal()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function onEdit() {
|
||||
showModal(
|
||||
<EditImagesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
showModal(
|
||||
<DeleteImagesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const convertedExtraOperations: IListFilterOperation[] =
|
||||
providedOperations.map((o) => ({
|
||||
...o,
|
||||
isDisplayed: o.isDisplayed
|
||||
? () => o.isDisplayed!(result, filter, selectedIds)
|
||||
: undefined,
|
||||
onClick: () => {
|
||||
o.onClick(result, filter, selectedIds);
|
||||
},
|
||||
}));
|
||||
|
||||
const otherOperations: IListFilterOperation[] = [
|
||||
...convertedExtraOperations,
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.select_all" }),
|
||||
onClick: () => onSelectAll(),
|
||||
isDisplayed: () => totalCount > 0,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.select_none" }),
|
||||
onClick: () => onSelectNone(),
|
||||
isDisplayed: () => hasSelection,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.invert_selection" }),
|
||||
onClick: () => onInvertSelection(),
|
||||
isDisplayed: () => totalCount > 0,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.view_random" }),
|
||||
onClick: viewRandom,
|
||||
},
|
||||
{
|
||||
text: `${intl.formatMessage({ id: "actions.generate" })}…`,
|
||||
onClick: (result, filter, selectedIds) => {
|
||||
onClick: () => {
|
||||
showModal(
|
||||
<GenerateDialog
|
||||
type="image"
|
||||
@@ -376,101 +688,78 @@ export const ImageList: React.FC<IImageList> = PatchComponent(
|
||||
onClose={() => closeModal()}
|
||||
/>
|
||||
);
|
||||
return Promise.resolve();
|
||||
},
|
||||
isDisplayed: showWhenSelected,
|
||||
isDisplayed: () => hasSelection,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.export" }),
|
||||
onClick: onExport,
|
||||
isDisplayed: showWhenSelected,
|
||||
onClick: () => onExport(false),
|
||||
isDisplayed: () => hasSelection,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.export_all" }),
|
||||
onClick: onExportAll,
|
||||
onClick: () => onExport(true),
|
||||
},
|
||||
];
|
||||
|
||||
function addKeybinds(
|
||||
result: GQL.FindImagesQueryResult,
|
||||
filter: ListFilterModel
|
||||
) {
|
||||
Mousetrap.bind("p r", () => {
|
||||
viewRandom(result, filter);
|
||||
});
|
||||
// render
|
||||
if (sidebarStateLoading) return null;
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("p r");
|
||||
};
|
||||
}
|
||||
const operations = (
|
||||
<ListOperations
|
||||
items={items.length}
|
||||
hasSelection={hasSelection}
|
||||
operations={otherOperations}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
operationsMenuClassName="image-list-operations-dropdown"
|
||||
/>
|
||||
);
|
||||
|
||||
async function viewRandom(
|
||||
result: GQL.FindImagesQueryResult,
|
||||
filter: ListFilterModel
|
||||
) {
|
||||
// query for a random image
|
||||
if (result.data?.findImages) {
|
||||
const { count } = result.data.findImages;
|
||||
const pageCount = Math.ceil(totalCount / filter.itemsPerPage);
|
||||
|
||||
const index = Math.floor(Math.random() * count);
|
||||
const filterCopy = cloneDeep(filter);
|
||||
filterCopy.itemsPerPage = 1;
|
||||
filterCopy.currentPage = index + 1;
|
||||
const singleResult = await queryFindImages(filterCopy);
|
||||
if (singleResult.data.findImages.images.length === 1) {
|
||||
const { id } = singleResult.data.findImages.images[0];
|
||||
// navigate to the image player page
|
||||
history.push(`/images/${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const content = (
|
||||
<>
|
||||
<FilteredListToolbar
|
||||
filter={filter}
|
||||
listSelect={listSelect}
|
||||
setFilter={setFilter}
|
||||
showEditFilter={showEditFilter}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
operationComponent={operations}
|
||||
view={view}
|
||||
zoomable
|
||||
/>
|
||||
|
||||
async function onExport() {
|
||||
setIsExportAll(false);
|
||||
setIsExportDialogOpen(true);
|
||||
}
|
||||
<FilterTags
|
||||
criteria={filter.criteria}
|
||||
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
||||
onRemoveCriterion={removeCriterion}
|
||||
onRemoveAll={clearAllCriteria}
|
||||
/>
|
||||
|
||||
async function onExportAll() {
|
||||
setIsExportAll(true);
|
||||
setIsExportDialogOpen(true);
|
||||
}
|
||||
<div className="pagination-index-container">
|
||||
<Pagination
|
||||
currentPage={filter.currentPage}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
totalItems={totalCount}
|
||||
onChangePage={(page) => setFilter(filter.changePage(page))}
|
||||
/>
|
||||
<PaginationIndex
|
||||
loading={cachedResult.loading}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
/>
|
||||
</div>
|
||||
|
||||
function renderContent(
|
||||
result: GQL.FindImagesQueryResult,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>,
|
||||
onSelectChange: (
|
||||
id: string,
|
||||
selected: boolean,
|
||||
shiftKey: boolean
|
||||
) => void,
|
||||
onChangePage: (page: number) => void,
|
||||
pageCount: number
|
||||
) {
|
||||
function maybeRenderImageExportDialog() {
|
||||
if (isExportDialogOpen) {
|
||||
return (
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
images: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: isExportAll,
|
||||
},
|
||||
}}
|
||||
onClose={() => setIsExportDialogOpen(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderImages() {
|
||||
if (!result.data?.findImages) return;
|
||||
|
||||
return (
|
||||
<ImageListImages
|
||||
<LoadedContent loading={result.loading} error={result.error}>
|
||||
<ImageList
|
||||
filter={filter}
|
||||
images={result.data.findImages.images}
|
||||
onChangePage={onChangePage}
|
||||
images={items}
|
||||
onChangePage={(page) => setFilter(filter.changePage(page))}
|
||||
onSelectChange={onSelectChange}
|
||||
pageCount={pageCount}
|
||||
selectedIds={selectedIds}
|
||||
@@ -478,54 +767,60 @@ export const ImageList: React.FC<IImageList> = PatchComponent(
|
||||
setSlideshowRunning={setSlideshowRunning}
|
||||
chapters={chapters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
</LoadedContent>
|
||||
|
||||
return (
|
||||
<>
|
||||
{maybeRenderImageExportDialog()}
|
||||
{renderImages()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
{totalCount > filter.itemsPerPage && (
|
||||
<div className="pagination-footer-container">
|
||||
<div className="pagination-footer">
|
||||
<Pagination
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
onChangePage={setPage}
|
||||
pagePopupPlacement="top"
|
||||
metadataByline={metadataByline}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function renderEditDialog(
|
||||
selectedImages: GQL.SlimImageDataFragment[],
|
||||
onClose: (applied: boolean) => void
|
||||
) {
|
||||
return <EditImagesDialog selected={selectedImages} onClose={onClose} />;
|
||||
}
|
||||
|
||||
function renderDeleteDialog(
|
||||
selectedImages: GQL.SlimImageDataFragment[],
|
||||
onClose: (confirmed: boolean) => void
|
||||
) {
|
||||
return <DeleteImagesDialog selected={selectedImages} onClose={onClose} />;
|
||||
if (!withSidebar) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<ItemListContext
|
||||
filterMode={filterMode}
|
||||
useResult={useFindImages}
|
||||
useMetadataInfo={useFindImagesMetadata}
|
||||
getItems={getItems}
|
||||
getCount={getCount}
|
||||
alterQuery={alterQuery}
|
||||
filterHook={filterHook}
|
||||
view={view}
|
||||
selectable
|
||||
<div
|
||||
className={cx("item-list-container image-list", {
|
||||
"hide-sidebar": !showSidebar,
|
||||
})}
|
||||
>
|
||||
{modal}
|
||||
<ItemList
|
||||
view={view}
|
||||
otherOperations={otherOperations}
|
||||
addKeybinds={addKeybinds}
|
||||
renderContent={renderContent}
|
||||
renderEditDialog={renderEditDialog}
|
||||
renderDeleteDialog={renderDeleteDialog}
|
||||
renderMetadataByline={renderMetadataByline}
|
||||
/>
|
||||
</ItemListContext>
|
||||
|
||||
<SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>
|
||||
<SidebarPane hideSidebar={!showSidebar}>
|
||||
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
|
||||
<SidebarContent
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
showEditFilter={showEditFilter}
|
||||
view={view}
|
||||
sidebarOpen={showSidebar}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
count={cachedResult.loading ? undefined : totalCount}
|
||||
focus={searchFocus}
|
||||
/>
|
||||
</Sidebar>
|
||||
<SidebarPaneContent
|
||||
onSidebarToggle={() => setShowSidebar(!showSidebar)}
|
||||
>
|
||||
{content}
|
||||
</SidebarPaneContent>
|
||||
</SidebarPane>
|
||||
</SidebarStateContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useFindImages } from "src/core/StashService";
|
||||
import Slider from "@ant-design/react-slick";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { ImageCard } from "./ImageCard";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
@@ -19,40 +15,26 @@ export const ImageRecommendationRow: React.FC<IProps> = PatchComponent(
|
||||
"ImageRecommendationRow",
|
||||
(props: IProps) => {
|
||||
const result = useFindImages(props.filter);
|
||||
const cardCount = result.data?.findImages.count;
|
||||
|
||||
if (!result.loading && !cardCount) {
|
||||
return null;
|
||||
}
|
||||
const count = result.data?.findImages.count ?? 0;
|
||||
|
||||
return (
|
||||
<RecommendationRow
|
||||
<FilteredRecommendationRow
|
||||
className="images-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<Link to={`/images?${props.filter.makeQueryParameters()}`}>
|
||||
<FormattedMessage id="view_all" />
|
||||
</Link>
|
||||
}
|
||||
heading={props.header}
|
||||
url={`/images?${props.filter.makeQueryParameters()}`}
|
||||
count={count}
|
||||
loading={result.loading}
|
||||
isTouch={props.isTouch}
|
||||
filter={props.filter}
|
||||
>
|
||||
<Slider
|
||||
{...getSlickSliderSettings(
|
||||
cardCount ? cardCount : props.filter.itemsPerPage,
|
||||
props.isTouch
|
||||
)}
|
||||
>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div
|
||||
key={`_${i}`}
|
||||
className="image-skeleton skeleton-card"
|
||||
></div>
|
||||
))
|
||||
: result.data?.findImages.images.map((i) => (
|
||||
<ImageCard key={i.id} image={i} zoomIndex={1} />
|
||||
))}
|
||||
</Slider>
|
||||
</RecommendationRow>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div key={`_${i}`} className="image-skeleton skeleton-card"></div>
|
||||
))
|
||||
: result.data?.findImages.images.map((i) => (
|
||||
<ImageCard key={i.id} image={i} zoomIndex={1} />
|
||||
))}
|
||||
</FilteredRecommendationRow>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -3,11 +3,11 @@ import { Route, Switch } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTitleProps } from "src/hooks/title";
|
||||
import Image from "./ImageDetails/Image";
|
||||
import { ImageList } from "./ImageList";
|
||||
import { FilteredImageList } from "./ImageList";
|
||||
import { View } from "../List/views";
|
||||
|
||||
const Images: React.FC = () => {
|
||||
return <ImageList view={View.Images} />;
|
||||
return <FilteredImageList view={View.Images} />;
|
||||
};
|
||||
|
||||
const ImageRoutes: React.FC = () => {
|
||||
|
||||
@@ -6,10 +6,17 @@ import React, {
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Badge, BadgeProps, Button, Overlay, Popover } from "react-bootstrap";
|
||||
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||
import {
|
||||
Criterion,
|
||||
UnsupportedCriterion,
|
||||
} from "src/models/list-filter/criteria/criterion";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { faMagnifyingGlass, faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faExclamationTriangle,
|
||||
faMagnifyingGlass,
|
||||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { BsPrefixProps, ReplaceProps } from "react-bootstrap/esm/helpers";
|
||||
import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields";
|
||||
import { useDebounce } from "src/hooks/debounce";
|
||||
@@ -38,9 +45,20 @@ export const FilterTag: React.FC<{
|
||||
label: React.ReactNode;
|
||||
onClick: React.MouseEventHandler<HTMLSpanElement>;
|
||||
onRemove: React.MouseEventHandler<HTMLElement>;
|
||||
}> = ({ className, label, onClick, onRemove }) => {
|
||||
unsupported?: boolean;
|
||||
}> = ({ className, label, onClick, onRemove, unsupported }) => {
|
||||
function handleClick(e: React.MouseEvent<HTMLSpanElement, MouseEvent>) {
|
||||
if (unsupported) {
|
||||
return;
|
||||
}
|
||||
onClick(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<TagItem className={className} onClick={onClick}>
|
||||
<TagItem className={cx(className, { unsupported })} onClick={handleClick}>
|
||||
{unsupported && (
|
||||
<Icon icon={faExclamationTriangle} className="unsupported-icon" />
|
||||
)}
|
||||
{label}
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -271,10 +289,13 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
const unsupported = criterion instanceof UnsupportedCriterion;
|
||||
|
||||
return (
|
||||
<FilterTag
|
||||
key={criterion.getId()}
|
||||
label={criterion.getLabel(intl, sfwContentMode)}
|
||||
unsupported={unsupported}
|
||||
onClick={() => onClickCriterionTag(criterion)}
|
||||
onRemove={($event) => onRemoveCriterionTag(criterion, $event)}
|
||||
/>
|
||||
|
||||
@@ -20,10 +20,12 @@ import {
|
||||
FilterMode,
|
||||
GalleryFilterType,
|
||||
GroupFilterType,
|
||||
ImageFilterType,
|
||||
InputMaybe,
|
||||
IntCriterionInput,
|
||||
PerformerFilterType,
|
||||
SceneFilterType,
|
||||
SceneMarkerFilterType,
|
||||
StudioFilterType,
|
||||
} from "src/core/generated-graphql";
|
||||
import { useIntl } from "react-intl";
|
||||
@@ -523,10 +525,14 @@ interface IFilterType {
|
||||
performer_count?: InputMaybe<IntCriterionInput>;
|
||||
galleries_filter?: InputMaybe<GalleryFilterType>;
|
||||
gallery_count?: InputMaybe<IntCriterionInput>;
|
||||
images_filter?: InputMaybe<ImageFilterType>;
|
||||
image_count?: InputMaybe<IntCriterionInput>;
|
||||
groups_filter?: InputMaybe<GroupFilterType>;
|
||||
group_count?: InputMaybe<IntCriterionInput>;
|
||||
studios_filter?: InputMaybe<StudioFilterType>;
|
||||
studio_count?: InputMaybe<IntCriterionInput>;
|
||||
marker_count?: InputMaybe<IntCriterionInput>;
|
||||
markers_filter?: InputMaybe<SceneMarkerFilterType>;
|
||||
}
|
||||
|
||||
export function setObjectFilter(
|
||||
@@ -549,6 +555,7 @@ export function setObjectFilter(
|
||||
modifier: CriterionModifier.GreaterThan,
|
||||
value: 0,
|
||||
};
|
||||
break;
|
||||
}
|
||||
out.scenes_filter = relatedFilterOutput as SceneFilterType;
|
||||
break;
|
||||
@@ -559,6 +566,7 @@ export function setObjectFilter(
|
||||
modifier: CriterionModifier.GreaterThan,
|
||||
value: 0,
|
||||
};
|
||||
break;
|
||||
}
|
||||
out.performers_filter = relatedFilterOutput as PerformerFilterType;
|
||||
break;
|
||||
@@ -569,9 +577,21 @@ export function setObjectFilter(
|
||||
modifier: CriterionModifier.GreaterThan,
|
||||
value: 0,
|
||||
};
|
||||
break;
|
||||
}
|
||||
out.galleries_filter = relatedFilterOutput as GalleryFilterType;
|
||||
break;
|
||||
case FilterMode.Images:
|
||||
// if empty, only get objects with galleries
|
||||
if (empty) {
|
||||
out.image_count = {
|
||||
modifier: CriterionModifier.GreaterThan,
|
||||
value: 0,
|
||||
};
|
||||
break;
|
||||
}
|
||||
out.images_filter = relatedFilterOutput as ImageFilterType;
|
||||
break;
|
||||
case FilterMode.Groups:
|
||||
// if empty, only get objects with groups
|
||||
if (empty) {
|
||||
@@ -579,6 +599,7 @@ export function setObjectFilter(
|
||||
modifier: CriterionModifier.GreaterThan,
|
||||
value: 0,
|
||||
};
|
||||
break;
|
||||
}
|
||||
out.groups_filter = relatedFilterOutput as GroupFilterType;
|
||||
break;
|
||||
@@ -589,9 +610,21 @@ export function setObjectFilter(
|
||||
modifier: CriterionModifier.GreaterThan,
|
||||
value: 0,
|
||||
};
|
||||
break;
|
||||
}
|
||||
out.studios_filter = relatedFilterOutput as StudioFilterType;
|
||||
break;
|
||||
case FilterMode.SceneMarkers:
|
||||
// if empty, only get objects with scene markers
|
||||
if (empty) {
|
||||
out.marker_count = {
|
||||
modifier: CriterionModifier.GreaterThan,
|
||||
value: 0,
|
||||
};
|
||||
break;
|
||||
}
|
||||
out.markers_filter = relatedFilterOutput as SceneMarkerFilterType;
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid filter mode");
|
||||
}
|
||||
|
||||
@@ -1,33 +1,11 @@
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { QueryResult } from "@apollo/client";
|
||||
import { Criterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import {
|
||||
EditFilterDialog,
|
||||
useShowEditFilter,
|
||||
} from "src/components/List/EditFilterDialog";
|
||||
import { FilterTags } from "./FilterTags";
|
||||
import { View } from "./views";
|
||||
import { useShowEditFilter } from "src/components/List/EditFilterDialog";
|
||||
import { IHasID } from "src/utils/data";
|
||||
import {
|
||||
ListContext,
|
||||
QueryResultContext,
|
||||
useListContext,
|
||||
useQueryResultContext,
|
||||
} from "./ListProvider";
|
||||
import { FilterContext, SetFilterURL, useFilter } from "./FilterProvider";
|
||||
import { useModal } from "src/hooks/modal";
|
||||
import {
|
||||
IFilterStateHook,
|
||||
IQueryResultHook,
|
||||
useDefaultFilter,
|
||||
useEnsureValidPage,
|
||||
useFilterOperations,
|
||||
useFilterState,
|
||||
@@ -36,26 +14,23 @@ import {
|
||||
useQueryResult,
|
||||
useScrollToTopOnPageChange,
|
||||
} from "./util";
|
||||
import {
|
||||
FilteredListToolbar,
|
||||
IFilteredListToolbar,
|
||||
IItemListOperation,
|
||||
} from "./FilteredListToolbar";
|
||||
import { PagedList } from "./PagedList";
|
||||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
import { useZoomKeybinds } from "./ZoomSlider";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
|
||||
interface IFilteredItemList<T extends QueryResult, E extends IHasID = IHasID> {
|
||||
interface IFilteredItemList<
|
||||
T extends QueryResult,
|
||||
E extends IHasID = IHasID,
|
||||
M = unknown
|
||||
> {
|
||||
filterStateProps: IFilterStateHook;
|
||||
queryResultProps: IQueryResultHook<T, E>;
|
||||
queryResultProps: IQueryResultHook<T, E, M>;
|
||||
}
|
||||
|
||||
// Provides the common state and behaviour for filtered item list components
|
||||
export function useFilteredItemList<
|
||||
T extends QueryResult,
|
||||
E extends IHasID = IHasID
|
||||
>(props: IFilteredItemList<T, E>) {
|
||||
E extends IHasID = IHasID,
|
||||
M = unknown
|
||||
>(props: IFilteredItemList<T, E, M>) {
|
||||
const { configuration: config } = useConfigurationContext();
|
||||
|
||||
// States
|
||||
@@ -70,7 +45,7 @@ export function useFilteredItemList<
|
||||
filter,
|
||||
...props.queryResultProps,
|
||||
});
|
||||
const { result, items, totalCount, pages } = queryResult;
|
||||
const { result, items, totalCount, pages, metadataInfo } = queryResult;
|
||||
|
||||
const listSelect = useListSelect(items);
|
||||
const { onSelectAll, onSelectNone, onInvertSelection } = listSelect;
|
||||
@@ -107,352 +82,13 @@ export function useFilteredItemList<
|
||||
return {
|
||||
filterState,
|
||||
queryResult,
|
||||
metadataInfo,
|
||||
listSelect,
|
||||
modalState,
|
||||
showEditFilter,
|
||||
};
|
||||
}
|
||||
|
||||
interface IItemListProps<T extends QueryResult, E extends IHasID, M = unknown> {
|
||||
view?: View;
|
||||
otherOperations?: IItemListOperation<T>[];
|
||||
renderContent: (
|
||||
result: T,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>,
|
||||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void,
|
||||
onChangePage: (page: number) => void,
|
||||
pageCount: number
|
||||
) => React.ReactNode;
|
||||
renderMetadataByline?: (data: T, metadataInfo?: M) => React.ReactNode;
|
||||
renderEditDialog?: (
|
||||
selected: E[],
|
||||
onClose: (applied: boolean) => void
|
||||
) => React.ReactNode;
|
||||
renderDeleteDialog?: (
|
||||
selected: E[],
|
||||
onClose: (confirmed: boolean) => void
|
||||
) => React.ReactNode;
|
||||
addKeybinds?: (
|
||||
result: T,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>
|
||||
) => () => void;
|
||||
renderToolbar?: (props: IFilteredListToolbar) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const ItemList = <T extends QueryResult, E extends IHasID, M = unknown>(
|
||||
props: IItemListProps<T, E, M>
|
||||
) => {
|
||||
const {
|
||||
view,
|
||||
otherOperations,
|
||||
renderContent,
|
||||
renderEditDialog,
|
||||
renderDeleteDialog,
|
||||
renderMetadataByline,
|
||||
addKeybinds,
|
||||
renderToolbar: providedToolbar,
|
||||
} = props;
|
||||
|
||||
const { filter, setFilter: updateFilter } = useFilter();
|
||||
const { effectiveFilter, result, metadataInfo, cachedResult, totalCount } =
|
||||
useQueryResultContext<T, E, M>();
|
||||
const listSelect = useListContext<E>();
|
||||
const {
|
||||
selectedIds,
|
||||
getSelected,
|
||||
onSelectChange,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
onInvertSelection,
|
||||
} = listSelect;
|
||||
|
||||
// scroll to the top of the page when the page changes
|
||||
useScrollToTopOnPageChange(filter.currentPage, result.loading);
|
||||
|
||||
const { modal, showModal, closeModal } = useModal();
|
||||
|
||||
const metadataByline = useMemo(() => {
|
||||
if (cachedResult.loading) return "";
|
||||
|
||||
return renderMetadataByline?.(cachedResult, metadataInfo) ?? "";
|
||||
}, [renderMetadataByline, cachedResult, metadataInfo]);
|
||||
|
||||
const pages = Math.ceil(totalCount / filter.itemsPerPage);
|
||||
|
||||
const onChangePage = useCallback(
|
||||
(p: number) => {
|
||||
updateFilter(filter.changePage(p));
|
||||
},
|
||||
[filter, updateFilter]
|
||||
);
|
||||
|
||||
useEnsureValidPage(filter, totalCount, updateFilter);
|
||||
|
||||
const showEditFilter = useCallback(
|
||||
(editingCriterion?: string) => {
|
||||
function onApplyEditFilter(f: ListFilterModel) {
|
||||
closeModal();
|
||||
updateFilter(f);
|
||||
}
|
||||
|
||||
showModal(
|
||||
<EditFilterDialog
|
||||
filter={filter}
|
||||
onApply={onApplyEditFilter}
|
||||
onCancel={() => closeModal()}
|
||||
editingCriterion={editingCriterion}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[filter, updateFilter, showModal, closeModal]
|
||||
);
|
||||
|
||||
useListKeyboardShortcuts({
|
||||
currentPage: filter.currentPage,
|
||||
onChangePage,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
onInvertSelection,
|
||||
pages,
|
||||
showEditFilter,
|
||||
});
|
||||
|
||||
const zoomable =
|
||||
filter.displayMode === DisplayMode.Grid ||
|
||||
filter.displayMode === DisplayMode.Wall;
|
||||
|
||||
useZoomKeybinds({
|
||||
zoomIndex: zoomable ? filter.zoomIndex : undefined,
|
||||
onChangeZoom: (zoom) => updateFilter(filter.setZoom(zoom)),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (addKeybinds) {
|
||||
const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds);
|
||||
return () => {
|
||||
unbindExtras();
|
||||
};
|
||||
}
|
||||
}, [addKeybinds, result, effectiveFilter, selectedIds]);
|
||||
|
||||
const operations = useMemo(() => {
|
||||
async function onOperationClicked(o: IItemListOperation<T>) {
|
||||
await o.onClick(result, effectiveFilter, selectedIds);
|
||||
if (o.postRefetch) {
|
||||
result.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
return otherOperations?.map((o) => ({
|
||||
text: o.text,
|
||||
onClick: () => {
|
||||
onOperationClicked(o);
|
||||
},
|
||||
isDisplayed: () => {
|
||||
if (o.isDisplayed) {
|
||||
return o.isDisplayed(result, effectiveFilter, selectedIds);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
icon: o.icon,
|
||||
buttonVariant: o.buttonVariant,
|
||||
}));
|
||||
}, [result, effectiveFilter, selectedIds, otherOperations]);
|
||||
|
||||
function onEdit() {
|
||||
if (!renderEditDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
showModal(
|
||||
renderEditDialog(getSelected(), (applied) => onEditDialogClosed(applied))
|
||||
);
|
||||
}
|
||||
|
||||
function onEditDialogClosed(applied: boolean) {
|
||||
if (applied) {
|
||||
onSelectNone();
|
||||
}
|
||||
closeModal();
|
||||
|
||||
// refetch
|
||||
result.refetch();
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!renderDeleteDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
showModal(
|
||||
renderDeleteDialog(getSelected(), (deleted) =>
|
||||
onDeleteDialogClosed(deleted)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
if (deleted) {
|
||||
onSelectNone();
|
||||
}
|
||||
closeModal();
|
||||
|
||||
// refetch
|
||||
result.refetch();
|
||||
}
|
||||
|
||||
function onRemoveCriterion(removedCriterion: Criterion, valueIndex?: number) {
|
||||
if (valueIndex === undefined) {
|
||||
updateFilter(
|
||||
filter.removeCriterion(removedCriterion.criterionOption.type)
|
||||
);
|
||||
} else {
|
||||
updateFilter(
|
||||
filter.removeCustomFieldCriterion(
|
||||
removedCriterion.criterionOption.type,
|
||||
valueIndex
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onClearAllCriteria() {
|
||||
updateFilter(filter.clearCriteria());
|
||||
}
|
||||
|
||||
const filterListToolbarProps: IFilteredListToolbar = {
|
||||
filter,
|
||||
setFilter: updateFilter,
|
||||
listSelect,
|
||||
showEditFilter,
|
||||
view: view,
|
||||
operations: operations,
|
||||
zoomable: zoomable,
|
||||
onEdit: renderEditDialog ? onEdit : undefined,
|
||||
onDelete: renderDeleteDialog ? onDelete : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="item-list-container">
|
||||
{providedToolbar ? (
|
||||
providedToolbar(filterListToolbarProps)
|
||||
) : (
|
||||
<FilteredListToolbar {...filterListToolbarProps} />
|
||||
)}
|
||||
<FilterTags
|
||||
criteria={filter.criteria}
|
||||
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
||||
onRemoveCriterion={onRemoveCriterion}
|
||||
onRemoveAll={() => onClearAllCriteria()}
|
||||
/>
|
||||
{modal}
|
||||
|
||||
<PagedList
|
||||
result={result}
|
||||
cachedResult={cachedResult}
|
||||
filter={filter}
|
||||
totalCount={totalCount}
|
||||
onChangePage={onChangePage}
|
||||
metadataByline={metadataByline}
|
||||
>
|
||||
{renderContent(
|
||||
result,
|
||||
// #4780 - use effectiveFilter to ensure filterHook is applied
|
||||
effectiveFilter,
|
||||
selectedIds,
|
||||
onSelectChange,
|
||||
onChangePage,
|
||||
pages
|
||||
)}
|
||||
</PagedList>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IItemListContextProps<
|
||||
T extends QueryResult,
|
||||
E extends IHasID,
|
||||
M = unknown
|
||||
> {
|
||||
filterMode: GQL.FilterMode;
|
||||
defaultSort?: string;
|
||||
defaultFilter?: ListFilterModel;
|
||||
useResult: (filter: ListFilterModel) => T;
|
||||
useMetadataInfo?: (filter: ListFilterModel) => M;
|
||||
getCount: (data: T) => number;
|
||||
getItems: (data: T) => E[];
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
view?: View;
|
||||
alterQuery?: boolean;
|
||||
selectable?: boolean;
|
||||
}
|
||||
|
||||
// Provides the contexts for the ItemList component. Includes functionality to scroll
|
||||
// to top on page change.
|
||||
export const ItemListContext = <
|
||||
T extends QueryResult,
|
||||
E extends IHasID,
|
||||
M = unknown
|
||||
>(
|
||||
props: PropsWithChildren<IItemListContextProps<T, E, M>>
|
||||
) => {
|
||||
const {
|
||||
filterMode,
|
||||
defaultSort,
|
||||
defaultFilter: providedDefaultFilter,
|
||||
useResult,
|
||||
useMetadataInfo,
|
||||
getCount,
|
||||
getItems,
|
||||
view,
|
||||
filterHook,
|
||||
alterQuery = true,
|
||||
selectable,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
const { configuration: config } = useConfigurationContext();
|
||||
|
||||
const emptyFilter = useMemo(
|
||||
() =>
|
||||
providedDefaultFilter?.clone() ??
|
||||
new ListFilterModel(filterMode, config, {
|
||||
defaultSortBy: defaultSort,
|
||||
}),
|
||||
[config, filterMode, defaultSort, providedDefaultFilter]
|
||||
);
|
||||
|
||||
const [filter, setFilterState] = useState<ListFilterModel>(
|
||||
() =>
|
||||
new ListFilterModel(filterMode, config, { defaultSortBy: defaultSort })
|
||||
);
|
||||
|
||||
const { defaultFilter } = useDefaultFilter(emptyFilter, view);
|
||||
|
||||
return (
|
||||
<FilterContext filter={filter} setFilter={setFilterState}>
|
||||
<SetFilterURL defaultFilter={defaultFilter} setURL={alterQuery}>
|
||||
<QueryResultContext
|
||||
filterHook={filterHook}
|
||||
useResult={useResult}
|
||||
useMetadataInfo={useMetadataInfo}
|
||||
getCount={getCount}
|
||||
getItems={getItems}
|
||||
>
|
||||
{({ items }) => (
|
||||
<ListContext selectable={selectable} items={items}>
|
||||
{children}
|
||||
</ListContext>
|
||||
)}
|
||||
</QueryResultContext>
|
||||
</SetFilterURL>
|
||||
</FilterContext>
|
||||
);
|
||||
};
|
||||
|
||||
export const showWhenSelected = <T extends QueryResult>(
|
||||
result: T,
|
||||
filter: ListFilterModel,
|
||||
|
||||
@@ -470,6 +470,10 @@ input[type="range"].zoom-slider {
|
||||
line-height: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tag-item.unsupported {
|
||||
background-color: $warning;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
|
||||
@@ -509,23 +509,27 @@ export function useCachedQueryResult<T extends QueryResult>(
|
||||
|
||||
export interface IQueryResultHook<
|
||||
T extends QueryResult,
|
||||
E extends IHasID = IHasID
|
||||
E extends IHasID = IHasID,
|
||||
M = unknown
|
||||
> {
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
useResult: (filter: ListFilterModel) => T;
|
||||
useMetadataInfo?: (filter: ListFilterModel) => M;
|
||||
getCount: (data: T) => number;
|
||||
getItems: (data: T) => E[];
|
||||
}
|
||||
|
||||
export function useQueryResult<
|
||||
T extends QueryResult,
|
||||
E extends IHasID = IHasID
|
||||
E extends IHasID = IHasID,
|
||||
M = unknown
|
||||
>(
|
||||
props: IQueryResultHook<T, E> & {
|
||||
props: IQueryResultHook<T, E, M> & {
|
||||
filter: ListFilterModel;
|
||||
}
|
||||
) {
|
||||
const { filter, filterHook, useResult, getItems, getCount } = props;
|
||||
const { filter, filterHook, useResult, useMetadataInfo, getItems, getCount } =
|
||||
props;
|
||||
|
||||
const effectiveFilter = useMemo(() => {
|
||||
if (filterHook) {
|
||||
@@ -534,7 +538,14 @@ export function useQueryResult<
|
||||
return filter;
|
||||
}, [filter, filterHook]);
|
||||
|
||||
// metadata filter is the effective filter with the sort, page size and page number removed
|
||||
const metadataFilter = useMemo(
|
||||
() => effectiveFilter.metadataInfo(),
|
||||
[effectiveFilter]
|
||||
);
|
||||
|
||||
const result = useResult(effectiveFilter);
|
||||
const metadataInfo = useMetadataInfo?.(metadataFilter);
|
||||
|
||||
// use cached query result for pagination and metadata rendering
|
||||
const cachedResult = useCachedQueryResult(effectiveFilter, result);
|
||||
@@ -549,6 +560,7 @@ export function useQueryResult<
|
||||
|
||||
return {
|
||||
effectiveFilter,
|
||||
metadataInfo,
|
||||
result,
|
||||
cachedResult,
|
||||
items,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { ImageList } from "src/components/Images/ImageList";
|
||||
import { FilteredImageList } from "src/components/Images/ImageList";
|
||||
import { usePerformerFilterHook } from "src/core/performers";
|
||||
import { View } from "src/components/List/views";
|
||||
import { PatchComponent } from "src/patch";
|
||||
@@ -14,7 +14,7 @@ export const PerformerImagesPanel: React.FC<IPerformerImagesPanel> =
|
||||
PatchComponent("PerformerImagesPanel", ({ active, performer }) => {
|
||||
const filterHook = usePerformerFilterHook(performer);
|
||||
return (
|
||||
<ImageList
|
||||
<FilteredImageList
|
||||
filterHook={filterHook}
|
||||
alterQuery={active}
|
||||
view={View.PerformerImages}
|
||||
|
||||
@@ -423,7 +423,7 @@ export const FilteredPerformerList = PatchComponent(
|
||||
setFilter,
|
||||
});
|
||||
|
||||
useAddKeybinds(filter, totalCount);
|
||||
useAddKeybinds(effectiveFilter, totalCount);
|
||||
useFilteredSidebarKeybinds({
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
@@ -454,7 +454,7 @@ export const FilteredPerformerList = PatchComponent(
|
||||
result,
|
||||
});
|
||||
|
||||
const viewRandom = useViewRandom(filter, totalCount);
|
||||
const viewRandom = useViewRandom(effectiveFilter, totalCount);
|
||||
|
||||
function onExport(all: boolean) {
|
||||
showModal(
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useFindPerformers } from "src/core/StashService";
|
||||
import Slider from "@ant-design/react-slick";
|
||||
import { PerformerCard } from "./PerformerCard";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
@@ -19,40 +15,29 @@ export const PerformerRecommendationRow: React.FC<IProps> = PatchComponent(
|
||||
"PerformerRecommendationRow",
|
||||
(props) => {
|
||||
const result = useFindPerformers(props.filter);
|
||||
const cardCount = result.data?.findPerformers.count;
|
||||
|
||||
if (!result.loading && !cardCount) {
|
||||
return null;
|
||||
}
|
||||
const count = result.data?.findPerformers.count ?? 0;
|
||||
|
||||
return (
|
||||
<RecommendationRow
|
||||
<FilteredRecommendationRow
|
||||
className="performer-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<Link to={`/performers?${props.filter.makeQueryParameters()}`}>
|
||||
<FormattedMessage id="view_all" />
|
||||
</Link>
|
||||
}
|
||||
heading={props.header}
|
||||
url={`/performers?${props.filter.makeQueryParameters()}`}
|
||||
count={count}
|
||||
loading={result.loading}
|
||||
isTouch={props.isTouch}
|
||||
filter={props.filter}
|
||||
>
|
||||
<Slider
|
||||
{...getSlickSliderSettings(
|
||||
cardCount ? cardCount : props.filter.itemsPerPage,
|
||||
props.isTouch
|
||||
)}
|
||||
>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div
|
||||
key={`_${i}`}
|
||||
className="performer-skeleton skeleton-card"
|
||||
></div>
|
||||
))
|
||||
: result.data?.findPerformers.performers.map((p) => (
|
||||
<PerformerCard key={p.id} performer={p} />
|
||||
))}
|
||||
</Slider>
|
||||
</RecommendationRow>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div
|
||||
key={`_${i}`}
|
||||
className="performer-skeleton skeleton-card"
|
||||
></div>
|
||||
))
|
||||
: result.data?.findPerformers.performers.map((p) => (
|
||||
<PerformerCard key={p.id} performer={p} />
|
||||
))}
|
||||
</FilteredRecommendationRow>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -412,7 +412,7 @@ export const FilteredSceneList = PatchComponent(
|
||||
setFilter,
|
||||
});
|
||||
|
||||
useAddKeybinds(filter, totalCount);
|
||||
useAddKeybinds(effectiveFilter, totalCount);
|
||||
useFilteredSidebarKeybinds({
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import React from "react";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useIntl } from "react-intl";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import Mousetrap from "mousetrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useFindSceneMarkers,
|
||||
} from "src/core/StashService";
|
||||
import NavUtils from "src/utils/navigation";
|
||||
import { ItemList, ItemListContext } from "../List/ItemList";
|
||||
import { useFilteredItemList } from "../List/ItemList";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
import { MarkerWallPanel } from "./SceneMarkerWallPanel";
|
||||
@@ -17,17 +17,179 @@ import { View } from "../List/views";
|
||||
import { SceneMarkerCardGrid } from "./SceneMarkerCardGrid";
|
||||
import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog";
|
||||
import { EditSceneMarkersDialog } from "./EditSceneMarkersDialog";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { IItemListOperation } from "../List/FilteredListToolbar";
|
||||
import { PatchComponent, PatchContainerComponent } from "src/patch";
|
||||
import {
|
||||
FilteredListToolbar,
|
||||
IItemListOperation,
|
||||
} from "../List/FilteredListToolbar";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarPane,
|
||||
SidebarPaneContent,
|
||||
SidebarStateContext,
|
||||
useSidebarState,
|
||||
} from "../Shared/Sidebar";
|
||||
import { useCloseEditDelete, useFilterOperations } from "../List/util";
|
||||
import {
|
||||
FilteredSidebarHeader,
|
||||
useFilteredSidebarKeybinds,
|
||||
} from "../List/Filters/FilterSidebar";
|
||||
import { useZoomKeybinds } from "../List/ZoomSlider";
|
||||
import {
|
||||
IListFilterOperation,
|
||||
ListOperations,
|
||||
} from "../List/ListOperationButtons";
|
||||
import cx from "classnames";
|
||||
import { FilterTags } from "../List/FilterTags";
|
||||
import { Pagination, PaginationIndex } from "../List/Pagination";
|
||||
import { LoadedContent } from "../List/PagedList";
|
||||
import useFocus from "src/utils/focus";
|
||||
import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter";
|
||||
import { SidebarTagsFilter } from "../List/Filters/TagsFilter";
|
||||
import { Button } from "react-bootstrap";
|
||||
|
||||
function getItems(result: GQL.FindSceneMarkersQueryResult) {
|
||||
return result?.data?.findSceneMarkers?.scene_markers ?? [];
|
||||
const SceneMarkerList: React.FC<{
|
||||
markers: GQL.SceneMarkerDataFragment[];
|
||||
filter: ListFilterModel;
|
||||
selectedIds: Set<string>;
|
||||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
|
||||
}> = PatchComponent(
|
||||
"SceneMarkerList",
|
||||
({ markers, filter, selectedIds, onSelectChange }) => {
|
||||
if (markers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filter.displayMode === DisplayMode.Wall) {
|
||||
return (
|
||||
<MarkerWallPanel
|
||||
markers={markers}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<SceneMarkerCardGrid
|
||||
markers={markers}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
function usePlayRandom(filter: ListFilterModel, count: number) {
|
||||
const history = useHistory();
|
||||
|
||||
const playRandom = useCallback(async () => {
|
||||
// query for a random scene
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pages = Math.ceil(count / filter.itemsPerPage);
|
||||
const page = Math.floor(Math.random() * pages) + 1;
|
||||
|
||||
const indexMax = Math.min(filter.itemsPerPage, count);
|
||||
const index = Math.floor(Math.random() * indexMax);
|
||||
const filterCopy = cloneDeep(filter);
|
||||
filterCopy.currentPage = page;
|
||||
filterCopy.sortBy = "random";
|
||||
const queryResults = await queryFindSceneMarkers(filterCopy);
|
||||
const marker = queryResults.data.findSceneMarkers.scene_markers[index];
|
||||
if (marker) {
|
||||
// navigate to the scene player page
|
||||
const url = NavUtils.makeSceneMarkerUrl(marker);
|
||||
history.push(url);
|
||||
}
|
||||
}, [filter, count, history]);
|
||||
|
||||
return playRandom;
|
||||
}
|
||||
|
||||
function getCount(result: GQL.FindSceneMarkersQueryResult) {
|
||||
return result?.data?.findSceneMarkers?.count ?? 0;
|
||||
function useAddKeybinds(filter: ListFilterModel, count: number) {
|
||||
const playRandom = usePlayRandom(filter, count);
|
||||
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("p r", () => {
|
||||
playRandom();
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("p r");
|
||||
};
|
||||
}, [playRandom]);
|
||||
}
|
||||
|
||||
const ScenesFilterSidebarSections = PatchContainerComponent(
|
||||
"FilteredSceneMarkerList.SidebarSections"
|
||||
);
|
||||
|
||||
const SidebarContent: React.FC<{
|
||||
filter: ListFilterModel;
|
||||
setFilter: (filter: ListFilterModel) => void;
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
view?: View;
|
||||
sidebarOpen: boolean;
|
||||
onClose?: () => void;
|
||||
showEditFilter: (editingCriterion?: string) => void;
|
||||
count?: number;
|
||||
focus?: ReturnType<typeof useFocus>;
|
||||
}> = ({
|
||||
filter,
|
||||
setFilter,
|
||||
filterHook,
|
||||
view,
|
||||
showEditFilter,
|
||||
sidebarOpen,
|
||||
onClose,
|
||||
count,
|
||||
focus,
|
||||
}) => {
|
||||
const showResultsId =
|
||||
count !== undefined ? "actions.show_count_results" : "actions.show_results";
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilteredSidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
showEditFilter={showEditFilter}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
view={view}
|
||||
focus={focus}
|
||||
/>
|
||||
|
||||
<ScenesFilterSidebarSections>
|
||||
<SidebarPerformersFilter
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
/>
|
||||
<SidebarTagsFilter
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
/>
|
||||
</ScenesFilterSidebarSections>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<Button className="sidebar-close-button" onClick={onClose}>
|
||||
<FormattedMessage id={showResultsId} values={{ count }} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISceneMarkerList {
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
view?: View;
|
||||
@@ -36,132 +198,274 @@ interface ISceneMarkerList {
|
||||
extraOperations?: IItemListOperation<GQL.FindSceneMarkersQueryResult>[];
|
||||
}
|
||||
|
||||
export const SceneMarkerList: React.FC<ISceneMarkerList> = PatchComponent(
|
||||
"SceneMarkerList",
|
||||
({ filterHook, view, alterQuery, extraOperations = [] }) => {
|
||||
export const FilteredSceneMarkerList = PatchComponent(
|
||||
"FilteredSceneMarkerList",
|
||||
(props: ISceneMarkerList) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const filterMode = GQL.FilterMode.SceneMarkers;
|
||||
const searchFocus = useFocus();
|
||||
|
||||
const otherOperations = [
|
||||
...extraOperations,
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.play_random" }),
|
||||
onClick: playRandom,
|
||||
},
|
||||
];
|
||||
const {
|
||||
filterHook,
|
||||
defaultSort,
|
||||
view,
|
||||
alterQuery,
|
||||
extraOperations = [],
|
||||
} = props;
|
||||
|
||||
function addKeybinds(
|
||||
result: GQL.FindSceneMarkersQueryResult,
|
||||
filter: ListFilterModel
|
||||
) {
|
||||
Mousetrap.bind("p r", () => {
|
||||
playRandom(result, filter);
|
||||
// States
|
||||
const {
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
loading: sidebarStateLoading,
|
||||
sectionOpen,
|
||||
setSectionOpen,
|
||||
} = useSidebarState(view);
|
||||
|
||||
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
|
||||
useFilteredItemList({
|
||||
filterStateProps: {
|
||||
filterMode: GQL.FilterMode.SceneMarkers,
|
||||
defaultSort,
|
||||
view,
|
||||
useURL: alterQuery,
|
||||
},
|
||||
queryResultProps: {
|
||||
useResult: useFindSceneMarkers,
|
||||
getCount: (r) => r.data?.findSceneMarkers.count ?? 0,
|
||||
getItems: (r) => r.data?.findSceneMarkers.scene_markers ?? [],
|
||||
filterHook,
|
||||
},
|
||||
});
|
||||
|
||||
const { filter, setFilter } = filterState;
|
||||
|
||||
const { effectiveFilter, result, cachedResult, items, totalCount } =
|
||||
queryResult;
|
||||
|
||||
const {
|
||||
selectedIds,
|
||||
selectedItems,
|
||||
onSelectChange,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
onInvertSelection,
|
||||
hasSelection,
|
||||
} = listSelect;
|
||||
|
||||
const { modal, showModal, closeModal } = modalState;
|
||||
|
||||
// Utility hooks
|
||||
const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({
|
||||
filter,
|
||||
setFilter,
|
||||
});
|
||||
|
||||
useAddKeybinds(filter, totalCount);
|
||||
useFilteredSidebarKeybinds({
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
});
|
||||
|
||||
const onCloseEditDelete = useCloseEditDelete({
|
||||
closeModal,
|
||||
onSelectNone,
|
||||
result,
|
||||
});
|
||||
|
||||
const onEdit = useCallback(() => {
|
||||
showModal(
|
||||
<EditSceneMarkersDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
);
|
||||
}, [showModal, selectedItems, onCloseEditDelete]);
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
showModal(
|
||||
<DeleteSceneMarkersDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
);
|
||||
}, [showModal, selectedItems, onCloseEditDelete]);
|
||||
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("e", () => {
|
||||
if (hasSelection) {
|
||||
onEdit?.();
|
||||
}
|
||||
});
|
||||
|
||||
Mousetrap.bind("d d", () => {
|
||||
if (hasSelection) {
|
||||
onDelete?.();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("p r");
|
||||
Mousetrap.unbind("e");
|
||||
Mousetrap.unbind("d d");
|
||||
};
|
||||
}
|
||||
}, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]);
|
||||
|
||||
async function playRandom(
|
||||
result: GQL.FindSceneMarkersQueryResult,
|
||||
filter: ListFilterModel
|
||||
) {
|
||||
// query for a random scene
|
||||
if (result.data?.findSceneMarkers) {
|
||||
const { count } = result.data.findSceneMarkers;
|
||||
useZoomKeybinds({
|
||||
zoomIndex: filter.zoomIndex,
|
||||
onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)),
|
||||
});
|
||||
|
||||
const index = Math.floor(Math.random() * count);
|
||||
const filterCopy = cloneDeep(filter);
|
||||
filterCopy.itemsPerPage = 1;
|
||||
filterCopy.currentPage = index + 1;
|
||||
const singleResult = await queryFindSceneMarkers(filterCopy);
|
||||
if (singleResult.data.findSceneMarkers.scene_markers.length === 1) {
|
||||
// navigate to the scene player page
|
||||
const url = NavUtils.makeSceneMarkerUrl(
|
||||
singleResult.data.findSceneMarkers.scene_markers[0]
|
||||
);
|
||||
history.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
const playRandom = usePlayRandom(effectiveFilter, totalCount);
|
||||
|
||||
function renderContent(
|
||||
result: GQL.FindSceneMarkersQueryResult,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>,
|
||||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
|
||||
) {
|
||||
if (!result.data?.findSceneMarkers) return;
|
||||
const convertedExtraOperations: IListFilterOperation[] =
|
||||
extraOperations.map((o) => ({
|
||||
...o,
|
||||
isDisplayed: o.isDisplayed
|
||||
? () => o.isDisplayed!(result, filter, selectedIds)
|
||||
: undefined,
|
||||
onClick: () => {
|
||||
o.onClick(result, filter, selectedIds);
|
||||
},
|
||||
}));
|
||||
|
||||
if (filter.displayMode === DisplayMode.Wall) {
|
||||
return (
|
||||
<MarkerWallPanel
|
||||
markers={result.data.findSceneMarkers.scene_markers}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const otherOperations = [
|
||||
...convertedExtraOperations,
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.select_all" }),
|
||||
onClick: () => onSelectAll(),
|
||||
isDisplayed: () => totalCount > 0,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.select_none" }),
|
||||
onClick: () => onSelectNone(),
|
||||
isDisplayed: () => hasSelection,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.invert_selection" }),
|
||||
onClick: () => onInvertSelection(),
|
||||
isDisplayed: () => totalCount > 0,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.play_random" }),
|
||||
onClick: playRandom,
|
||||
isDisplayed: () => totalCount > 1,
|
||||
},
|
||||
// {
|
||||
// text: `${intl.formatMessage({ id: "actions.generate" })}…`,
|
||||
// onClick: () =>
|
||||
// showModal(
|
||||
// <GenerateDialog
|
||||
// type="scene"
|
||||
// selectedIds={Array.from(selectedIds.values())}
|
||||
// onClose={() => closeModal()}
|
||||
// />
|
||||
// ),
|
||||
// isDisplayed: () => hasSelection,
|
||||
// },
|
||||
];
|
||||
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<SceneMarkerCardGrid
|
||||
markers={result.data.findSceneMarkers.scene_markers}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
// render
|
||||
if (sidebarStateLoading) return null;
|
||||
|
||||
function renderEditDialog(
|
||||
selectedMarkers: GQL.SceneMarkerDataFragment[],
|
||||
onClose: (applied: boolean) => void
|
||||
) {
|
||||
return (
|
||||
<EditSceneMarkersDialog selected={selectedMarkers} onClose={onClose} />
|
||||
);
|
||||
}
|
||||
|
||||
function renderDeleteDialog(
|
||||
selectedSceneMarkers: GQL.SceneMarkerDataFragment[],
|
||||
onClose: (confirmed: boolean) => void
|
||||
) {
|
||||
return (
|
||||
<DeleteSceneMarkersDialog
|
||||
selected={selectedSceneMarkers}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const operations = (
|
||||
<ListOperations
|
||||
items={items.length}
|
||||
hasSelection={hasSelection}
|
||||
operations={otherOperations}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
operationsMenuClassName="scene-marker-list-operations-dropdown"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ItemListContext
|
||||
filterMode={filterMode}
|
||||
useResult={useFindSceneMarkers}
|
||||
getItems={getItems}
|
||||
getCount={getCount}
|
||||
alterQuery={alterQuery}
|
||||
filterHook={filterHook}
|
||||
view={view}
|
||||
selectable
|
||||
<div
|
||||
className={cx("item-list-container scene-list", {
|
||||
"hide-sidebar": !showSidebar,
|
||||
})}
|
||||
>
|
||||
<ItemList
|
||||
view={view}
|
||||
otherOperations={otherOperations}
|
||||
addKeybinds={addKeybinds}
|
||||
renderContent={renderContent}
|
||||
renderEditDialog={renderEditDialog}
|
||||
renderDeleteDialog={renderDeleteDialog}
|
||||
/>
|
||||
</ItemListContext>
|
||||
{modal}
|
||||
|
||||
<SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>
|
||||
<SidebarPane hideSidebar={!showSidebar}>
|
||||
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
|
||||
<SidebarContent
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
showEditFilter={showEditFilter}
|
||||
view={view}
|
||||
sidebarOpen={showSidebar}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
count={cachedResult.loading ? undefined : totalCount}
|
||||
focus={searchFocus}
|
||||
/>
|
||||
</Sidebar>
|
||||
<SidebarPaneContent
|
||||
onSidebarToggle={() => setShowSidebar(!showSidebar)}
|
||||
>
|
||||
<FilteredListToolbar
|
||||
filter={filter}
|
||||
listSelect={listSelect}
|
||||
setFilter={setFilter}
|
||||
showEditFilter={showEditFilter}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
operationComponent={operations}
|
||||
view={view}
|
||||
zoomable
|
||||
/>
|
||||
|
||||
<FilterTags
|
||||
criteria={filter.criteria}
|
||||
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
||||
onRemoveCriterion={removeCriterion}
|
||||
onRemoveAll={clearAllCriteria}
|
||||
/>
|
||||
|
||||
<div className="pagination-index-container">
|
||||
<Pagination
|
||||
currentPage={filter.currentPage}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
totalItems={totalCount}
|
||||
onChangePage={(page) => setFilter(filter.changePage(page))}
|
||||
/>
|
||||
<PaginationIndex
|
||||
loading={cachedResult.loading}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LoadedContent loading={result.loading} error={result.error}>
|
||||
<SceneMarkerList
|
||||
filter={effectiveFilter}
|
||||
markers={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
/>
|
||||
</LoadedContent>
|
||||
|
||||
{totalCount > filter.itemsPerPage && (
|
||||
<div className="pagination-footer-container">
|
||||
<div className="pagination-footer">
|
||||
<Pagination
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
onChangePage={setPage}
|
||||
pagePopupPlacement="top"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SidebarPaneContent>
|
||||
</SidebarPane>
|
||||
</SidebarStateContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default SceneMarkerList;
|
||||
export default FilteredSceneMarkerList;
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useFindSceneMarkers } from "src/core/StashService";
|
||||
import Slider from "@ant-design/react-slick";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { SceneMarkerCard } from "./SceneMarkerCard";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
@@ -19,47 +15,34 @@ export const SceneMarkerRecommendationRow: React.FC<IProps> = PatchComponent(
|
||||
"SceneMarkerRecommendationRow",
|
||||
(props) => {
|
||||
const result = useFindSceneMarkers(props.filter);
|
||||
const cardCount = result.data?.findSceneMarkers.count;
|
||||
|
||||
if (!result.loading && !cardCount) {
|
||||
return null;
|
||||
}
|
||||
const count = result.data?.findSceneMarkers.count ?? 0;
|
||||
|
||||
return (
|
||||
<RecommendationRow
|
||||
<FilteredRecommendationRow
|
||||
className="scene-marker-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<Link to={`/scenes/markers?${props.filter.makeQueryParameters()}`}>
|
||||
<FormattedMessage id="view_all" />
|
||||
</Link>
|
||||
}
|
||||
heading={props.header}
|
||||
url={`/scenes/markers?${props.filter.makeQueryParameters()}`}
|
||||
count={count}
|
||||
loading={result.loading}
|
||||
isTouch={props.isTouch}
|
||||
filter={props.filter}
|
||||
>
|
||||
<Slider
|
||||
{...getSlickSliderSettings(
|
||||
cardCount ? cardCount : props.filter.itemsPerPage,
|
||||
props.isTouch
|
||||
)}
|
||||
>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div
|
||||
key={`_${i}`}
|
||||
className="scene-marker-skeleton skeleton-card"
|
||||
></div>
|
||||
))
|
||||
: result.data?.findSceneMarkers.scene_markers.map(
|
||||
(marker, index) => (
|
||||
<SceneMarkerCard
|
||||
key={marker.id}
|
||||
marker={marker}
|
||||
index={index}
|
||||
zoomIndex={1}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Slider>
|
||||
</RecommendationRow>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div
|
||||
key={`_${i}`}
|
||||
className="scene-marker-skeleton skeleton-card"
|
||||
></div>
|
||||
))
|
||||
: result.data?.findSceneMarkers.scene_markers.map((marker, index) => (
|
||||
<SceneMarkerCard
|
||||
key={marker.id}
|
||||
marker={marker}
|
||||
index={index}
|
||||
zoomIndex={1}
|
||||
/>
|
||||
))}
|
||||
</FilteredRecommendationRow>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useFindScenes } from "src/core/StashService";
|
||||
import Slider from "@ant-design/react-slick";
|
||||
import { SceneCard } from "./SceneCard";
|
||||
import { SceneQueue } from "src/models/sceneQueue";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
@@ -20,50 +16,36 @@ export const SceneRecommendationRow: React.FC<IProps> = PatchComponent(
|
||||
"SceneRecommendationRow",
|
||||
(props) => {
|
||||
const result = useFindScenes(props.filter);
|
||||
const cardCount = result.data?.findScenes.count;
|
||||
const count = result.data?.findScenes.count ?? 0;
|
||||
|
||||
const queue = useMemo(() => {
|
||||
return SceneQueue.fromListFilterModel(props.filter);
|
||||
}, [props.filter]);
|
||||
|
||||
if (!result.loading && !cardCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RecommendationRow
|
||||
<FilteredRecommendationRow
|
||||
className="scene-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<Link to={`/scenes?${props.filter.makeQueryParameters()}`}>
|
||||
<FormattedMessage id="view_all" />
|
||||
</Link>
|
||||
}
|
||||
heading={props.header}
|
||||
url={`/scenes?${props.filter.makeQueryParameters()}`}
|
||||
count={count}
|
||||
loading={result.loading}
|
||||
isTouch={props.isTouch}
|
||||
filter={props.filter}
|
||||
>
|
||||
<Slider
|
||||
{...getSlickSliderSettings(
|
||||
cardCount ? cardCount : props.filter.itemsPerPage,
|
||||
props.isTouch
|
||||
)}
|
||||
>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div
|
||||
key={`_${i}`}
|
||||
className="scene-skeleton skeleton-card"
|
||||
></div>
|
||||
))
|
||||
: result.data?.findScenes.scenes.map((scene, index) => (
|
||||
<SceneCard
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
queue={queue}
|
||||
index={index}
|
||||
zoomIndex={1}
|
||||
/>
|
||||
))}
|
||||
</Slider>
|
||||
</RecommendationRow>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div key={`_${i}`} className="scene-skeleton skeleton-card"></div>
|
||||
))
|
||||
: result.data?.findScenes.scenes.map((scene, index) => (
|
||||
<SceneCard
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
queue={queue}
|
||||
index={index}
|
||||
zoomIndex={1}
|
||||
/>
|
||||
))}
|
||||
</FilteredRecommendationRow>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Overlay, Popover, OverlayProps } from "react-bootstrap";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { Icon } from "./Icon";
|
||||
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IHoverPopover {
|
||||
enterDelay?: number;
|
||||
@@ -85,3 +87,20 @@ export const HoverPopover: React.FC<IHoverPopover> = PatchComponent(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// convenience component to set the padding on popover content
|
||||
export const PopoverCard: React.FC<{ className?: string }> = ({
|
||||
className,
|
||||
children,
|
||||
}) => {
|
||||
return <div className={`popover-card ${className}`}>{children}</div>;
|
||||
};
|
||||
|
||||
export const WarningHoverPopover: React.FC<IHoverPopover> = PatchComponent(
|
||||
"WarningHoverPopover",
|
||||
({ children, ...props }) => (
|
||||
<HoverPopover {...props} className="warning-hover-popover">
|
||||
<Icon icon={faExclamationTriangle} />
|
||||
</HoverPopover>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -233,6 +233,19 @@ button.collapse-button {
|
||||
.hover-popover-content {
|
||||
max-width: 32rem;
|
||||
text-align: center;
|
||||
|
||||
.popover-card {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.warning-hover-popover {
|
||||
display: inline-flex;
|
||||
margin: 0 0.25rem;
|
||||
|
||||
.fa-icon {
|
||||
color: $warning;
|
||||
}
|
||||
}
|
||||
|
||||
.ErrorMessage-container {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useStudioFilterHook } from "src/core/studios";
|
||||
import { ImageList } from "src/components/Images/ImageList";
|
||||
import { FilteredImageList } from "src/components/Images/ImageList";
|
||||
import { View } from "src/components/List/views";
|
||||
|
||||
interface IStudioImagesPanel {
|
||||
@@ -17,7 +17,7 @@ export const StudioImagesPanel: React.FC<IStudioImagesPanel> = ({
|
||||
}) => {
|
||||
const filterHook = useStudioFilterHook(studio, showChildStudioContent);
|
||||
return (
|
||||
<ImageList
|
||||
<FilteredImageList
|
||||
filterHook={filterHook}
|
||||
alterQuery={active}
|
||||
view={View.StudioImages}
|
||||
|
||||
@@ -251,7 +251,7 @@ export const FilteredStudioList = PatchComponent(
|
||||
setFilter,
|
||||
});
|
||||
|
||||
useAddKeybinds(filter, totalCount);
|
||||
useAddKeybinds(effectiveFilter, totalCount);
|
||||
useFilteredSidebarKeybinds({
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
@@ -282,7 +282,7 @@ export const FilteredStudioList = PatchComponent(
|
||||
result,
|
||||
});
|
||||
|
||||
const viewRandom = useViewRandom(filter, totalCount);
|
||||
const viewRandom = useViewRandom(effectiveFilter, totalCount);
|
||||
|
||||
function onExport(all: boolean) {
|
||||
showModal(
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useFindStudios } from "src/core/StashService";
|
||||
import Slider from "@ant-design/react-slick";
|
||||
import { StudioCard } from "./StudioCard";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
@@ -19,40 +15,29 @@ export const StudioRecommendationRow: React.FC<IProps> = PatchComponent(
|
||||
"StudioRecommendationRow",
|
||||
(props) => {
|
||||
const result = useFindStudios(props.filter);
|
||||
const cardCount = result.data?.findStudios.count;
|
||||
|
||||
if (!result.loading && !cardCount) {
|
||||
return null;
|
||||
}
|
||||
const count = result.data?.findStudios.count ?? 0;
|
||||
|
||||
return (
|
||||
<RecommendationRow
|
||||
<FilteredRecommendationRow
|
||||
className="studio-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<Link to={`/studios?${props.filter.makeQueryParameters()}`}>
|
||||
<FormattedMessage id="view_all" />
|
||||
</Link>
|
||||
}
|
||||
heading={props.header}
|
||||
url={`/studios?${props.filter.makeQueryParameters()}`}
|
||||
count={count}
|
||||
loading={result.loading}
|
||||
isTouch={props.isTouch}
|
||||
filter={props.filter}
|
||||
>
|
||||
<Slider
|
||||
{...getSlickSliderSettings(
|
||||
cardCount ? cardCount : props.filter.itemsPerPage,
|
||||
props.isTouch
|
||||
)}
|
||||
>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div
|
||||
key={`_${i}`}
|
||||
className="studio-skeleton skeleton-card"
|
||||
></div>
|
||||
))
|
||||
: result.data?.findStudios.studios.map((s) => (
|
||||
<StudioCard key={s.id} studio={s} hideParent={true} />
|
||||
))}
|
||||
</Slider>
|
||||
</RecommendationRow>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div
|
||||
key={`_${i}`}
|
||||
className="studio-skeleton skeleton-card"
|
||||
></div>
|
||||
))
|
||||
: result.data?.findStudios.studios.map((s) => (
|
||||
<StudioCard key={s.id} studio={s} hideParent={true} />
|
||||
))}
|
||||
</FilteredRecommendationRow>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useTagFilterHook } from "src/core/tags";
|
||||
import { ImageList } from "src/components/Images/ImageList";
|
||||
import { FilteredImageList } from "src/components/Images/ImageList";
|
||||
import { View } from "src/components/List/views";
|
||||
|
||||
interface ITagImagesPanel {
|
||||
@@ -17,7 +17,7 @@ export const TagImagesPanel: React.FC<ITagImagesPanel> = ({
|
||||
}) => {
|
||||
const filterHook = useTagFilterHook(tag, showSubTagContent);
|
||||
return (
|
||||
<ImageList
|
||||
<FilteredImageList
|
||||
filterHook={filterHook}
|
||||
alterQuery={active}
|
||||
view={View.TagImages}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
TagsCriterion,
|
||||
TagsCriterionOption,
|
||||
} from "src/models/list-filter/criteria/tags";
|
||||
import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList";
|
||||
import { FilteredSceneMarkerList } from "src/components/Scenes/SceneMarkerList";
|
||||
import { View } from "src/components/List/views";
|
||||
|
||||
function useFilterHook(tag: GQL.TagDataFragment, showSubTagContent?: boolean) {
|
||||
@@ -60,7 +60,7 @@ export const TagMarkersPanel: React.FC<ITagMarkersPanel> = ({
|
||||
const filterHook = useFilterHook(tag, showSubTagContent);
|
||||
|
||||
return (
|
||||
<SceneMarkerList
|
||||
<FilteredSceneMarkerList
|
||||
filterHook={filterHook}
|
||||
alterQuery={active}
|
||||
view={View.TagMarkers}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import Mousetrap from "mousetrap";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
|
||||
import { useFilteredItemList } from "../List/ItemList";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
@@ -11,33 +11,269 @@ import {
|
||||
queryFindTagsForList,
|
||||
mutateMetadataAutoTag,
|
||||
useFindTagsForList,
|
||||
useTagDestroy,
|
||||
useTagsDestroy,
|
||||
} from "src/core/StashService";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
|
||||
import NavUtils from "src/utils/navigation";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog";
|
||||
import { ExportDialog } from "../Shared/ExportDialog";
|
||||
import { tagRelationHook } from "../../core/tags";
|
||||
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { TagMergeModal } from "./TagMergeDialog";
|
||||
import { Tag } from "./TagSelect";
|
||||
import { TagCardGrid } from "./TagCardGrid";
|
||||
import { EditTagsDialog } from "./EditTagsDialog";
|
||||
import { View } from "../List/views";
|
||||
import { IItemListOperation } from "../List/FilteredListToolbar";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import {
|
||||
FilteredListToolbar,
|
||||
IItemListOperation,
|
||||
} from "../List/FilteredListToolbar";
|
||||
import { PatchComponent, PatchContainerComponent } from "src/patch";
|
||||
import { TagTagger } from "../Tagger/tags/TagTagger";
|
||||
import useFocus from "src/utils/focus";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarPane,
|
||||
SidebarPaneContent,
|
||||
SidebarStateContext,
|
||||
useSidebarState,
|
||||
} from "../Shared/Sidebar";
|
||||
import { useCloseEditDelete, useFilterOperations } from "../List/util";
|
||||
import {
|
||||
FilteredSidebarHeader,
|
||||
useFilteredSidebarKeybinds,
|
||||
} from "../List/Filters/FilterSidebar";
|
||||
import { ListOperations } from "../List/ListOperationButtons";
|
||||
import cx from "classnames";
|
||||
import { FilterTags } from "../List/FilterTags";
|
||||
import { Pagination, PaginationIndex } from "../List/Pagination";
|
||||
import { LoadedContent } from "../List/PagedList";
|
||||
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
|
||||
import { FavoriteTagCriterionOption } from "src/models/list-filter/criteria/favorite";
|
||||
|
||||
function getItems(result: GQL.FindTagsForListQueryResult) {
|
||||
return result?.data?.findTags?.tags ?? [];
|
||||
const TagList: React.FC<{
|
||||
tags: GQL.TagListDataFragment[];
|
||||
filter: ListFilterModel;
|
||||
selectedIds: Set<string>;
|
||||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
|
||||
onDelete: (tag: GQL.TagListDataFragment) => void;
|
||||
onAutoTag: (tag: GQL.TagListDataFragment) => void;
|
||||
}> = PatchComponent(
|
||||
"TagList",
|
||||
({ tags, filter, selectedIds, onSelectChange, onDelete, onAutoTag }) => {
|
||||
if (tags.length === 0 && filter.displayMode !== DisplayMode.Tagger) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<TagCardGrid
|
||||
tags={tags}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.List) {
|
||||
const tagElements = tags.map((tag) => {
|
||||
return (
|
||||
<div key={tag.id} className="tag-list-row row">
|
||||
<Link to={`/tags/${tag.id}`}>{tag.name}</Link>
|
||||
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="tag-list-button"
|
||||
onClick={() => onAutoTag(tag)}
|
||||
>
|
||||
<FormattedMessage id="actions.auto_tag" />
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagScenesUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.scenes"
|
||||
values={{
|
||||
count: tag.scene_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.scene_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagImagesUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.images"
|
||||
values={{
|
||||
count: tag.image_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.image_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagGalleriesUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.galleries"
|
||||
values={{
|
||||
count: tag.gallery_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.gallery_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagSceneMarkersUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.markers"
|
||||
values={{
|
||||
count: tag.scene_marker_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.scene_marker_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="tag-list-count">
|
||||
<FormattedMessage id="total" />:{" "}
|
||||
<FormattedNumber
|
||||
value={
|
||||
(tag.scene_count || 0) +
|
||||
(tag.scene_marker_count || 0) +
|
||||
(tag.image_count || 0) +
|
||||
(tag.gallery_count || 0)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<Button variant="danger" onClick={() => onDelete(tag)}>
|
||||
<Icon icon={faTrashAlt} color="danger" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return <div className="col col-sm-8 m-auto">{tagElements}</div>;
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Tagger) {
|
||||
return <TagTagger tags={tags} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
const TagFilterSidebarSections = PatchContainerComponent(
|
||||
"FilteredTagList.SidebarSections"
|
||||
);
|
||||
|
||||
const SidebarContent: React.FC<{
|
||||
filter: ListFilterModel;
|
||||
setFilter: (filter: ListFilterModel) => void;
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
view?: View;
|
||||
sidebarOpen: boolean;
|
||||
onClose?: () => void;
|
||||
showEditFilter: (editingCriterion?: string) => void;
|
||||
count?: number;
|
||||
focus?: ReturnType<typeof useFocus>;
|
||||
}> = ({
|
||||
filter,
|
||||
setFilter,
|
||||
// filterHook,
|
||||
view,
|
||||
showEditFilter,
|
||||
sidebarOpen,
|
||||
onClose,
|
||||
count,
|
||||
focus,
|
||||
}) => {
|
||||
const showResultsId =
|
||||
count !== undefined ? "actions.show_count_results" : "actions.show_results";
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilteredSidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
showEditFilter={showEditFilter}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
view={view}
|
||||
focus={focus}
|
||||
/>
|
||||
|
||||
<TagFilterSidebarSections>
|
||||
{/* <SidebarTagsFilter
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
/> */}
|
||||
<SidebarBooleanFilter
|
||||
title={<FormattedMessage id="favourite" />}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
option={FavoriteTagCriterionOption}
|
||||
sectionID="favourite"
|
||||
/>
|
||||
</TagFilterSidebarSections>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<Button className="sidebar-close-button" onClick={onClose}>
|
||||
<FormattedMessage id={showResultsId} values={{ count }} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function useViewRandom(filter: ListFilterModel, count: number) {
|
||||
const history = useHistory();
|
||||
|
||||
const viewRandom = useCallback(async () => {
|
||||
// query for a random tag
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = Math.floor(Math.random() * count);
|
||||
const filterCopy = cloneDeep(filter);
|
||||
filterCopy.itemsPerPage = 1;
|
||||
filterCopy.currentPage = index + 1;
|
||||
const singleResult = await queryFindTagsForList(filterCopy);
|
||||
if (singleResult.data.findTags.tags.length === 1) {
|
||||
const { id } = singleResult.data.findTags.tags[0];
|
||||
// navigate to the tag page
|
||||
history.push(`/tags/${id}`);
|
||||
}
|
||||
}, [history, filter, count]);
|
||||
|
||||
return viewRandom;
|
||||
}
|
||||
|
||||
function getCount(result: GQL.FindTagsForListQueryResult) {
|
||||
return result?.data?.findTags?.count ?? 0;
|
||||
function useAddKeybinds(filter: ListFilterModel, count: number) {
|
||||
const viewRandom = useViewRandom(filter, count);
|
||||
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("p r", () => {
|
||||
viewRandom();
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("p r");
|
||||
};
|
||||
}, [viewRandom]);
|
||||
}
|
||||
|
||||
interface ITagList {
|
||||
@@ -46,105 +282,155 @@ interface ITagList {
|
||||
extraOperations?: IItemListOperation<GQL.FindTagsForListQueryResult>[];
|
||||
}
|
||||
|
||||
export const TagList: React.FC<ITagList> = PatchComponent(
|
||||
"TagList",
|
||||
({ filterHook, alterQuery, extraOperations = [] }) => {
|
||||
const Toast = useToast();
|
||||
const [deletingTag, setDeletingTag] =
|
||||
useState<Partial<GQL.TagListDataFragment> | null>(null);
|
||||
|
||||
const filterMode = GQL.FilterMode.Tags;
|
||||
const view = View.Tags;
|
||||
|
||||
function getDeleteTagInput() {
|
||||
const tagInput: Partial<GQL.TagDestroyInput> = {};
|
||||
if (deletingTag) {
|
||||
tagInput.id = deletingTag.id;
|
||||
}
|
||||
return tagInput as GQL.TagDestroyInput;
|
||||
}
|
||||
const [deleteTag] = useTagDestroy(getDeleteTagInput());
|
||||
|
||||
export const FilteredTagList = PatchComponent(
|
||||
"FilteredTagList",
|
||||
(props: ITagList) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const [mergeTags, setMergeTags] = useState<Tag[] | undefined>(undefined);
|
||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||
const [isExportAll, setIsExportAll] = useState(false);
|
||||
const Toast = useToast();
|
||||
|
||||
const otherOperations = [
|
||||
...extraOperations,
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.view_random" }),
|
||||
onClick: viewRandom,
|
||||
},
|
||||
{
|
||||
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
|
||||
onClick: merge,
|
||||
isDisplayed: showWhenSelected,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.export" }),
|
||||
onClick: onExport,
|
||||
isDisplayed: showWhenSelected,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.export_all" }),
|
||||
onClick: onExportAll,
|
||||
},
|
||||
];
|
||||
const searchFocus = useFocus();
|
||||
|
||||
function addKeybinds(
|
||||
result: GQL.FindTagsForListQueryResult,
|
||||
filter: ListFilterModel
|
||||
) {
|
||||
Mousetrap.bind("p r", () => {
|
||||
viewRandom(result, filter);
|
||||
const { filterHook, alterQuery, extraOperations = [] } = props;
|
||||
|
||||
const view = View.Tags;
|
||||
|
||||
// States
|
||||
const {
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
sectionOpen,
|
||||
setSectionOpen,
|
||||
loading: sidebarStateLoading,
|
||||
} = useSidebarState(view);
|
||||
|
||||
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
|
||||
useFilteredItemList({
|
||||
filterStateProps: {
|
||||
filterMode: GQL.FilterMode.Tags,
|
||||
view,
|
||||
useURL: alterQuery,
|
||||
},
|
||||
queryResultProps: {
|
||||
useResult: useFindTagsForList,
|
||||
getCount: (r) => r.data?.findTags.count ?? 0,
|
||||
getItems: (r) => r.data?.findTags.tags ?? [],
|
||||
filterHook,
|
||||
},
|
||||
});
|
||||
|
||||
const { filter, setFilter } = filterState;
|
||||
|
||||
const { effectiveFilter, result, cachedResult, items, totalCount } =
|
||||
queryResult;
|
||||
|
||||
const {
|
||||
selectedIds,
|
||||
selectedItems,
|
||||
onSelectChange,
|
||||
onSelectAll,
|
||||
onSelectNone,
|
||||
onInvertSelection,
|
||||
hasSelection,
|
||||
} = listSelect;
|
||||
|
||||
const { modal, showModal, closeModal } = modalState;
|
||||
|
||||
// Utility hooks
|
||||
const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({
|
||||
filter,
|
||||
setFilter,
|
||||
});
|
||||
|
||||
useAddKeybinds(effectiveFilter, totalCount);
|
||||
useFilteredSidebarKeybinds({
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("e", () => {
|
||||
if (hasSelection) {
|
||||
onEdit?.();
|
||||
}
|
||||
});
|
||||
|
||||
Mousetrap.bind("d d", () => {
|
||||
if (hasSelection) {
|
||||
onDelete?.();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("p r");
|
||||
Mousetrap.unbind("e");
|
||||
Mousetrap.unbind("d d");
|
||||
};
|
||||
});
|
||||
|
||||
const onCloseEditDelete = useCloseEditDelete({
|
||||
closeModal,
|
||||
onSelectNone,
|
||||
result,
|
||||
});
|
||||
|
||||
const viewRandom = useViewRandom(effectiveFilter, totalCount);
|
||||
|
||||
function onExport(all: boolean) {
|
||||
showModal(
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
studios: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: all,
|
||||
},
|
||||
}}
|
||||
onClose={() => closeModal()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function viewRandom(
|
||||
result: GQL.FindTagsForListQueryResult,
|
||||
filter: ListFilterModel
|
||||
) {
|
||||
// query for a random tag
|
||||
if (result.data?.findTags) {
|
||||
const { count } = result.data.findTags;
|
||||
|
||||
const index = Math.floor(Math.random() * count);
|
||||
const filterCopy = cloneDeep(filter);
|
||||
filterCopy.itemsPerPage = 1;
|
||||
filterCopy.currentPage = index + 1;
|
||||
const singleResult = await queryFindTagsForList(filterCopy);
|
||||
if (singleResult.data.findTags.tags.length === 1) {
|
||||
const { id } = singleResult.data.findTags.tags[0];
|
||||
// navigate to the tag page
|
||||
history.push(`/tags/${id}`);
|
||||
}
|
||||
}
|
||||
function onEdit() {
|
||||
showModal(
|
||||
<EditTagsDialog selected={selectedItems} onClose={onCloseEditDelete} />
|
||||
);
|
||||
}
|
||||
|
||||
async function merge(
|
||||
result: GQL.FindTagsForListQueryResult,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>
|
||||
) {
|
||||
const selected =
|
||||
result.data?.findTags.tags.filter((t) => selectedIds.has(t.id)) ?? [];
|
||||
setMergeTags(selected);
|
||||
function onDelete(tag?: GQL.TagListDataFragment) {
|
||||
const itemsToDelete = tag ? [tag] : selectedItems;
|
||||
|
||||
showModal(
|
||||
<DeleteEntityDialog
|
||||
selected={itemsToDelete}
|
||||
onClose={onCloseEditDelete}
|
||||
singularEntity={intl.formatMessage({ id: "tag" })}
|
||||
pluralEntity={intl.formatMessage({ id: "tags" })}
|
||||
destroyMutation={useTagsDestroy}
|
||||
onDeleted={() => {
|
||||
itemsToDelete.forEach((t) =>
|
||||
tagRelationHook(
|
||||
t,
|
||||
{ parents: t.parents ?? [], children: t.children ?? [] },
|
||||
{ parents: [], children: [] }
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function onExport() {
|
||||
setIsExportAll(false);
|
||||
setIsExportDialogOpen(true);
|
||||
}
|
||||
|
||||
async function onExportAll() {
|
||||
setIsExportAll(true);
|
||||
setIsExportDialogOpen(true);
|
||||
function onMerge() {
|
||||
showModal(
|
||||
<TagMergeModal
|
||||
tags={selectedItems}
|
||||
onClose={(mergedId?: string) => {
|
||||
onCloseEditDelete();
|
||||
if (mergedId) {
|
||||
history.push(`/tags/${mergedId}`);
|
||||
}
|
||||
}}
|
||||
show
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function onAutoTag(tag: GQL.TagListDataFragment) {
|
||||
@@ -157,269 +443,151 @@ export const TagList: React.FC<ITagList> = PatchComponent(
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
try {
|
||||
const oldRelations = {
|
||||
parents: deletingTag?.parents ?? [],
|
||||
children: deletingTag?.children ?? [],
|
||||
};
|
||||
await deleteTag();
|
||||
tagRelationHook(deletingTag as GQL.TagListDataFragment, oldRelations, {
|
||||
parents: [],
|
||||
children: [],
|
||||
});
|
||||
Toast.success(
|
||||
intl.formatMessage(
|
||||
{ id: "toast.delete_past_tense" },
|
||||
{
|
||||
count: 1,
|
||||
singularEntity: intl.formatMessage({ id: "tag" }),
|
||||
pluralEntity: intl.formatMessage({ id: "tags" }),
|
||||
}
|
||||
)
|
||||
);
|
||||
setDeletingTag(null);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
}
|
||||
const convertedExtraOperations = extraOperations.map((op) => ({
|
||||
text: op.text,
|
||||
onClick: () => op.onClick(result, filter, selectedIds),
|
||||
isDisplayed: () => op.isDisplayed?.(result, filter, selectedIds) ?? true,
|
||||
}));
|
||||
|
||||
function renderContent(
|
||||
result: GQL.FindTagsForListQueryResult,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>,
|
||||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
|
||||
) {
|
||||
function renderMergeDialog() {
|
||||
if (mergeTags) {
|
||||
return (
|
||||
<TagMergeModal
|
||||
tags={mergeTags}
|
||||
onClose={(mergedId?: string) => {
|
||||
setMergeTags(undefined);
|
||||
if (mergedId) {
|
||||
history.push(`/tags/${mergedId}`);
|
||||
}
|
||||
}}
|
||||
show
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
const otherOperations = [
|
||||
...convertedExtraOperations,
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.select_all" }),
|
||||
onClick: () => onSelectAll(),
|
||||
isDisplayed: () => totalCount > 0,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.select_none" }),
|
||||
onClick: () => onSelectNone(),
|
||||
isDisplayed: () => hasSelection,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.invert_selection" }),
|
||||
onClick: () => onInvertSelection(),
|
||||
isDisplayed: () => totalCount > 0,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.view_random" }),
|
||||
onClick: viewRandom,
|
||||
},
|
||||
{
|
||||
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
|
||||
onClick: () => onMerge(),
|
||||
isDisplayed: () => hasSelection,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.export" }),
|
||||
onClick: () => onExport(false),
|
||||
isDisplayed: () => hasSelection,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.export_all" }),
|
||||
onClick: () => onExport(true),
|
||||
},
|
||||
];
|
||||
|
||||
function maybeRenderExportDialog() {
|
||||
if (isExportDialogOpen) {
|
||||
return (
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
tags: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: isExportAll,
|
||||
},
|
||||
}}
|
||||
onClose={() => setIsExportDialogOpen(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
// render
|
||||
if (sidebarStateLoading) return null;
|
||||
|
||||
function renderTags() {
|
||||
if (!result.data?.findTags) return;
|
||||
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<TagCardGrid
|
||||
tags={result.data.findTags.tags}
|
||||
zoomIndex={filter.zoomIndex}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.List) {
|
||||
const deleteAlert = (
|
||||
<ModalComponent
|
||||
onHide={() => {}}
|
||||
show={!!deletingTag}
|
||||
icon={faTrashAlt}
|
||||
accept={{
|
||||
onClick: onDelete,
|
||||
variant: "danger",
|
||||
text: intl.formatMessage({ id: "actions.delete" }),
|
||||
}}
|
||||
cancel={{ onClick: () => setDeletingTag(null) }}
|
||||
>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id="dialogs.delete_confirm"
|
||||
values={{ entityName: deletingTag && deletingTag.name }}
|
||||
/>
|
||||
</span>
|
||||
</ModalComponent>
|
||||
);
|
||||
|
||||
const tagElements = result.data.findTags.tags.map((tag) => {
|
||||
return (
|
||||
<div key={tag.id} className="tag-list-row row">
|
||||
<Link to={`/tags/${tag.id}`}>{tag.name}</Link>
|
||||
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="tag-list-button"
|
||||
onClick={() => onAutoTag(tag)}
|
||||
>
|
||||
<FormattedMessage id="actions.auto_tag" />
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagScenesUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.scenes"
|
||||
values={{
|
||||
count: tag.scene_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.scene_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagImagesUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.images"
|
||||
values={{
|
||||
count: tag.image_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.image_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagGalleriesUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.galleries"
|
||||
values={{
|
||||
count: tag.gallery_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.gallery_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" className="tag-list-button">
|
||||
<Link
|
||||
to={NavUtils.makeTagSceneMarkersUrl(tag)}
|
||||
className="tag-list-anchor"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="countables.markers"
|
||||
values={{
|
||||
count: tag.scene_marker_count ?? 0,
|
||||
}}
|
||||
/>
|
||||
: <FormattedNumber value={tag.scene_marker_count ?? 0} />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="tag-list-count">
|
||||
<FormattedMessage id="total" />:{" "}
|
||||
<FormattedNumber
|
||||
value={
|
||||
(tag.scene_count || 0) +
|
||||
(tag.scene_marker_count || 0) +
|
||||
(tag.image_count || 0) +
|
||||
(tag.gallery_count || 0)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<Button variant="danger" onClick={() => setDeletingTag(tag)}>
|
||||
<Icon icon={faTrashAlt} color="danger" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="col col-sm-8 m-auto">
|
||||
{tagElements}
|
||||
{deleteAlert}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Wall) {
|
||||
return <h1>TODO</h1>;
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Tagger) {
|
||||
return <TagTagger tags={result.data.findTags.tags} />;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{renderMergeDialog()}
|
||||
{maybeRenderExportDialog()}
|
||||
{renderTags()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderEditDialog(
|
||||
selectedTags: GQL.TagListDataFragment[],
|
||||
onClose: (confirmed: boolean) => void
|
||||
) {
|
||||
return <EditTagsDialog selected={selectedTags} onClose={onClose} />;
|
||||
}
|
||||
|
||||
function renderDeleteDialog(
|
||||
selectedTags: GQL.TagListDataFragment[],
|
||||
onClose: (confirmed: boolean) => void
|
||||
) {
|
||||
return (
|
||||
<DeleteEntityDialog
|
||||
selected={selectedTags}
|
||||
onClose={onClose}
|
||||
singularEntity={intl.formatMessage({ id: "tag" })}
|
||||
pluralEntity={intl.formatMessage({ id: "tags" })}
|
||||
destroyMutation={useTagsDestroy}
|
||||
onDeleted={() => {
|
||||
selectedTags.forEach((t) =>
|
||||
tagRelationHook(
|
||||
t,
|
||||
{ parents: t.parents ?? [], children: t.children ?? [] },
|
||||
{ parents: [], children: [] }
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const operations = (
|
||||
<ListOperations
|
||||
items={items.length}
|
||||
hasSelection={hasSelection}
|
||||
operations={otherOperations}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
operationsMenuClassName="tag-list-operations-dropdown"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ItemListContext
|
||||
filterMode={filterMode}
|
||||
useResult={useFindTagsForList}
|
||||
getItems={getItems}
|
||||
getCount={getCount}
|
||||
alterQuery={alterQuery}
|
||||
filterHook={filterHook}
|
||||
view={view}
|
||||
selectable
|
||||
<div
|
||||
className={cx("item-list-container tag-list", {
|
||||
"hide-sidebar": !showSidebar,
|
||||
})}
|
||||
>
|
||||
<ItemList
|
||||
view={view}
|
||||
otherOperations={otherOperations}
|
||||
addKeybinds={addKeybinds}
|
||||
renderContent={renderContent}
|
||||
renderEditDialog={renderEditDialog}
|
||||
renderDeleteDialog={renderDeleteDialog}
|
||||
/>
|
||||
</ItemListContext>
|
||||
{modal}
|
||||
|
||||
<SidebarStateContext.Provider value={{ sectionOpen, setSectionOpen }}>
|
||||
<SidebarPane hideSidebar={!showSidebar}>
|
||||
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
|
||||
<SidebarContent
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
filterHook={filterHook}
|
||||
showEditFilter={showEditFilter}
|
||||
view={view}
|
||||
sidebarOpen={showSidebar}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
count={cachedResult.loading ? undefined : totalCount}
|
||||
focus={searchFocus}
|
||||
/>
|
||||
</Sidebar>
|
||||
<SidebarPaneContent
|
||||
onSidebarToggle={() => setShowSidebar(!showSidebar)}
|
||||
>
|
||||
<FilteredListToolbar
|
||||
filter={filter}
|
||||
listSelect={listSelect}
|
||||
setFilter={setFilter}
|
||||
showEditFilter={showEditFilter}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
operationComponent={operations}
|
||||
view={view}
|
||||
zoomable
|
||||
/>
|
||||
|
||||
<FilterTags
|
||||
criteria={filter.criteria}
|
||||
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
||||
onRemoveCriterion={removeCriterion}
|
||||
onRemoveAll={clearAllCriteria}
|
||||
/>
|
||||
|
||||
<div className="pagination-index-container">
|
||||
<Pagination
|
||||
currentPage={filter.currentPage}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
totalItems={totalCount}
|
||||
onChangePage={(page) => setFilter(filter.changePage(page))}
|
||||
/>
|
||||
<PaginationIndex
|
||||
loading={cachedResult.loading}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LoadedContent loading={result.loading} error={result.error}>
|
||||
<TagList
|
||||
filter={effectiveFilter}
|
||||
tags={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
onDelete={(tag) => onDelete(tag)}
|
||||
onAutoTag={(tag) => onAutoTag(tag)}
|
||||
/>
|
||||
</LoadedContent>
|
||||
|
||||
{totalCount > filter.itemsPerPage && (
|
||||
<div className="pagination-footer-container">
|
||||
<div className="pagination-footer">
|
||||
<Pagination
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
onChangePage={setPage}
|
||||
pagePopupPlacement="top"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SidebarPaneContent>
|
||||
</SidebarPane>
|
||||
</SidebarStateContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useFindTags } from "src/core/StashService";
|
||||
import Slider from "@ant-design/react-slick";
|
||||
import { TagCard } from "./TagCard";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { FilteredRecommendationRow } from "../FrontPage/FilteredRecommendationRow";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
@@ -19,37 +15,26 @@ export const TagRecommendationRow: React.FC<IProps> = PatchComponent(
|
||||
"TagRecommendationRow",
|
||||
(props) => {
|
||||
const result = useFindTags(props.filter);
|
||||
const cardCount = result.data?.findTags.count;
|
||||
|
||||
if (!result.loading && !cardCount) {
|
||||
return null;
|
||||
}
|
||||
const count = result.data?.findTags.count ?? 0;
|
||||
|
||||
return (
|
||||
<RecommendationRow
|
||||
<FilteredRecommendationRow
|
||||
className="tag-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<Link to={`/tags?${props.filter.makeQueryParameters()}`}>
|
||||
<FormattedMessage id="view_all" />
|
||||
</Link>
|
||||
}
|
||||
heading={props.header}
|
||||
url={`/tags?${props.filter.makeQueryParameters()}`}
|
||||
count={count}
|
||||
loading={result.loading}
|
||||
isTouch={props.isTouch}
|
||||
filter={props.filter}
|
||||
>
|
||||
<Slider
|
||||
{...getSlickSliderSettings(
|
||||
cardCount ? cardCount : props.filter.itemsPerPage,
|
||||
props.isTouch
|
||||
)}
|
||||
>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div key={`_${i}`} className="tag-skeleton skeleton-card"></div>
|
||||
))
|
||||
: result.data?.findTags.tags.map((p) => (
|
||||
<TagCard key={p.id} tag={p} zoomIndex={0} />
|
||||
))}
|
||||
</Slider>
|
||||
</RecommendationRow>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div key={`_${i}`} className="tag-skeleton skeleton-card"></div>
|
||||
))
|
||||
: result.data?.findTags.tags.map((p) => (
|
||||
<TagCard key={p.id} tag={p} zoomIndex={0} />
|
||||
))}
|
||||
</FilteredRecommendationRow>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -4,10 +4,10 @@ import { Helmet } from "react-helmet";
|
||||
import { useTitleProps } from "src/hooks/title";
|
||||
import Tag from "./TagDetails/Tag";
|
||||
import TagCreate from "./TagDetails/TagCreate";
|
||||
import { TagList } from "./TagList";
|
||||
import { FilteredTagList } from "./TagList";
|
||||
|
||||
const Tags: React.FC = () => {
|
||||
return <TagList />;
|
||||
return <FilteredTagList />;
|
||||
};
|
||||
|
||||
const TagRoutes: React.FC = () => {
|
||||
|
||||
@@ -230,7 +230,13 @@ Returns `void`.
|
||||
- `ExternalLinkButtons`
|
||||
- `ExternalLinksButton`
|
||||
- `FilteredGalleryList`
|
||||
- `FilteredGroupList`
|
||||
- `FilteredImageList`
|
||||
- `FilteredPerformerList`
|
||||
- `FilteredSceneList`
|
||||
- `FilteredSceneMarkerList`
|
||||
- `FilteredStudioList`
|
||||
- `FilteredTagList`
|
||||
- `FolderSelect`
|
||||
- `FrontPage`
|
||||
- `GalleryCard`
|
||||
@@ -248,6 +254,7 @@ Returns `void`.
|
||||
- `GroupCard`
|
||||
- `GroupCardGrid`
|
||||
- `GroupIDSelect`
|
||||
- `GroupList`
|
||||
- `GroupRecommendationRow`
|
||||
- `GroupSelect`
|
||||
- `GroupSelect.sort`
|
||||
@@ -262,6 +269,7 @@ Returns `void`.
|
||||
- `ImageDetailPanel`
|
||||
- `ImageGridCard`
|
||||
- `ImageInput`
|
||||
- `ImageList`
|
||||
- `ImageRecommendationRow`
|
||||
- `LightboxLink`
|
||||
- `LoadingIndicator`
|
||||
@@ -286,6 +294,7 @@ Returns `void`.
|
||||
- `PerformerHeaderImage`
|
||||
- `PerformerIDSelect`
|
||||
- `PerformerImagesPanel`
|
||||
- `PerformerList`
|
||||
- `PerformerPage`
|
||||
- `PerformerRecommendationRow`
|
||||
- `PerformerScenesPanel`
|
||||
@@ -310,6 +319,7 @@ Returns `void`.
|
||||
- `SceneMarkerCard.Image`
|
||||
- `SceneMarkerCard.Popovers`
|
||||
- `SceneMarkerCardsGrid`
|
||||
- `SceneMarkerList`
|
||||
- `SceneMarkerRecommendationRow`
|
||||
- `SceneList`
|
||||
- `ScenePage`
|
||||
@@ -329,6 +339,7 @@ Returns `void`.
|
||||
- `StudioCardGrid`
|
||||
- `StudioDetailsPanel`
|
||||
- `StudioIDSelect`
|
||||
- `StudioList`
|
||||
- `StudioRecommendationRow`
|
||||
- `StudioSelect`
|
||||
- `StudioSelect.sort`
|
||||
@@ -343,6 +354,7 @@ Returns `void`.
|
||||
- `TagCardGrid`
|
||||
- `TagIDSelect`
|
||||
- `TagLink`
|
||||
- `TagList`
|
||||
- `TagRecommendationRow`
|
||||
- `TagSelect`
|
||||
- `TagSelect.sort`
|
||||
|
||||
@@ -441,7 +441,7 @@
|
||||
"heading": "Scrapers path"
|
||||
},
|
||||
"scraping": "Scraping",
|
||||
"sprite_generation_head": "Sprite generation",
|
||||
"sprite_generation_head": "Sprite Generation",
|
||||
"sprite_interval_desc": "Time between each generated sprite in seconds.",
|
||||
"sprite_interval_head": "Sprite interval",
|
||||
"sprite_maximum_desc": "Maximum number of sprites to be generated for a scene. Set to 0 to disable the limit.",
|
||||
@@ -669,7 +669,7 @@
|
||||
},
|
||||
"custom_locales": {
|
||||
"description": "Override individual locale strings. See https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json for the master list. Page must be reloaded for changes to take effect.",
|
||||
"heading": "Custom Localisation",
|
||||
"heading": "Custom localisation",
|
||||
"option_label": "Custom localisation enabled"
|
||||
},
|
||||
"custom_title": {
|
||||
@@ -918,6 +918,7 @@
|
||||
"criterion": {
|
||||
"greater_than": "Greater than",
|
||||
"less_than": "Less than",
|
||||
"unsupported": "{type} (unsupported)",
|
||||
"value": "Value"
|
||||
},
|
||||
"criterion_modifier": {
|
||||
@@ -1656,6 +1657,7 @@
|
||||
"twitter": "Twitter",
|
||||
"type": "Type",
|
||||
"unknown_date": "Unknown date",
|
||||
"unsupported_criteria": "Unsupported criteria: {criteria}",
|
||||
"updated_at": "Updated At",
|
||||
"url": "URL",
|
||||
"urls": "URLs",
|
||||
|
||||
@@ -1221,3 +1221,54 @@ export class TimestampCriterion extends ModifierCriterion<ITimestampValue> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedCriterionOption extends StringCriterionOption {
|
||||
constructor(type: string) {
|
||||
super({
|
||||
messageID: "unsupported_criterion",
|
||||
type: type as CriterionType,
|
||||
makeCriterion: () => new UnsupportedCriterion(this),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedCriterion extends StringCriterion {
|
||||
public getLabel(intl: IntlShape): string {
|
||||
const modifierString = ModifierCriterion.getModifierLabel(
|
||||
intl,
|
||||
this.modifier
|
||||
);
|
||||
let valueString = "";
|
||||
|
||||
if (
|
||||
this.modifier !== CriterionModifier.IsNull &&
|
||||
this.modifier !== CriterionModifier.NotNull
|
||||
) {
|
||||
valueString = this.getLabelValue(intl);
|
||||
}
|
||||
|
||||
return intl.formatMessage(
|
||||
{ id: "criterion_modifier.format_string" },
|
||||
{
|
||||
criterion: intl.formatMessage(
|
||||
{ id: "criterion.unsupported" },
|
||||
{ type: this.criterionOption.type }
|
||||
),
|
||||
modifierString,
|
||||
valueString,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public applyToCriterionInput(): void {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
public applyToSavedCriterion(): void {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
public setFromSavedCriterion(): void {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,13 @@ export const SceneIsMissingCriterionOption = new IsMissingCriterionOption(
|
||||
"is_missing",
|
||||
[
|
||||
"title",
|
||||
"cover",
|
||||
"code",
|
||||
"details",
|
||||
"director",
|
||||
"url",
|
||||
"date",
|
||||
"rating",
|
||||
"cover",
|
||||
"galleries",
|
||||
"studio",
|
||||
"group",
|
||||
@@ -42,7 +45,19 @@ export const SceneIsMissingCriterionOption = new IsMissingCriterionOption(
|
||||
export const ImageIsMissingCriterionOption = new IsMissingCriterionOption(
|
||||
"isMissing",
|
||||
"is_missing",
|
||||
["title", "galleries", "studio", "performers", "tags"]
|
||||
[
|
||||
"title",
|
||||
"details",
|
||||
"photographer",
|
||||
"url",
|
||||
"date",
|
||||
"code",
|
||||
"rating",
|
||||
"galleries",
|
||||
"studio",
|
||||
"performers",
|
||||
"tags",
|
||||
]
|
||||
);
|
||||
|
||||
export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption(
|
||||
@@ -58,14 +73,21 @@ export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption(
|
||||
"weight",
|
||||
"measurements",
|
||||
"fake_tits",
|
||||
"penis_length",
|
||||
"circumcised",
|
||||
"career_start",
|
||||
"career_end",
|
||||
"tattoos",
|
||||
"piercings",
|
||||
"aliases",
|
||||
"gender",
|
||||
"birthdate",
|
||||
"death_date",
|
||||
"disambiguation",
|
||||
"tags",
|
||||
"image",
|
||||
"details",
|
||||
"rating",
|
||||
"stash_id",
|
||||
]
|
||||
);
|
||||
@@ -73,23 +95,49 @@ export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption(
|
||||
export const GalleryIsMissingCriterionOption = new IsMissingCriterionOption(
|
||||
"isMissing",
|
||||
"is_missing",
|
||||
["title", "details", "url", "date", "studio", "performers", "tags", "scenes"]
|
||||
[
|
||||
"title",
|
||||
"code",
|
||||
"details",
|
||||
"photographer",
|
||||
"url",
|
||||
"date",
|
||||
"rating",
|
||||
"cover",
|
||||
"studio",
|
||||
"performers",
|
||||
"tags",
|
||||
"scenes",
|
||||
]
|
||||
);
|
||||
|
||||
export const TagIsMissingCriterionOption = new IsMissingCriterionOption(
|
||||
"isMissing",
|
||||
"is_missing",
|
||||
["image"]
|
||||
["image", "aliases", "description", "stash_id"]
|
||||
);
|
||||
|
||||
export const StudioIsMissingCriterionOption = new IsMissingCriterionOption(
|
||||
"isMissing",
|
||||
"is_missing",
|
||||
["image", "stash_id", "details"]
|
||||
["image", "stash_id", "details", "url", "aliases", "tags", "rating"]
|
||||
);
|
||||
|
||||
export const GroupIsMissingCriterionOption = new IsMissingCriterionOption(
|
||||
"isMissing",
|
||||
"is_missing",
|
||||
["front_image", "back_image", "scenes"]
|
||||
[
|
||||
"aliases",
|
||||
"description",
|
||||
"director",
|
||||
"date",
|
||||
"url",
|
||||
"rating",
|
||||
"studio",
|
||||
"performers",
|
||||
"tags",
|
||||
"front_image",
|
||||
"back_image",
|
||||
"scenes",
|
||||
]
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
SavedFilterDataFragment,
|
||||
SortDirectionEnum,
|
||||
} from "src/core/generated-graphql";
|
||||
import { Criterion } from "./criteria/criterion";
|
||||
import { Criterion, UnsupportedCriterionOption } from "./criteria/criterion";
|
||||
import { getFilterOptions } from "./factory";
|
||||
import { CriterionType, DisplayMode, SavedUIOptions } from "./types";
|
||||
import { ListFilterOptions } from "./filter-options";
|
||||
@@ -437,7 +437,7 @@ export class ListFilterModel {
|
||||
const option = criterionOptions.find((o) => o.type === type);
|
||||
|
||||
if (!option) {
|
||||
throw new Error(`Unknown criterion parameter name: ${type}`);
|
||||
return new UnsupportedCriterionOption(type).makeCriterion(this.config);
|
||||
}
|
||||
|
||||
return option.makeCriterion(this.config);
|
||||
|
||||
@@ -44,6 +44,9 @@ const displayModeOptions = [
|
||||
DisplayMode.Wall,
|
||||
];
|
||||
|
||||
export const PerformerAgeCriterionOption =
|
||||
createMandatoryNumberCriterionOption("performer_age");
|
||||
|
||||
const criterionOptions = [
|
||||
createStringCriterionOption("title"),
|
||||
createStringCriterionOption("code", "scene_code"),
|
||||
@@ -61,7 +64,7 @@ const criterionOptions = [
|
||||
PerformerTagsCriterionOption,
|
||||
PerformersCriterionOption,
|
||||
createMandatoryNumberCriterionOption("performer_count"),
|
||||
createMandatoryNumberCriterionOption("performer_age"),
|
||||
PerformerAgeCriterionOption,
|
||||
PerformerFavoriteCriterionOption,
|
||||
createMandatoryNumberCriterionOption("image_count"),
|
||||
// StudioTagsCriterionOption,
|
||||
|
||||
@@ -16,7 +16,6 @@ import { OrientationCriterionOption } from "./criteria/orientation";
|
||||
import { StudiosCriterionOption } from "./criteria/studios";
|
||||
import {
|
||||
PerformerTagsCriterionOption,
|
||||
// StudioTagsCriterionOption,
|
||||
TagsCriterionOption,
|
||||
} from "./criteria/tags";
|
||||
import { ListFilterOptions, MediaSortByOptions } from "./filter-options";
|
||||
@@ -43,6 +42,10 @@ const sortByOptions = [
|
||||
},
|
||||
]);
|
||||
const displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall];
|
||||
|
||||
export const PerformerAgeCriterionOption =
|
||||
createMandatoryNumberCriterionOption("performer_age");
|
||||
|
||||
const criterionOptions = [
|
||||
createStringCriterionOption("title"),
|
||||
createStringCriterionOption("code", "scene_code"),
|
||||
@@ -65,7 +68,7 @@ const criterionOptions = [
|
||||
PerformerTagsCriterionOption,
|
||||
PerformersCriterionOption,
|
||||
createMandatoryNumberCriterionOption("performer_count"),
|
||||
createMandatoryNumberCriterionOption("performer_age"),
|
||||
PerformerAgeCriterionOption,
|
||||
PerformerFavoriteCriterionOption,
|
||||
// StudioTagsCriterionOption,
|
||||
StudiosCriterionOption,
|
||||
|
||||
6
ui/v2.5/src/pluginApi.d.ts
vendored
6
ui/v2.5/src/pluginApi.d.ts
vendored
@@ -667,7 +667,13 @@ declare namespace PluginApi {
|
||||
ExternalLinkButtons: React.FC<any>;
|
||||
ExternalLinksButton: React.FC<any>;
|
||||
FilteredGalleryList: React.FC<any>;
|
||||
FilteredGroupList: React.FC<any>;
|
||||
FilteredImageList: React.FC<any>;
|
||||
FilteredPerformerList: React.FC<any>;
|
||||
FilteredSceneList: React.FC<any>;
|
||||
FilteredSceneMarkerList: React.FC<any>;
|
||||
FilteredStudioList: React.FC<any>;
|
||||
FilteredTagList: React.FC<any>;
|
||||
FolderSelect: React.FC<any>;
|
||||
FrontPage: React.FC<any>;
|
||||
GalleryCard: React.FC<any>;
|
||||
|
||||
Reference in New Issue
Block a user