mirror of
https://github.com/stashapp/stash.git
synced 2026-01-27 18:46:06 +01:00
Merge pull request #2714 from stashapp/develop
Post-release merge to master
This commit is contained in:
commit
38ade2b4b6
224 changed files with 5970 additions and 2315 deletions
1
.github/workflows/build.yml
vendored
1
.github/workflows/build.yml
vendored
|
|
@ -4,7 +4,6 @@ on:
|
|||
push:
|
||||
branches: [ develop, master ]
|
||||
pull_request:
|
||||
branches: [ develop ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ Many community-maintained scrapers are available for download at the [Community
|
|||
|
||||
# Translation
|
||||
[](https://translate.stashapp.cc/engage/stash/)
|
||||
🇧🇷 🇨🇳 🇩🇰 🇳🇱 🇬🇧 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇵🇱 🇪🇸 🇸🇪 🇹🇼 🇹🇷
|
||||
🇧🇷 🇨🇳 🇩🇰 🇳🇱 🇬🇧 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇰🇷 🇵🇱 🇪🇸 🇸🇪 🇹🇼 🇹🇷
|
||||
|
||||
Stash is available in 15 languages (so far!) and it could be in your language too. If you want to help us translate Stash into your language, you can make an account at [translate.stashapp.cc](https://translate.stashapp.cc/projects/stash/stash-desktop-client/) to get started contributing new languages or improving existing ones. Thanks!
|
||||
Stash is available in 16 languages (so far!) and it could be in your language too. If you want to help us translate Stash into your language, you can make an account at [translate.stashapp.cc](https://translate.stashapp.cc/projects/stash/stash-desktop-client/) to get started contributing new languages or improving existing ones. Thanks!
|
||||
|
||||
# Support (FAQ)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ NOTE: You may need to run the `go get` commands outside the project directory to
|
|||
### Windows
|
||||
|
||||
1. Download and install [Go for Windows](https://golang.org/dl/)
|
||||
2. Download and install [MingW](https://sourceforge.net/projects/mingw/) and select packages `mingw32-base`
|
||||
2. Download and extract [MingW64](https://sourceforge.net/projects/mingw-w64/files/) (scroll down and select x86_64-posix-seh, dont use the autoinstaller it doesnt work)
|
||||
3. Search for "advanced system settings" and open the system properties dialog.
|
||||
1. Click the `Environment Variables` button
|
||||
2. Under system variables find the `Path`. Edit and add `C:\MinGW\bin` (replace * with the correct path).
|
||||
2. Under system variables find the `Path`. Edit and add `C:\MinGW\bin` (replace with the correct path to where you extracted MingW64).
|
||||
|
||||
NOTE: The `make` command in Windows will be `mingw32-make` with MingW. For example `make pre-ui` will be `mingw32-make pre-ui`
|
||||
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -53,6 +53,7 @@ require (
|
|||
github.com/kermieisinthehouse/gosx-notifier v0.1.1
|
||||
github.com/kermieisinthehouse/systray v1.2.4
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||
github.com/spf13/cast v1.4.1
|
||||
github.com/vearutop/statigz v1.1.6
|
||||
github.com/vektah/gqlparser/v2 v2.4.1
|
||||
)
|
||||
|
|
@ -90,7 +91,6 @@ require (
|
|||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rs/zerolog v1.26.1 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/spf13/cast v1.4.1 // indirect
|
||||
github.com/spf13/cobra v1.4.0 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/stretchr/objx v0.2.0 // indirect
|
||||
|
|
|
|||
|
|
@ -185,4 +185,5 @@ fragment ConfigData on ConfigResult {
|
|||
defaults {
|
||||
...ConfigDefaultSettingsData
|
||||
}
|
||||
ui
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ mutation ConfigureDefaults($input: ConfigDefaultSettingsInput!) {
|
|||
}
|
||||
}
|
||||
|
||||
mutation ConfigureUI($input: Map!) {
|
||||
configureUI(input: $input)
|
||||
}
|
||||
|
||||
mutation GenerateAPIKey($input: GenerateAPIKeyInput!) {
|
||||
generateAPIKey(input: $input)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
query FindSavedFilters($mode: FilterMode!) {
|
||||
query FindSavedFilter($id: ID!) {
|
||||
findSavedFilter(id: $id) {
|
||||
...SavedFilterData
|
||||
}
|
||||
}
|
||||
|
||||
query FindSavedFilters($mode: FilterMode) {
|
||||
findSavedFilters(mode: $mode) {
|
||||
...SavedFilterData
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""The query root for this schema"""
|
||||
type Query {
|
||||
# Filters
|
||||
findSavedFilters(mode: FilterMode!): [SavedFilter!]!
|
||||
findSavedFilter(id: ID!): SavedFilter
|
||||
findSavedFilters(mode: FilterMode): [SavedFilter!]!
|
||||
findDefaultFilter(mode: FilterMode!): SavedFilter
|
||||
|
||||
"""Find a scene by ID or Checksum"""
|
||||
|
|
@ -114,12 +115,6 @@ type Query {
|
|||
"""Scrape a list of performers from a query"""
|
||||
scrapeFreeonesPerformerList(query: String!): [String!]! @deprecated(reason: "use scrapeSinglePerformer with scraper_id = builtin_freeones")
|
||||
|
||||
"""Query StashBox for scenes"""
|
||||
queryStashBoxScene(input: StashBoxSceneQueryInput!): [ScrapedScene!]! @deprecated(reason: "use scrapeSingleScene or scrapeMultiScenes")
|
||||
"""Query StashBox for performers"""
|
||||
queryStashBoxPerformer(input: StashBoxPerformerQueryInput!): [StashBoxPerformerQueryResult!]! @deprecated(reason: "use scrapeSinglePerformer or scrapeMultiPerformers")
|
||||
# === end deprecated methods ===
|
||||
|
||||
# Plugins
|
||||
"""List loaded plugins"""
|
||||
plugins: [Plugin!]
|
||||
|
|
@ -244,6 +239,11 @@ type Mutation {
|
|||
configureScraping(input: ConfigScrapingInput!): ConfigScrapingResult!
|
||||
configureDefaults(input: ConfigDefaultSettingsInput!): ConfigDefaultSettingsResult!
|
||||
|
||||
# overwrites the entire UI configuration
|
||||
configureUI(input: Map!): Map!
|
||||
# sets a single UI key value
|
||||
configureUISetting(key: String!, value: Any): Map!
|
||||
|
||||
"""Generate and set (or clear) API key"""
|
||||
generateAPIKey(input: GenerateAPIKeyInput!): String!
|
||||
|
||||
|
|
|
|||
|
|
@ -413,6 +413,7 @@ type ConfigResult {
|
|||
dlna: ConfigDLNAResult!
|
||||
scraping: ConfigScrapingResult!
|
||||
defaults: ConfigDefaultSettingsResult!
|
||||
ui: Map!
|
||||
}
|
||||
|
||||
"""Directory structure of a path"""
|
||||
|
|
|
|||
|
|
@ -4,4 +4,9 @@ Timestamp is a point in time. It is always output as RFC3339-compatible time poi
|
|||
It can be input as a RFC3339 string, or as "<4h" for "4 hours in the past" or ">5m"
|
||||
for "5 minutes in the future"
|
||||
"""
|
||||
scalar Timestamp
|
||||
scalar Timestamp
|
||||
|
||||
# generic JSON object
|
||||
scalar Map
|
||||
|
||||
scalar Any
|
||||
|
|
@ -129,6 +129,12 @@ query FindScenesByFullFingerprints($fingerprints: [FingerprintQueryInput!]!) {
|
|||
}
|
||||
}
|
||||
|
||||
query FindScenesBySceneFingerprints($fingerprints: [[FingerprintQueryInput!]!]!) {
|
||||
findScenesBySceneFingerprints(fingerprints: $fingerprints) {
|
||||
...SceneFragment
|
||||
}
|
||||
}
|
||||
|
||||
query SearchScene($term: String!) {
|
||||
searchScene(term: $term) {
|
||||
...SceneFragment
|
||||
|
|
|
|||
|
|
@ -501,3 +501,23 @@ func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input models.Gene
|
|||
|
||||
return newAPIKey, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]interface{}) (map[string]interface{}, error) {
|
||||
c := config.GetInstance()
|
||||
c.SetUIConfiguration(input)
|
||||
|
||||
if err := c.Write(); err != nil {
|
||||
return c.GetUIConfiguration(), err
|
||||
}
|
||||
|
||||
return c.GetUIConfiguration(), nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) ConfigureUISetting(ctx context.Context, key string, value interface{}) (map[string]interface{}, error) {
|
||||
c := config.GetInstance()
|
||||
|
||||
cfg := c.GetUIConfiguration()
|
||||
cfg[key] = value
|
||||
|
||||
return r.ConfigureUI(ctx, cfg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ func makeConfigResult() *models.ConfigResult {
|
|||
Dlna: makeConfigDLNAResult(),
|
||||
Scraping: makeConfigScrapingResult(),
|
||||
Defaults: makeConfigDefaultsResult(),
|
||||
UI: makeConfigUIResult(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -216,6 +217,10 @@ func makeConfigDefaultsResult() *models.ConfigDefaultSettingsResult {
|
|||
}
|
||||
}
|
||||
|
||||
func makeConfigUIResult() map[string]interface{} {
|
||||
return config.GetInstance().GetUIConfiguration()
|
||||
}
|
||||
|
||||
func (r *queryResolver) ValidateStashBoxCredentials(ctx context.Context, input models.StashBoxInput) (*models.StashBoxValidationResult, error) {
|
||||
client := stashbox.NewClient(models.StashBox{Endpoint: input.Endpoint, APIKey: input.APIKey}, r.txnManager)
|
||||
user, err := client.GetUser(ctx)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,33 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindSavedFilters(ctx context.Context, mode models.FilterMode) (ret []*models.SavedFilter, err error) {
|
||||
func (r *queryResolver) FindSavedFilter(ctx context.Context, id string) (ret *models.SavedFilter, err error) {
|
||||
idInt, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
|
||||
ret, err = repo.SavedFilter().FindByMode(mode)
|
||||
ret, err = repo.SavedFilter().Find(idInt)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ret, err
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindSavedFilters(ctx context.Context, mode *models.FilterMode) (ret []*models.SavedFilter, err error) {
|
||||
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
|
||||
if mode != nil {
|
||||
ret, err = repo.SavedFilter().FindByMode(*mode)
|
||||
} else {
|
||||
ret, err = repo.SavedFilter().All()
|
||||
}
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -227,46 +227,6 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models
|
|||
return marshalScrapedMovie(content)
|
||||
}
|
||||
|
||||
func (r *queryResolver) QueryStashBoxScene(ctx context.Context, input models.StashBoxSceneQueryInput) ([]*models.ScrapedScene, error) {
|
||||
boxes := config.GetInstance().GetStashBoxes()
|
||||
|
||||
if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) {
|
||||
return nil, fmt.Errorf("%w: invalid stash_box_index %d", ErrInput, input.StashBoxIndex)
|
||||
}
|
||||
|
||||
client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.txnManager)
|
||||
|
||||
if len(input.SceneIds) > 0 {
|
||||
return client.FindStashBoxScenesByFingerprintsFlat(ctx, input.SceneIds)
|
||||
}
|
||||
|
||||
if input.Q != nil {
|
||||
return client.QueryStashBoxScene(ctx, *input.Q)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) QueryStashBoxPerformer(ctx context.Context, input models.StashBoxPerformerQueryInput) ([]*models.StashBoxPerformerQueryResult, error) {
|
||||
boxes := config.GetInstance().GetStashBoxes()
|
||||
|
||||
if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) {
|
||||
return nil, fmt.Errorf("%w: invalid stash_box_index %d", ErrInput, input.StashBoxIndex)
|
||||
}
|
||||
|
||||
client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.txnManager)
|
||||
|
||||
if len(input.PerformerIds) > 0 {
|
||||
return client.FindStashBoxPerformersByNames(ctx, input.PerformerIds)
|
||||
}
|
||||
|
||||
if input.Q != nil {
|
||||
return client.QueryStashBoxPerformer(ctx, *input.Q)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) getStashBoxClient(index int) (*stashbox.Client, error) {
|
||||
boxes := config.GetInstance().GetStashBoxes()
|
||||
|
||||
|
|
@ -280,6 +240,15 @@ func (r *queryResolver) getStashBoxClient(index int) (*stashbox.Client, error) {
|
|||
func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source models.ScraperSourceInput, input models.ScrapeSingleSceneInput) ([]*models.ScrapedScene, error) {
|
||||
var ret []*models.ScrapedScene
|
||||
|
||||
var sceneID int
|
||||
if input.SceneID != nil {
|
||||
var err error
|
||||
sceneID, err = strconv.Atoi(*input.SceneID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: sceneID is not an integer: '%s'", ErrInput, *input.SceneID)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case source.ScraperID != nil:
|
||||
var err error
|
||||
|
|
@ -288,11 +257,6 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source models.Scr
|
|||
|
||||
switch {
|
||||
case input.SceneID != nil:
|
||||
var sceneID int
|
||||
sceneID, err = strconv.Atoi(*input.SceneID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: sceneID is not an integer: '%s'", ErrInput, *input.SceneID)
|
||||
}
|
||||
c, err = r.scraperCache().ScrapeID(ctx, *source.ScraperID, sceneID, models.ScrapeContentTypeScene)
|
||||
if c != nil {
|
||||
content = []models.ScrapedContent{c}
|
||||
|
|
@ -324,7 +288,7 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source models.Scr
|
|||
|
||||
switch {
|
||||
case input.SceneID != nil:
|
||||
ret, err = client.FindStashBoxScenesByFingerprintsFlat(ctx, []string{*input.SceneID})
|
||||
ret, err = client.FindStashBoxSceneByFingerprints(ctx, sceneID)
|
||||
case input.Query != nil:
|
||||
ret, err = client.QueryStashBoxScene(ctx, *input.Query)
|
||||
default:
|
||||
|
|
@ -352,7 +316,12 @@ func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source models.Scr
|
|||
return nil, err
|
||||
}
|
||||
|
||||
return client.FindStashBoxScenesByFingerprints(ctx, input.SceneIds)
|
||||
sceneIDs, err := stringslice.StringSliceToIntSlice(input.SceneIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client.FindStashBoxScenesByFingerprints(ctx, sceneIDs)
|
||||
}
|
||||
|
||||
return nil, errors.New("scraper_id or stash_box_index must be set")
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ func Start() error {
|
|||
|
||||
prefix := getProxyPrefix(r.Header)
|
||||
if prefix != "" {
|
||||
r.URL.Path = strings.Replace(r.URL.Path, prefix, "", 1)
|
||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
|
||||
}
|
||||
r.URL.Path = uiRootDir + r.URL.Path
|
||||
|
||||
|
|
|
|||
|
|
@ -7,12 +7,16 @@ import (
|
|||
)
|
||||
|
||||
func getGalleryFileTagger(s *models.Gallery, cache *match.Cache) tagger {
|
||||
// only trim the extension if gallery is file-based
|
||||
trimExt := s.Zip
|
||||
|
||||
return tagger{
|
||||
ID: s.ID,
|
||||
Type: "gallery",
|
||||
Name: s.GetTitle(),
|
||||
Path: s.Path.String,
|
||||
cache: cache,
|
||||
ID: s.ID,
|
||||
Type: "gallery",
|
||||
Name: s.GetTitle(),
|
||||
Path: s.Path.String,
|
||||
trimExt: trimExt,
|
||||
cache: cache,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ func generateNamePatterns(name, separator, ext string) []string {
|
|||
ret = append(ret, fmt.Sprintf("aaa%s%s.%s", separator, name, ext))
|
||||
ret = append(ret, fmt.Sprintf("aaa%s%s%sbbb.%s", separator, name, separator, ext))
|
||||
ret = append(ret, fmt.Sprintf("dir/%s%saaa.%s", name, separator, ext))
|
||||
ret = append(ret, fmt.Sprintf("dir%sdir/%s%saaa.%s", separator, name, separator, ext))
|
||||
ret = append(ret, fmt.Sprintf("dir\\%s%saaa.%s", name, separator, ext))
|
||||
ret = append(ret, fmt.Sprintf("%s%saaa/dir/bbb.%s", name, separator, ext))
|
||||
ret = append(ret, fmt.Sprintf("%s%saaa\\dir\\bbb.%s", name, separator, ext))
|
||||
|
|
|
|||
|
|
@ -22,10 +22,11 @@ import (
|
|||
)
|
||||
|
||||
type tagger struct {
|
||||
ID int
|
||||
Type string
|
||||
Name string
|
||||
Path string
|
||||
ID int
|
||||
Type string
|
||||
Name string
|
||||
Path string
|
||||
trimExt bool
|
||||
|
||||
cache *match.Cache
|
||||
}
|
||||
|
|
@ -41,7 +42,7 @@ func (t *tagger) addLog(otherType, otherName string) {
|
|||
}
|
||||
|
||||
func (t *tagger) tagPerformers(performerReader models.PerformerReader, addFunc addLinkFunc) error {
|
||||
others, err := match.PathToPerformers(t.Path, performerReader, t.cache)
|
||||
others, err := match.PathToPerformers(t.Path, performerReader, t.cache, t.trimExt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -62,7 +63,7 @@ func (t *tagger) tagPerformers(performerReader models.PerformerReader, addFunc a
|
|||
}
|
||||
|
||||
func (t *tagger) tagStudios(studioReader models.StudioReader, addFunc addLinkFunc) error {
|
||||
studio, err := match.PathToStudio(t.Path, studioReader, t.cache)
|
||||
studio, err := match.PathToStudio(t.Path, studioReader, t.cache, t.trimExt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -83,7 +84,7 @@ func (t *tagger) tagStudios(studioReader models.StudioReader, addFunc addLinkFun
|
|||
}
|
||||
|
||||
func (t *tagger) tagTags(tagReader models.TagReader, addFunc addLinkFunc) error {
|
||||
others, err := match.PathToTags(t.Path, tagReader, t.cache)
|
||||
others, err := match.PathToTags(t.Path, tagReader, t.cache, t.trimExt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@ const (
|
|||
ImageLightboxScrollMode = "image_lightbox.scroll_mode"
|
||||
ImageLightboxScrollAttemptsBeforeChange = "image_lightbox.scroll_attempts_before_change"
|
||||
|
||||
UI = "ui"
|
||||
|
||||
defaultImageLightboxSlideshowDelay = 5000
|
||||
|
||||
DisableDropdownCreatePerformer = "disable_dropdown_create.performer"
|
||||
|
|
@ -971,6 +973,26 @@ func (i *Instance) GetDisableDropdownCreate() *models.ConfigDisableDropdownCreat
|
|||
}
|
||||
}
|
||||
|
||||
func (i *Instance) GetUIConfiguration() map[string]interface{} {
|
||||
i.RLock()
|
||||
defer i.RUnlock()
|
||||
|
||||
// HACK: viper changes map keys to case insensitive values, so the workaround is to
|
||||
// convert map keys to snake case for storage
|
||||
v := i.viper(UI).GetStringMap(UI)
|
||||
|
||||
return fromSnakeCaseMap(v)
|
||||
}
|
||||
|
||||
func (i *Instance) SetUIConfiguration(v map[string]interface{}) {
|
||||
i.RLock()
|
||||
defer i.RUnlock()
|
||||
|
||||
// HACK: viper changes map keys to case insensitive values, so the workaround is to
|
||||
// convert map keys to snake case for storage
|
||||
i.viper(UI).Set(UI, toSnakeCaseMap(v))
|
||||
}
|
||||
|
||||
func (i *Instance) GetCSSPath() string {
|
||||
// use custom.css in the same directory as the config file
|
||||
configFileUsed := i.GetConfigFile()
|
||||
|
|
|
|||
100
internal/manager/config/map.go
Normal file
100
internal/manager/config/map.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"unicode"
|
||||
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// HACK: viper changes map keys to case insensitive values, so the workaround is to
|
||||
// convert the map to use snake-case keys
|
||||
|
||||
// toSnakeCase converts a string to snake_case
|
||||
// NOTE: a double capital will be converted in a way that will yield a different result
|
||||
// when converted back to camel case.
|
||||
// For example: someIDs => some_ids => someIds
|
||||
func toSnakeCase(v string) string {
|
||||
var buf bytes.Buffer
|
||||
underscored := false
|
||||
for i, c := range v {
|
||||
if !underscored && unicode.IsUpper(c) && i > 0 {
|
||||
buf.WriteByte('_')
|
||||
underscored = true
|
||||
} else {
|
||||
underscored = false
|
||||
}
|
||||
|
||||
buf.WriteRune(unicode.ToLower(c))
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func fromSnakeCase(v string) string {
|
||||
var buf bytes.Buffer
|
||||
cap := false
|
||||
for i, c := range v {
|
||||
switch {
|
||||
case c == '_' && i > 0:
|
||||
cap = true
|
||||
case cap:
|
||||
buf.WriteRune(unicode.ToUpper(c))
|
||||
cap = false
|
||||
default:
|
||||
buf.WriteRune(c)
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// copyAndInsensitiviseMap behaves like insensitiviseMap, but creates a copy of
|
||||
// any map it makes case insensitive.
|
||||
func toSnakeCaseMap(m map[string]interface{}) map[string]interface{} {
|
||||
nm := make(map[string]interface{})
|
||||
|
||||
for key, val := range m {
|
||||
adjKey := toSnakeCase(key)
|
||||
nm[adjKey] = val
|
||||
}
|
||||
|
||||
return nm
|
||||
}
|
||||
|
||||
// convertMapValue converts values into something that can be marshalled in JSON
|
||||
// This means converting map[interface{}]interface{} to map[string]interface{} where ever
|
||||
// encountered.
|
||||
func convertMapValue(val interface{}) interface{} {
|
||||
switch v := val.(type) {
|
||||
case map[interface{}]interface{}:
|
||||
ret := cast.ToStringMap(v)
|
||||
for k, vv := range ret {
|
||||
ret[k] = convertMapValue(vv)
|
||||
}
|
||||
return ret
|
||||
case map[string]interface{}:
|
||||
ret := make(map[string]interface{})
|
||||
for k, vv := range v {
|
||||
ret[k] = convertMapValue(vv)
|
||||
}
|
||||
return ret
|
||||
case []interface{}:
|
||||
ret := make([]interface{}, len(v))
|
||||
for i, vv := range v {
|
||||
ret[i] = convertMapValue(vv)
|
||||
}
|
||||
return ret
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func fromSnakeCaseMap(m map[string]interface{}) map[string]interface{} {
|
||||
nm := make(map[string]interface{})
|
||||
|
||||
for key, val := range m {
|
||||
adjKey := fromSnakeCase(key)
|
||||
nm[adjKey] = convertMapValue(val)
|
||||
}
|
||||
|
||||
return nm
|
||||
}
|
||||
82
internal/manager/config/map_test.go
Normal file
82
internal/manager/config/map_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_toSnakeCase(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
v string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"basic",
|
||||
"basic",
|
||||
"basic",
|
||||
},
|
||||
{
|
||||
"two words",
|
||||
"twoWords",
|
||||
"two_words",
|
||||
},
|
||||
{
|
||||
"three word value",
|
||||
"threeWordValue",
|
||||
"three_word_value",
|
||||
},
|
||||
{
|
||||
"snake case",
|
||||
"snake_case",
|
||||
"snake_case",
|
||||
},
|
||||
{
|
||||
"double capital",
|
||||
"doubleCApital",
|
||||
"double_capital",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := toSnakeCase(tt.v); got != tt.want {
|
||||
t.Errorf("toSnakeCase() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_fromSnakeCase(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
v string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"basic",
|
||||
"basic",
|
||||
"basic",
|
||||
},
|
||||
{
|
||||
"two words",
|
||||
"two_words",
|
||||
"twoWords",
|
||||
},
|
||||
{
|
||||
"three word value",
|
||||
"three_word_value",
|
||||
"threeWordValue",
|
||||
},
|
||||
{
|
||||
"camel case",
|
||||
"camelCase",
|
||||
"camelCase",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := fromSnakeCase(tt.v); got != tt.want {
|
||||
t.Errorf("fromSnakeCase() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/internal/identify"
|
||||
"github.com/stashapp/stash/pkg/job"
|
||||
|
|
@ -212,7 +211,7 @@ type stashboxSource struct {
|
|||
}
|
||||
|
||||
func (s stashboxSource) ScrapeScene(ctx context.Context, sceneID int) (*models.ScrapedScene, error) {
|
||||
results, err := s.FindStashBoxScenesByFingerprintsFlat(ctx, []string{strconv.Itoa(sceneID)})
|
||||
results, err := s.FindStashBoxSceneByFingerprints(ctx, sceneID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error querying stash-box using scene ID %d: %w", sceneID, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,5 +12,5 @@ import (
|
|||
|
||||
// hideExecShell hides the windows when executing on Windows.
|
||||
func hideExecShell(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS & windows.CREATE_NO_WINDOW}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,7 +187,8 @@ func (scanner *Scanner) ScanNew(ctx context.Context, file file.SourceFile) (retG
|
|||
scanner.PluginCache.ExecutePostHooks(ctx, g.ID, plugin.GalleryUpdatePost, nil, nil)
|
||||
}
|
||||
|
||||
scanImages = isNewGallery
|
||||
// Also scan images if zip file has been moved (ie updated) as the image paths are no longer valid
|
||||
scanImages = isNewGallery || isUpdatedGallery
|
||||
retGallery = g
|
||||
|
||||
return
|
||||
|
|
|
|||
|
|
@ -37,13 +37,15 @@ func getPathQueryRegex(name string) string {
|
|||
return ret
|
||||
}
|
||||
|
||||
func getPathWords(path string) []string {
|
||||
func getPathWords(path string, trimExt bool) []string {
|
||||
retStr := path
|
||||
|
||||
// remove the extension
|
||||
ext := filepath.Ext(retStr)
|
||||
if ext != "" {
|
||||
retStr = strings.TrimSuffix(retStr, ext)
|
||||
if trimExt {
|
||||
// remove the extension
|
||||
ext := filepath.Ext(retStr)
|
||||
if ext != "" {
|
||||
retStr = strings.TrimSuffix(retStr, ext)
|
||||
}
|
||||
}
|
||||
|
||||
// handle path separators
|
||||
|
|
@ -136,8 +138,8 @@ func getPerformers(words []string, performerReader models.PerformerReader, cache
|
|||
return append(performers, swPerformers...), nil
|
||||
}
|
||||
|
||||
func PathToPerformers(path string, reader models.PerformerReader, cache *Cache) ([]*models.Performer, error) {
|
||||
words := getPathWords(path)
|
||||
func PathToPerformers(path string, reader models.PerformerReader, cache *Cache, trimExt bool) ([]*models.Performer, error) {
|
||||
words := getPathWords(path, trimExt)
|
||||
|
||||
performers, err := getPerformers(words, reader, cache)
|
||||
if err != nil {
|
||||
|
|
@ -172,8 +174,8 @@ func getStudios(words []string, reader models.StudioReader, cache *Cache) ([]*mo
|
|||
// PathToStudio returns the Studio that matches the given path.
|
||||
// Where multiple matching studios are found, the one that matches the latest
|
||||
// position in the path is returned.
|
||||
func PathToStudio(path string, reader models.StudioReader, cache *Cache) (*models.Studio, error) {
|
||||
words := getPathWords(path)
|
||||
func PathToStudio(path string, reader models.StudioReader, cache *Cache, trimExt bool) (*models.Studio, error) {
|
||||
words := getPathWords(path, trimExt)
|
||||
candidates, err := getStudios(words, reader, cache)
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -220,8 +222,8 @@ func getTags(words []string, reader models.TagReader, cache *Cache) ([]*models.T
|
|||
return append(tags, swTags...), nil
|
||||
}
|
||||
|
||||
func PathToTags(path string, reader models.TagReader, cache *Cache) ([]*models.Tag, error) {
|
||||
words := getPathWords(path)
|
||||
func PathToTags(path string, reader models.TagReader, cache *Cache, trimExt bool) ([]*models.Tag, error) {
|
||||
words := getPathWords(path, trimExt)
|
||||
tags, err := getTags(words, reader, cache)
|
||||
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"os"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models/json"
|
||||
)
|
||||
|
||||
|
|
@ -15,7 +17,7 @@ type Movie struct {
|
|||
Date string `json:"date,omitempty"`
|
||||
Rating int `json:"rating,omitempty"`
|
||||
Director string `json:"director,omitempty"`
|
||||
Synopsis string `json:"sypnopsis,omitempty"`
|
||||
Synopsis string `json:"synopsis,omitempty"`
|
||||
FrontImage string `json:"front_image,omitempty"`
|
||||
BackImage string `json:"back_image,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
|
|
@ -24,6 +26,11 @@ type Movie struct {
|
|||
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// Backwards Compatible synopsis for the movie
|
||||
type MovieSynopsisBC struct {
|
||||
Synopsis string `json:"sypnopsis,omitempty"`
|
||||
}
|
||||
|
||||
func LoadMovieFile(filePath string) (*Movie, error) {
|
||||
var movie Movie
|
||||
file, err := os.Open(filePath)
|
||||
|
|
@ -37,6 +44,22 @@ func LoadMovieFile(filePath string) (*Movie, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if movie.Synopsis == "" {
|
||||
// keep backwards compatibility with pre #2664 builds
|
||||
// attempt to get the synopsis from the alternate (sypnopsis) key
|
||||
|
||||
_, err = file.Seek(0, 0) // seek to start of file
|
||||
if err == nil {
|
||||
var synopsis MovieSynopsisBC
|
||||
err = jsonParser.Decode(&synopsis)
|
||||
if err == nil {
|
||||
movie.Synopsis = synopsis.Synopsis
|
||||
if movie.Synopsis != "" {
|
||||
logger.Debug("Movie synopsis retrieved from alternate key")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &movie, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,29 @@ type SavedFilterReaderWriter struct {
|
|||
mock.Mock
|
||||
}
|
||||
|
||||
// All provides a mock function with given fields:
|
||||
func (_m *SavedFilterReaderWriter) All() ([]*models.SavedFilter, error) {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 []*models.SavedFilter
|
||||
if rf, ok := ret.Get(0).(func() []*models.SavedFilter); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.SavedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func() error); ok {
|
||||
r1 = rf()
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Create provides a mock function with given fields: obj
|
||||
func (_m *SavedFilterReaderWriter) Create(obj models.SavedFilter) (*models.SavedFilter, error) {
|
||||
ret := _m.Called(obj)
|
||||
|
|
@ -118,6 +141,29 @@ func (_m *SavedFilterReaderWriter) FindDefault(mode models.FilterMode) (*models.
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// FindMany provides a mock function with given fields: ids, ignoreNotFound
|
||||
func (_m *SavedFilterReaderWriter) FindMany(ids []int, ignoreNotFound bool) ([]*models.SavedFilter, error) {
|
||||
ret := _m.Called(ids, ignoreNotFound)
|
||||
|
||||
var r0 []*models.SavedFilter
|
||||
if rf, ok := ret.Get(0).(func([]int, bool) []*models.SavedFilter); ok {
|
||||
r0 = rf(ids, ignoreNotFound)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.SavedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func([]int, bool) error); ok {
|
||||
r1 = rf(ids, ignoreNotFound)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// SetDefault provides a mock function with given fields: obj
|
||||
func (_m *SavedFilterReaderWriter) SetDefault(obj models.SavedFilter) (*models.SavedFilter, error) {
|
||||
ret := _m.Called(obj)
|
||||
|
|
|
|||
|
|
@ -482,6 +482,7 @@ func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) {
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// GetCaptions provides a mock function with given fields: sceneID
|
||||
func (_m *SceneReaderWriter) GetCaptions(sceneID int) ([]*models.SceneCaption, error) {
|
||||
ret := _m.Called(sceneID)
|
||||
|
||||
|
|
@ -751,13 +752,13 @@ func (_m *SceneReaderWriter) Update(updatedScene models.ScenePartial) (*models.S
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// UpdateCaptions provides a mock function with given fields: id, newCaptions
|
||||
func (_m *SceneReaderWriter) UpdateCaptions(sceneID int, captions []*models.SceneCaption) error {
|
||||
ret := _m.Called(sceneID, captions)
|
||||
// UpdateCaptions provides a mock function with given fields: id, captions
|
||||
func (_m *SceneReaderWriter) UpdateCaptions(id int, captions []*models.SceneCaption) error {
|
||||
ret := _m.Called(id, captions)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(int, []*models.SceneCaption) error); ok {
|
||||
r0 = rf(sceneID, captions)
|
||||
r0 = rf(id, captions)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package models
|
||||
|
||||
type SavedFilterReader interface {
|
||||
All() ([]*SavedFilter, error)
|
||||
Find(id int) (*SavedFilter, error)
|
||||
FindMany(ids []int, ignoreNotFound bool) ([]*SavedFilter, error)
|
||||
FindByMode(mode FilterMode) ([]*SavedFilter, error)
|
||||
FindDefault(mode FilterMode) (*SavedFilter, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ type autotagScraper struct {
|
|||
globalConfig GlobalConfig
|
||||
}
|
||||
|
||||
func autotagMatchPerformers(path string, performerReader models.PerformerReader) ([]*models.ScrapedPerformer, error) {
|
||||
p, err := match.PathToPerformers(path, performerReader, nil)
|
||||
func autotagMatchPerformers(path string, performerReader models.PerformerReader, trimExt bool) ([]*models.ScrapedPerformer, error) {
|
||||
p, err := match.PathToPerformers(path, performerReader, nil, trimExt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error matching performers: %w", err)
|
||||
}
|
||||
|
|
@ -45,8 +45,8 @@ func autotagMatchPerformers(path string, performerReader models.PerformerReader)
|
|||
return ret, nil
|
||||
}
|
||||
|
||||
func autotagMatchStudio(path string, studioReader models.StudioReader) (*models.ScrapedStudio, error) {
|
||||
studio, err := match.PathToStudio(path, studioReader, nil)
|
||||
func autotagMatchStudio(path string, studioReader models.StudioReader, trimExt bool) (*models.ScrapedStudio, error) {
|
||||
studio, err := match.PathToStudio(path, studioReader, nil, trimExt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error matching studios: %w", err)
|
||||
}
|
||||
|
|
@ -62,8 +62,8 @@ func autotagMatchStudio(path string, studioReader models.StudioReader) (*models.
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
func autotagMatchTags(path string, tagReader models.TagReader) ([]*models.ScrapedTag, error) {
|
||||
t, err := match.PathToTags(path, tagReader, nil)
|
||||
func autotagMatchTags(path string, tagReader models.TagReader, trimExt bool) ([]*models.ScrapedTag, error) {
|
||||
t, err := match.PathToTags(path, tagReader, nil, trimExt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error matching tags: %w", err)
|
||||
}
|
||||
|
|
@ -85,20 +85,21 @@ func autotagMatchTags(path string, tagReader models.TagReader) ([]*models.Scrape
|
|||
|
||||
func (s autotagScraper) viaScene(ctx context.Context, _client *http.Client, scene *models.Scene) (*models.ScrapedScene, error) {
|
||||
var ret *models.ScrapedScene
|
||||
const trimExt = false
|
||||
|
||||
// populate performers, studio and tags based on scene path
|
||||
if err := s.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
|
||||
path := scene.Path
|
||||
performers, err := autotagMatchPerformers(path, r.Performer())
|
||||
performers, err := autotagMatchPerformers(path, r.Performer(), trimExt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autotag scraper viaScene: %w", err)
|
||||
}
|
||||
studio, err := autotagMatchStudio(path, r.Studio())
|
||||
studio, err := autotagMatchStudio(path, r.Studio(), trimExt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autotag scraper viaScene: %w", err)
|
||||
}
|
||||
|
||||
tags, err := autotagMatchTags(path, r.Tag())
|
||||
tags, err := autotagMatchTags(path, r.Tag(), trimExt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autotag scraper viaScene: %w", err)
|
||||
}
|
||||
|
|
@ -125,21 +126,24 @@ func (s autotagScraper) viaGallery(ctx context.Context, _client *http.Client, ga
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
// only trim extension if gallery is file-based
|
||||
trimExt := gallery.Zip
|
||||
|
||||
var ret *models.ScrapedGallery
|
||||
|
||||
// populate performers, studio and tags based on scene path
|
||||
if err := s.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
|
||||
path := gallery.Path.String
|
||||
performers, err := autotagMatchPerformers(path, r.Performer())
|
||||
performers, err := autotagMatchPerformers(path, r.Performer(), trimExt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autotag scraper viaGallery: %w", err)
|
||||
}
|
||||
studio, err := autotagMatchStudio(path, r.Studio())
|
||||
studio, err := autotagMatchStudio(path, r.Studio(), trimExt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autotag scraper viaGallery: %w", err)
|
||||
}
|
||||
|
||||
tags, err := autotagMatchTags(path, r.Tag())
|
||||
tags, err := autotagMatchTags(path, r.Tag(), trimExt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autotag scraper viaGallery: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
type StashBoxGraphQLClient interface {
|
||||
FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByFingerprint, error)
|
||||
FindScenesByFullFingerprints(ctx context.Context, fingerprints []*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesByFullFingerprints, error)
|
||||
FindScenesBySceneFingerprints(ctx context.Context, fingerprints [][]*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesBySceneFingerprints, error)
|
||||
SearchScene(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchScene, error)
|
||||
SearchPerformer(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchPerformer, error)
|
||||
FindPerformerByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindPerformerByID, error)
|
||||
|
|
@ -31,32 +32,33 @@ func NewClient(cli *http.Client, baseURL string, options ...client.HTTPRequestOp
|
|||
}
|
||||
|
||||
type Query struct {
|
||||
FindPerformer *Performer "json:\"findPerformer\" graphql:\"findPerformer\""
|
||||
QueryPerformers QueryPerformersResultType "json:\"queryPerformers\" graphql:\"queryPerformers\""
|
||||
FindStudio *Studio "json:\"findStudio\" graphql:\"findStudio\""
|
||||
QueryStudios QueryStudiosResultType "json:\"queryStudios\" graphql:\"queryStudios\""
|
||||
FindTag *Tag "json:\"findTag\" graphql:\"findTag\""
|
||||
QueryTags QueryTagsResultType "json:\"queryTags\" graphql:\"queryTags\""
|
||||
FindTagCategory *TagCategory "json:\"findTagCategory\" graphql:\"findTagCategory\""
|
||||
QueryTagCategories QueryTagCategoriesResultType "json:\"queryTagCategories\" graphql:\"queryTagCategories\""
|
||||
FindScene *Scene "json:\"findScene\" graphql:\"findScene\""
|
||||
FindSceneByFingerprint []*Scene "json:\"findSceneByFingerprint\" graphql:\"findSceneByFingerprint\""
|
||||
FindScenesByFingerprints []*Scene "json:\"findScenesByFingerprints\" graphql:\"findScenesByFingerprints\""
|
||||
FindScenesByFullFingerprints []*Scene "json:\"findScenesByFullFingerprints\" graphql:\"findScenesByFullFingerprints\""
|
||||
QueryScenes QueryScenesResultType "json:\"queryScenes\" graphql:\"queryScenes\""
|
||||
FindSite *Site "json:\"findSite\" graphql:\"findSite\""
|
||||
QuerySites QuerySitesResultType "json:\"querySites\" graphql:\"querySites\""
|
||||
FindEdit *Edit "json:\"findEdit\" graphql:\"findEdit\""
|
||||
QueryEdits QueryEditsResultType "json:\"queryEdits\" graphql:\"queryEdits\""
|
||||
FindUser *User "json:\"findUser\" graphql:\"findUser\""
|
||||
QueryUsers QueryUsersResultType "json:\"queryUsers\" graphql:\"queryUsers\""
|
||||
Me *User "json:\"me\" graphql:\"me\""
|
||||
SearchPerformer []*Performer "json:\"searchPerformer\" graphql:\"searchPerformer\""
|
||||
SearchScene []*Scene "json:\"searchScene\" graphql:\"searchScene\""
|
||||
FindDraft *Draft "json:\"findDraft\" graphql:\"findDraft\""
|
||||
FindDrafts []*Draft "json:\"findDrafts\" graphql:\"findDrafts\""
|
||||
Version Version "json:\"version\" graphql:\"version\""
|
||||
GetConfig StashBoxConfig "json:\"getConfig\" graphql:\"getConfig\""
|
||||
FindPerformer *Performer "json:\"findPerformer\" graphql:\"findPerformer\""
|
||||
QueryPerformers QueryPerformersResultType "json:\"queryPerformers\" graphql:\"queryPerformers\""
|
||||
FindStudio *Studio "json:\"findStudio\" graphql:\"findStudio\""
|
||||
QueryStudios QueryStudiosResultType "json:\"queryStudios\" graphql:\"queryStudios\""
|
||||
FindTag *Tag "json:\"findTag\" graphql:\"findTag\""
|
||||
QueryTags QueryTagsResultType "json:\"queryTags\" graphql:\"queryTags\""
|
||||
FindTagCategory *TagCategory "json:\"findTagCategory\" graphql:\"findTagCategory\""
|
||||
QueryTagCategories QueryTagCategoriesResultType "json:\"queryTagCategories\" graphql:\"queryTagCategories\""
|
||||
FindScene *Scene "json:\"findScene\" graphql:\"findScene\""
|
||||
FindSceneByFingerprint []*Scene "json:\"findSceneByFingerprint\" graphql:\"findSceneByFingerprint\""
|
||||
FindScenesByFingerprints []*Scene "json:\"findScenesByFingerprints\" graphql:\"findScenesByFingerprints\""
|
||||
FindScenesByFullFingerprints []*Scene "json:\"findScenesByFullFingerprints\" graphql:\"findScenesByFullFingerprints\""
|
||||
FindScenesBySceneFingerprints [][]*Scene "json:\"findScenesBySceneFingerprints\" graphql:\"findScenesBySceneFingerprints\""
|
||||
QueryScenes QueryScenesResultType "json:\"queryScenes\" graphql:\"queryScenes\""
|
||||
FindSite *Site "json:\"findSite\" graphql:\"findSite\""
|
||||
QuerySites QuerySitesResultType "json:\"querySites\" graphql:\"querySites\""
|
||||
FindEdit *Edit "json:\"findEdit\" graphql:\"findEdit\""
|
||||
QueryEdits QueryEditsResultType "json:\"queryEdits\" graphql:\"queryEdits\""
|
||||
FindUser *User "json:\"findUser\" graphql:\"findUser\""
|
||||
QueryUsers QueryUsersResultType "json:\"queryUsers\" graphql:\"queryUsers\""
|
||||
Me *User "json:\"me\" graphql:\"me\""
|
||||
SearchPerformer []*Performer "json:\"searchPerformer\" graphql:\"searchPerformer\""
|
||||
SearchScene []*Scene "json:\"searchScene\" graphql:\"searchScene\""
|
||||
FindDraft *Draft "json:\"findDraft\" graphql:\"findDraft\""
|
||||
FindDrafts []*Draft "json:\"findDrafts\" graphql:\"findDrafts\""
|
||||
Version Version "json:\"version\" graphql:\"version\""
|
||||
GetConfig StashBoxConfig "json:\"getConfig\" graphql:\"getConfig\""
|
||||
}
|
||||
type Mutation struct {
|
||||
SceneCreate *Scene "json:\"sceneCreate\" graphql:\"sceneCreate\""
|
||||
|
|
@ -95,6 +97,10 @@ type Mutation struct {
|
|||
PerformerEdit Edit "json:\"performerEdit\" graphql:\"performerEdit\""
|
||||
StudioEdit Edit "json:\"studioEdit\" graphql:\"studioEdit\""
|
||||
TagEdit Edit "json:\"tagEdit\" graphql:\"tagEdit\""
|
||||
SceneEditUpdate Edit "json:\"sceneEditUpdate\" graphql:\"sceneEditUpdate\""
|
||||
PerformerEditUpdate Edit "json:\"performerEditUpdate\" graphql:\"performerEditUpdate\""
|
||||
StudioEditUpdate Edit "json:\"studioEditUpdate\" graphql:\"studioEditUpdate\""
|
||||
TagEditUpdate Edit "json:\"tagEditUpdate\" graphql:\"tagEditUpdate\""
|
||||
EditVote Edit "json:\"editVote\" graphql:\"editVote\""
|
||||
EditComment Edit "json:\"editComment\" graphql:\"editComment\""
|
||||
ApplyEdit Edit "json:\"applyEdit\" graphql:\"applyEdit\""
|
||||
|
|
@ -190,6 +196,9 @@ type FindSceneByFingerprint struct {
|
|||
type FindScenesByFullFingerprints struct {
|
||||
FindScenesByFullFingerprints []*SceneFragment "json:\"findScenesByFullFingerprints\" graphql:\"findScenesByFullFingerprints\""
|
||||
}
|
||||
type FindScenesBySceneFingerprints struct {
|
||||
FindScenesBySceneFingerprints [][]*SceneFragment "json:\"findScenesBySceneFingerprints\" graphql:\"findScenesBySceneFingerprints\""
|
||||
}
|
||||
type SearchScene struct {
|
||||
SearchScene []*SceneFragment "json:\"searchScene\" graphql:\"searchScene\""
|
||||
}
|
||||
|
|
@ -230,6 +239,52 @@ fragment URLFragment on URL {
|
|||
url
|
||||
type
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
band_size
|
||||
cup_size
|
||||
waist
|
||||
hip
|
||||
}
|
||||
fragment FingerprintFragment on Fingerprint {
|
||||
algorithm
|
||||
hash
|
||||
duration
|
||||
}
|
||||
fragment SceneFragment on Scene {
|
||||
id
|
||||
title
|
||||
details
|
||||
duration
|
||||
date
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
studio {
|
||||
... StudioFragment
|
||||
}
|
||||
tags {
|
||||
... TagFragment
|
||||
}
|
||||
performers {
|
||||
... PerformerAppearanceFragment
|
||||
}
|
||||
fingerprints {
|
||||
... FingerprintFragment
|
||||
}
|
||||
}
|
||||
fragment StudioFragment on Studio {
|
||||
name
|
||||
id
|
||||
|
|
@ -240,6 +295,12 @@ fragment StudioFragment on Studio {
|
|||
... ImageFragment
|
||||
}
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
performer {
|
||||
... PerformerFragment
|
||||
}
|
||||
}
|
||||
fragment PerformerFragment on Performer {
|
||||
id
|
||||
name
|
||||
|
|
@ -278,62 +339,10 @@ fragment FuzzyDateFragment on FuzzyDate {
|
|||
date
|
||||
accuracy
|
||||
}
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
band_size
|
||||
cup_size
|
||||
waist
|
||||
hip
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
fragment FingerprintFragment on Fingerprint {
|
||||
algorithm
|
||||
hash
|
||||
duration
|
||||
}
|
||||
fragment SceneFragment on Scene {
|
||||
id
|
||||
title
|
||||
details
|
||||
duration
|
||||
date
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
studio {
|
||||
... StudioFragment
|
||||
}
|
||||
tags {
|
||||
... TagFragment
|
||||
}
|
||||
performers {
|
||||
... PerformerAppearanceFragment
|
||||
}
|
||||
fingerprints {
|
||||
... FingerprintFragment
|
||||
}
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
performer {
|
||||
... PerformerFragment
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func (c *Client) FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByFingerprint, error) {
|
||||
|
|
@ -354,6 +363,31 @@ const FindScenesByFullFingerprintsDocument = `query FindScenesByFullFingerprints
|
|||
... SceneFragment
|
||||
}
|
||||
}
|
||||
fragment SceneFragment on Scene {
|
||||
id
|
||||
title
|
||||
details
|
||||
duration
|
||||
date
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
studio {
|
||||
... StudioFragment
|
||||
}
|
||||
tags {
|
||||
... TagFragment
|
||||
}
|
||||
performers {
|
||||
... PerformerAppearanceFragment
|
||||
}
|
||||
fingerprints {
|
||||
... FingerprintFragment
|
||||
}
|
||||
}
|
||||
fragment URLFragment on URL {
|
||||
url
|
||||
type
|
||||
|
|
@ -374,6 +408,25 @@ fragment PerformerAppearanceFragment on PerformerAppearance {
|
|||
... PerformerFragment
|
||||
}
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
fragment FingerprintFragment on Fingerprint {
|
||||
algorithm
|
||||
hash
|
||||
duration
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment PerformerFragment on Performer {
|
||||
id
|
||||
name
|
||||
|
|
@ -412,56 +465,12 @@ fragment FuzzyDateFragment on FuzzyDate {
|
|||
date
|
||||
accuracy
|
||||
}
|
||||
fragment SceneFragment on Scene {
|
||||
id
|
||||
title
|
||||
details
|
||||
duration
|
||||
date
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
studio {
|
||||
... StudioFragment
|
||||
}
|
||||
tags {
|
||||
... TagFragment
|
||||
}
|
||||
performers {
|
||||
... PerformerAppearanceFragment
|
||||
}
|
||||
fingerprints {
|
||||
... FingerprintFragment
|
||||
}
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
band_size
|
||||
cup_size
|
||||
waist
|
||||
hip
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
fragment FingerprintFragment on Fingerprint {
|
||||
algorithm
|
||||
hash
|
||||
duration
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
`
|
||||
|
||||
func (c *Client) FindScenesByFullFingerprints(ctx context.Context, fingerprints []*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesByFullFingerprints, error) {
|
||||
|
|
@ -477,8 +486,8 @@ func (c *Client) FindScenesByFullFingerprints(ctx context.Context, fingerprints
|
|||
return &res, nil
|
||||
}
|
||||
|
||||
const SearchSceneDocument = `query SearchScene ($term: String!) {
|
||||
searchScene(term: $term) {
|
||||
const FindScenesBySceneFingerprintsDocument = `query FindScenesBySceneFingerprints ($fingerprints: [[FingerprintQueryInput!]!]!) {
|
||||
findScenesBySceneFingerprints(fingerprints: $fingerprints) {
|
||||
... SceneFragment
|
||||
}
|
||||
}
|
||||
|
|
@ -486,54 +495,6 @@ fragment URLFragment on URL {
|
|||
url
|
||||
type
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment FuzzyDateFragment on FuzzyDate {
|
||||
date
|
||||
accuracy
|
||||
}
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
band_size
|
||||
cup_size
|
||||
waist
|
||||
hip
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
fragment FingerprintFragment on Fingerprint {
|
||||
algorithm
|
||||
hash
|
||||
duration
|
||||
}
|
||||
fragment SceneFragment on Scene {
|
||||
id
|
||||
title
|
||||
details
|
||||
duration
|
||||
date
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
studio {
|
||||
... StudioFragment
|
||||
}
|
||||
tags {
|
||||
... TagFragment
|
||||
}
|
||||
performers {
|
||||
... PerformerAppearanceFragment
|
||||
}
|
||||
fingerprints {
|
||||
... FingerprintFragment
|
||||
}
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
|
|
@ -550,12 +511,6 @@ fragment StudioFragment on Studio {
|
|||
... ImageFragment
|
||||
}
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
performer {
|
||||
... PerformerFragment
|
||||
}
|
||||
}
|
||||
fragment PerformerFragment on Performer {
|
||||
id
|
||||
name
|
||||
|
|
@ -590,6 +545,188 @@ fragment PerformerFragment on Performer {
|
|||
... BodyModificationFragment
|
||||
}
|
||||
}
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
band_size
|
||||
cup_size
|
||||
waist
|
||||
hip
|
||||
}
|
||||
fragment FingerprintFragment on Fingerprint {
|
||||
algorithm
|
||||
hash
|
||||
duration
|
||||
}
|
||||
fragment SceneFragment on Scene {
|
||||
id
|
||||
title
|
||||
details
|
||||
duration
|
||||
date
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
studio {
|
||||
... StudioFragment
|
||||
}
|
||||
tags {
|
||||
... TagFragment
|
||||
}
|
||||
performers {
|
||||
... PerformerAppearanceFragment
|
||||
}
|
||||
fingerprints {
|
||||
... FingerprintFragment
|
||||
}
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
performer {
|
||||
... PerformerFragment
|
||||
}
|
||||
}
|
||||
fragment FuzzyDateFragment on FuzzyDate {
|
||||
date
|
||||
accuracy
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
`
|
||||
|
||||
func (c *Client) FindScenesBySceneFingerprints(ctx context.Context, fingerprints [][]*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesBySceneFingerprints, error) {
|
||||
vars := map[string]interface{}{
|
||||
"fingerprints": fingerprints,
|
||||
}
|
||||
|
||||
var res FindScenesBySceneFingerprints
|
||||
if err := c.Client.Post(ctx, "FindScenesBySceneFingerprints", FindScenesBySceneFingerprintsDocument, &res, vars, httpRequestOptions...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
const SearchSceneDocument = `query SearchScene ($term: String!) {
|
||||
searchScene(term: $term) {
|
||||
... SceneFragment
|
||||
}
|
||||
}
|
||||
fragment SceneFragment on Scene {
|
||||
id
|
||||
title
|
||||
details
|
||||
duration
|
||||
date
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
studio {
|
||||
... StudioFragment
|
||||
}
|
||||
tags {
|
||||
... TagFragment
|
||||
}
|
||||
performers {
|
||||
... PerformerAppearanceFragment
|
||||
}
|
||||
fingerprints {
|
||||
... FingerprintFragment
|
||||
}
|
||||
}
|
||||
fragment URLFragment on URL {
|
||||
url
|
||||
type
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment PerformerFragment on Performer {
|
||||
id
|
||||
name
|
||||
disambiguation
|
||||
aliases
|
||||
gender
|
||||
merged_ids
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
birthdate {
|
||||
... FuzzyDateFragment
|
||||
}
|
||||
ethnicity
|
||||
country
|
||||
eye_color
|
||||
hair_color
|
||||
height
|
||||
measurements {
|
||||
... MeasurementsFragment
|
||||
}
|
||||
breast_type
|
||||
career_start_year
|
||||
career_end_year
|
||||
tattoos {
|
||||
... BodyModificationFragment
|
||||
}
|
||||
piercings {
|
||||
... BodyModificationFragment
|
||||
}
|
||||
}
|
||||
fragment FuzzyDateFragment on FuzzyDate {
|
||||
date
|
||||
accuracy
|
||||
}
|
||||
fragment FingerprintFragment on Fingerprint {
|
||||
algorithm
|
||||
hash
|
||||
duration
|
||||
}
|
||||
fragment StudioFragment on Studio {
|
||||
name
|
||||
id
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
performer {
|
||||
... PerformerFragment
|
||||
}
|
||||
}
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
band_size
|
||||
cup_size
|
||||
waist
|
||||
hip
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
`
|
||||
|
||||
func (c *Client) SearchScene(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchScene, error) {
|
||||
|
|
@ -610,30 +747,6 @@ const SearchPerformerDocument = `query SearchPerformer ($term: String!) {
|
|||
... PerformerFragment
|
||||
}
|
||||
}
|
||||
fragment URLFragment on URL {
|
||||
url
|
||||
type
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
fragment FuzzyDateFragment on FuzzyDate {
|
||||
date
|
||||
accuracy
|
||||
}
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
band_size
|
||||
cup_size
|
||||
waist
|
||||
hip
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
fragment PerformerFragment on Performer {
|
||||
id
|
||||
name
|
||||
|
|
@ -668,6 +781,30 @@ fragment PerformerFragment on Performer {
|
|||
... BodyModificationFragment
|
||||
}
|
||||
}
|
||||
fragment URLFragment on URL {
|
||||
url
|
||||
type
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
fragment FuzzyDateFragment on FuzzyDate {
|
||||
date
|
||||
accuracy
|
||||
}
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
band_size
|
||||
cup_size
|
||||
waist
|
||||
hip
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
`
|
||||
|
||||
func (c *Client) SearchPerformer(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchPerformer, error) {
|
||||
|
|
@ -688,10 +825,6 @@ const FindPerformerByIDDocument = `query FindPerformerByID ($id: ID!) {
|
|||
... PerformerFragment
|
||||
}
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
fragment PerformerFragment on Performer {
|
||||
id
|
||||
name
|
||||
|
|
@ -746,6 +879,10 @@ fragment MeasurementsFragment on Measurements {
|
|||
waist
|
||||
hip
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
`
|
||||
|
||||
func (c *Client) FindPerformerByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindPerformerByID, error) {
|
||||
|
|
@ -766,6 +903,24 @@ const FindSceneByIDDocument = `query FindSceneByID ($id: ID!) {
|
|||
... SceneFragment
|
||||
}
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
fragment URLFragment on URL {
|
||||
url
|
||||
type
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment PerformerFragment on Performer {
|
||||
id
|
||||
name
|
||||
|
|
@ -800,49 +955,16 @@ fragment PerformerFragment on Performer {
|
|||
... BodyModificationFragment
|
||||
}
|
||||
}
|
||||
fragment FuzzyDateFragment on FuzzyDate {
|
||||
date
|
||||
accuracy
|
||||
}
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
band_size
|
||||
cup_size
|
||||
waist
|
||||
hip
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
fragment URLFragment on URL {
|
||||
url
|
||||
type
|
||||
}
|
||||
fragment StudioFragment on Studio {
|
||||
name
|
||||
id
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
performer {
|
||||
... PerformerFragment
|
||||
}
|
||||
}
|
||||
fragment FuzzyDateFragment on FuzzyDate {
|
||||
date
|
||||
accuracy
|
||||
}
|
||||
fragment FingerprintFragment on Fingerprint {
|
||||
algorithm
|
||||
hash
|
||||
duration
|
||||
}
|
||||
fragment SceneFragment on Scene {
|
||||
id
|
||||
title
|
||||
|
|
@ -868,11 +990,26 @@ fragment SceneFragment on Scene {
|
|||
... FingerprintFragment
|
||||
}
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
fragment StudioFragment on Studio {
|
||||
name
|
||||
id
|
||||
url
|
||||
width
|
||||
height
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
performer {
|
||||
... PerformerFragment
|
||||
}
|
||||
}
|
||||
fragment FingerprintFragment on Fingerprint {
|
||||
algorithm
|
||||
hash
|
||||
duration
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -130,7 +130,9 @@ type Edit struct {
|
|||
Status VoteStatusEnum `json:"status"`
|
||||
Applied bool `json:"applied"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
Updated *time.Time `json:"updated,omitempty"`
|
||||
Closed *time.Time `json:"closed,omitempty"`
|
||||
Expires *time.Time `json:"expires,omitempty"`
|
||||
}
|
||||
|
||||
type EditComment struct {
|
||||
|
|
@ -149,8 +151,6 @@ type EditInput struct {
|
|||
// Not required for create type
|
||||
ID *string `json:"id,omitempty"`
|
||||
Operation OperationEnum `json:"operation"`
|
||||
// Required for amending an existing edit
|
||||
EditID *string `json:"edit_id,omitempty"`
|
||||
// Only required for merge type
|
||||
MergeSourceIds []string `json:"merge_source_ids,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
|
|
@ -206,15 +206,13 @@ type Fingerprint struct {
|
|||
}
|
||||
|
||||
type FingerprintEditInput struct {
|
||||
UserIds []string `json:"user_ids,omitempty"`
|
||||
Hash string `json:"hash"`
|
||||
Algorithm FingerprintAlgorithm `json:"algorithm"`
|
||||
Duration int `json:"duration"`
|
||||
Created time.Time `json:"created"`
|
||||
// @deprecated(reason: "unused")
|
||||
Submissions *int `json:"submissions,omitempty"`
|
||||
// @deprecated(reason: "unused")
|
||||
Updated *time.Time `json:"updated,omitempty"`
|
||||
UserIds []string `json:"user_ids,omitempty"`
|
||||
Hash string `json:"hash"`
|
||||
Algorithm FingerprintAlgorithm `json:"algorithm"`
|
||||
Duration int `json:"duration"`
|
||||
Created time.Time `json:"created"`
|
||||
Submissions *int `json:"submissions,omitempty"`
|
||||
Updated *time.Time `json:"updated,omitempty"`
|
||||
}
|
||||
|
||||
type FingerprintInput struct {
|
||||
|
|
@ -241,11 +239,6 @@ type FuzzyDate struct {
|
|||
Accuracy DateAccuracyEnum `json:"accuracy"`
|
||||
}
|
||||
|
||||
type FuzzyDateInput struct {
|
||||
Date string `json:"date"`
|
||||
Accuracy DateAccuracyEnum `json:"accuracy"`
|
||||
}
|
||||
|
||||
type GrantInviteInput struct {
|
||||
UserID string `json:"user_id"`
|
||||
Amount int `json:"amount"`
|
||||
|
|
@ -294,13 +287,6 @@ type Measurements struct {
|
|||
Hip *int `json:"hip,omitempty"`
|
||||
}
|
||||
|
||||
type MeasurementsInput struct {
|
||||
CupSize *string `json:"cup_size,omitempty"`
|
||||
BandSize *int `json:"band_size,omitempty"`
|
||||
Waist *int `json:"waist,omitempty"`
|
||||
Hip *int `json:"hip,omitempty"`
|
||||
}
|
||||
|
||||
type MultiIDCriterionInput struct {
|
||||
Value []string `json:"value,omitempty"`
|
||||
Modifier CriterionModifier `json:"modifier"`
|
||||
|
|
@ -324,6 +310,7 @@ type Performer struct {
|
|||
Gender *GenderEnum `json:"gender,omitempty"`
|
||||
Urls []*URL `json:"urls,omitempty"`
|
||||
Birthdate *FuzzyDate `json:"birthdate,omitempty"`
|
||||
BirthDate *string `json:"birth_date,omitempty"`
|
||||
Age *int `json:"age,omitempty"`
|
||||
Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"`
|
||||
Country *string `json:"country,omitempty"`
|
||||
|
|
@ -332,6 +319,10 @@ type Performer struct {
|
|||
// Height in cm
|
||||
Height *int `json:"height,omitempty"`
|
||||
Measurements *Measurements `json:"measurements,omitempty"`
|
||||
CupSize *string `json:"cup_size,omitempty"`
|
||||
BandSize *int `json:"band_size,omitempty"`
|
||||
WaistSize *int `json:"waist_size,omitempty"`
|
||||
HipSize *int `json:"hip_size,omitempty"`
|
||||
BreastType *BreastTypeEnum `json:"breast_type,omitempty"`
|
||||
CareerStartYear *int `json:"career_start_year,omitempty"`
|
||||
CareerEndYear *int `json:"career_end_year,omitempty"`
|
||||
|
|
@ -369,13 +360,16 @@ type PerformerCreateInput struct {
|
|||
Aliases []string `json:"aliases,omitempty"`
|
||||
Gender *GenderEnum `json:"gender,omitempty"`
|
||||
Urls []*URLInput `json:"urls,omitempty"`
|
||||
Birthdate *FuzzyDateInput `json:"birthdate,omitempty"`
|
||||
Birthdate *string `json:"birthdate,omitempty"`
|
||||
Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"`
|
||||
Country *string `json:"country,omitempty"`
|
||||
EyeColor *EyeColorEnum `json:"eye_color,omitempty"`
|
||||
HairColor *HairColorEnum `json:"hair_color,omitempty"`
|
||||
Height *int `json:"height,omitempty"`
|
||||
Measurements *MeasurementsInput `json:"measurements,omitempty"`
|
||||
CupSize *string `json:"cup_size,omitempty"`
|
||||
BandSize *int `json:"band_size,omitempty"`
|
||||
WaistSize *int `json:"waist_size,omitempty"`
|
||||
HipSize *int `json:"hip_size,omitempty"`
|
||||
BreastType *BreastTypeEnum `json:"breast_type,omitempty"`
|
||||
CareerStartYear *int `json:"career_start_year,omitempty"`
|
||||
CareerEndYear *int `json:"career_end_year,omitempty"`
|
||||
|
|
@ -390,6 +384,7 @@ type PerformerDestroyInput struct {
|
|||
}
|
||||
|
||||
type PerformerDraft struct {
|
||||
ID *string `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Aliases *string `json:"aliases,omitempty"`
|
||||
Gender *string `json:"gender,omitempty"`
|
||||
|
|
@ -412,6 +407,7 @@ type PerformerDraft struct {
|
|||
func (PerformerDraft) IsDraftData() {}
|
||||
|
||||
type PerformerDraftInput struct {
|
||||
ID *string `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Aliases *string `json:"aliases,omitempty"`
|
||||
Gender *string `json:"gender,omitempty"`
|
||||
|
|
@ -432,19 +428,18 @@ type PerformerDraftInput struct {
|
|||
}
|
||||
|
||||
type PerformerEdit struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Disambiguation *string `json:"disambiguation,omitempty"`
|
||||
AddedAliases []string `json:"added_aliases,omitempty"`
|
||||
RemovedAliases []string `json:"removed_aliases,omitempty"`
|
||||
Gender *GenderEnum `json:"gender,omitempty"`
|
||||
AddedUrls []*URL `json:"added_urls,omitempty"`
|
||||
RemovedUrls []*URL `json:"removed_urls,omitempty"`
|
||||
Birthdate *string `json:"birthdate,omitempty"`
|
||||
BirthdateAccuracy *string `json:"birthdate_accuracy,omitempty"`
|
||||
Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"`
|
||||
Country *string `json:"country,omitempty"`
|
||||
EyeColor *EyeColorEnum `json:"eye_color,omitempty"`
|
||||
HairColor *HairColorEnum `json:"hair_color,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Disambiguation *string `json:"disambiguation,omitempty"`
|
||||
AddedAliases []string `json:"added_aliases,omitempty"`
|
||||
RemovedAliases []string `json:"removed_aliases,omitempty"`
|
||||
Gender *GenderEnum `json:"gender,omitempty"`
|
||||
AddedUrls []*URL `json:"added_urls,omitempty"`
|
||||
RemovedUrls []*URL `json:"removed_urls,omitempty"`
|
||||
Birthdate *string `json:"birthdate,omitempty"`
|
||||
Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"`
|
||||
Country *string `json:"country,omitempty"`
|
||||
EyeColor *EyeColorEnum `json:"eye_color,omitempty"`
|
||||
HairColor *HairColorEnum `json:"hair_color,omitempty"`
|
||||
// Height in cm
|
||||
Height *int `json:"height,omitempty"`
|
||||
CupSize *string `json:"cup_size,omitempty"`
|
||||
|
|
@ -461,6 +456,11 @@ type PerformerEdit struct {
|
|||
AddedImages []*Image `json:"added_images,omitempty"`
|
||||
RemovedImages []*Image `json:"removed_images,omitempty"`
|
||||
DraftID *string `json:"draft_id,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
Urls []*URL `json:"urls,omitempty"`
|
||||
Images []*Image `json:"images,omitempty"`
|
||||
Tattoos []*BodyModification `json:"tattoos,omitempty"`
|
||||
Piercings []*BodyModification `json:"piercings,omitempty"`
|
||||
}
|
||||
|
||||
func (PerformerEdit) IsEditDetails() {}
|
||||
|
|
@ -471,13 +471,16 @@ type PerformerEditDetailsInput struct {
|
|||
Aliases []string `json:"aliases,omitempty"`
|
||||
Gender *GenderEnum `json:"gender,omitempty"`
|
||||
Urls []*URLInput `json:"urls,omitempty"`
|
||||
Birthdate *FuzzyDateInput `json:"birthdate,omitempty"`
|
||||
Birthdate *string `json:"birthdate,omitempty"`
|
||||
Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"`
|
||||
Country *string `json:"country,omitempty"`
|
||||
EyeColor *EyeColorEnum `json:"eye_color,omitempty"`
|
||||
HairColor *HairColorEnum `json:"hair_color,omitempty"`
|
||||
Height *int `json:"height,omitempty"`
|
||||
Measurements *MeasurementsInput `json:"measurements,omitempty"`
|
||||
CupSize *string `json:"cup_size,omitempty"`
|
||||
BandSize *int `json:"band_size,omitempty"`
|
||||
WaistSize *int `json:"waist_size,omitempty"`
|
||||
HipSize *int `json:"hip_size,omitempty"`
|
||||
BreastType *BreastTypeEnum `json:"breast_type,omitempty"`
|
||||
CareerStartYear *int `json:"career_start_year,omitempty"`
|
||||
CareerEndYear *int `json:"career_end_year,omitempty"`
|
||||
|
|
@ -510,7 +513,7 @@ type PerformerEditOptionsInput struct {
|
|||
}
|
||||
|
||||
type PerformerQueryInput struct {
|
||||
// Searches name and aliases - assumes like query unless quoted
|
||||
// Searches name and disambiguation - assumes like query unless quoted
|
||||
Names *string `json:"names,omitempty"`
|
||||
// Searches name only - assumes like query unless quoted
|
||||
Name *string `json:"name,omitempty"`
|
||||
|
|
@ -557,13 +560,16 @@ type PerformerUpdateInput struct {
|
|||
Aliases []string `json:"aliases,omitempty"`
|
||||
Gender *GenderEnum `json:"gender,omitempty"`
|
||||
Urls []*URLInput `json:"urls,omitempty"`
|
||||
Birthdate *FuzzyDateInput `json:"birthdate,omitempty"`
|
||||
Birthdate *string `json:"birthdate,omitempty"`
|
||||
Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"`
|
||||
Country *string `json:"country,omitempty"`
|
||||
EyeColor *EyeColorEnum `json:"eye_color,omitempty"`
|
||||
HairColor *HairColorEnum `json:"hair_color,omitempty"`
|
||||
Height *int `json:"height,omitempty"`
|
||||
Measurements *MeasurementsInput `json:"measurements,omitempty"`
|
||||
CupSize *string `json:"cup_size,omitempty"`
|
||||
BandSize *int `json:"band_size,omitempty"`
|
||||
WaistSize *int `json:"waist_size,omitempty"`
|
||||
HipSize *int `json:"hip_size,omitempty"`
|
||||
BreastType *BreastTypeEnum `json:"breast_type,omitempty"`
|
||||
CareerStartYear *int `json:"career_start_year,omitempty"`
|
||||
CareerEndYear *int `json:"career_end_year,omitempty"`
|
||||
|
|
@ -631,6 +637,7 @@ type Scene struct {
|
|||
Title *string `json:"title,omitempty"`
|
||||
Details *string `json:"details,omitempty"`
|
||||
Date *string `json:"date,omitempty"`
|
||||
ReleaseDate *string `json:"release_date,omitempty"`
|
||||
Urls []*URL `json:"urls,omitempty"`
|
||||
Studio *Studio `json:"studio,omitempty"`
|
||||
Tags []*Tag `json:"tags,omitempty"`
|
||||
|
|
@ -652,7 +659,7 @@ type SceneCreateInput struct {
|
|||
Title *string `json:"title,omitempty"`
|
||||
Details *string `json:"details,omitempty"`
|
||||
Urls []*URLInput `json:"urls,omitempty"`
|
||||
Date *string `json:"date,omitempty"`
|
||||
Date string `json:"date"`
|
||||
StudioID *string `json:"studio_id,omitempty"`
|
||||
Performers []*PerformerAppearanceInput `json:"performers,omitempty"`
|
||||
TagIds []string `json:"tag_ids,omitempty"`
|
||||
|
|
@ -668,6 +675,7 @@ type SceneDestroyInput struct {
|
|||
}
|
||||
|
||||
type SceneDraft struct {
|
||||
ID *string `json:"id,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Details *string `json:"details,omitempty"`
|
||||
URL *URL `json:"url,omitempty"`
|
||||
|
|
@ -701,6 +709,11 @@ type SceneEdit struct {
|
|||
Director *string `json:"director,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
DraftID *string `json:"draft_id,omitempty"`
|
||||
Urls []*URL `json:"urls,omitempty"`
|
||||
Performers []*PerformerAppearance `json:"performers,omitempty"`
|
||||
Tags []*Tag `json:"tags,omitempty"`
|
||||
Images []*Image `json:"images,omitempty"`
|
||||
Fingerprints []*Fingerprint `json:"fingerprints,omitempty"`
|
||||
}
|
||||
|
||||
func (SceneEdit) IsEditDetails() {}
|
||||
|
|
@ -833,8 +846,8 @@ type Studio struct {
|
|||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
func (Studio) IsSceneDraftStudio() {}
|
||||
func (Studio) IsEditTarget() {}
|
||||
func (Studio) IsSceneDraftStudio() {}
|
||||
|
||||
type StudioCreateInput struct {
|
||||
Name string `json:"name"`
|
||||
|
|
@ -855,6 +868,8 @@ type StudioEdit struct {
|
|||
Parent *Studio `json:"parent,omitempty"`
|
||||
AddedImages []*Image `json:"added_images,omitempty"`
|
||||
RemovedImages []*Image `json:"removed_images,omitempty"`
|
||||
Images []*Image `json:"images,omitempty"`
|
||||
Urls []*URL `json:"urls,omitempty"`
|
||||
}
|
||||
|
||||
func (StudioEdit) IsEditDetails() {}
|
||||
|
|
@ -909,8 +924,8 @@ type Tag struct {
|
|||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
func (Tag) IsSceneDraftTag() {}
|
||||
func (Tag) IsEditTarget() {}
|
||||
func (Tag) IsSceneDraftTag() {}
|
||||
|
||||
type TagCategory struct {
|
||||
ID string `json:"id"`
|
||||
|
|
@ -953,6 +968,7 @@ type TagEdit struct {
|
|||
AddedAliases []string `json:"added_aliases,omitempty"`
|
||||
RemovedAliases []string `json:"removed_aliases,omitempty"`
|
||||
Category *TagCategory `json:"category,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
}
|
||||
|
||||
func (TagEdit) IsEditDetails() {}
|
||||
|
|
@ -1256,16 +1272,18 @@ type EditSortEnum string
|
|||
const (
|
||||
EditSortEnumCreatedAt EditSortEnum = "CREATED_AT"
|
||||
EditSortEnumUpdatedAt EditSortEnum = "UPDATED_AT"
|
||||
EditSortEnumClosedAt EditSortEnum = "CLOSED_AT"
|
||||
)
|
||||
|
||||
var AllEditSortEnum = []EditSortEnum{
|
||||
EditSortEnumCreatedAt,
|
||||
EditSortEnumUpdatedAt,
|
||||
EditSortEnumClosedAt,
|
||||
}
|
||||
|
||||
func (e EditSortEnum) IsValid() bool {
|
||||
switch e {
|
||||
case EditSortEnumCreatedAt, EditSortEnumUpdatedAt:
|
||||
case EditSortEnumCreatedAt, EditSortEnumUpdatedAt, EditSortEnumClosedAt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import "github.com/99designs/gqlgen/graphql"
|
|||
// Override for generated struct due to mistaken omitempty
|
||||
// https://github.com/Yamashou/gqlgenc/issues/77
|
||||
type SceneDraftInput struct {
|
||||
ID *string `json:"id,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Details *string `json:"details,omitempty"`
|
||||
URL *string `json:"url,omitempty"`
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/Yamashou/gqlgenc/client"
|
||||
"github.com/corona10/goimagehash"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
|
|
@ -24,7 +23,6 @@ import (
|
|||
"github.com/stashapp/stash/pkg/match"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/scraper/stashbox/graphql"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/intslice"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
|
@ -78,127 +76,21 @@ func (c Client) QueryStashBoxScene(ctx context.Context, queryStr string) ([]*mod
|
|||
return ret, nil
|
||||
}
|
||||
|
||||
func phashMatches(hash, other int64) bool {
|
||||
// HACK - stash-box match distance is configurable. This needs to be fixed on
|
||||
// the stash-box end.
|
||||
const stashBoxDistance = 4
|
||||
|
||||
imageHash := goimagehash.NewImageHash(uint64(hash), goimagehash.PHash)
|
||||
otherHash := goimagehash.NewImageHash(uint64(other), goimagehash.PHash)
|
||||
|
||||
distance, _ := imageHash.Distance(otherHash)
|
||||
return distance <= stashBoxDistance
|
||||
// FindStashBoxScenesByFingerprints queries stash-box for a scene using the
|
||||
// scene's MD5/OSHASH checksum, or PHash.
|
||||
func (c Client) FindStashBoxSceneByFingerprints(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error) {
|
||||
res, err := c.FindStashBoxScenesByFingerprints(ctx, []int{sceneID})
|
||||
if len(res) > 0 {
|
||||
return res[0], err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// FindStashBoxScenesByFingerprints queries stash-box for scenes using every
|
||||
// scene's MD5/OSHASH checksum, or PHash, and returns results in the same order
|
||||
// as the input slice.
|
||||
func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, sceneIDs []string) ([][]*models.ScrapedScene, error) {
|
||||
ids, err := stringslice.StringSliceToIntSlice(sceneIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var fingerprints []*graphql.FingerprintQueryInput
|
||||
// map fingerprints to their scene index
|
||||
fpToScene := make(map[string][]int)
|
||||
phashToScene := make(map[int64][]int)
|
||||
|
||||
if err := c.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
|
||||
qb := r.Scene()
|
||||
|
||||
for index, sceneID := range ids {
|
||||
scene, err := qb.Find(sceneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if scene == nil {
|
||||
return fmt.Errorf("scene with id %d not found", sceneID)
|
||||
}
|
||||
|
||||
if scene.Checksum.Valid {
|
||||
fingerprints = append(fingerprints, &graphql.FingerprintQueryInput{
|
||||
Hash: scene.Checksum.String,
|
||||
Algorithm: graphql.FingerprintAlgorithmMd5,
|
||||
})
|
||||
fpToScene[scene.Checksum.String] = append(fpToScene[scene.Checksum.String], index)
|
||||
}
|
||||
|
||||
if scene.OSHash.Valid {
|
||||
fingerprints = append(fingerprints, &graphql.FingerprintQueryInput{
|
||||
Hash: scene.OSHash.String,
|
||||
Algorithm: graphql.FingerprintAlgorithmOshash,
|
||||
})
|
||||
fpToScene[scene.OSHash.String] = append(fpToScene[scene.OSHash.String], index)
|
||||
}
|
||||
|
||||
if scene.Phash.Valid {
|
||||
phashStr := utils.PhashToString(scene.Phash.Int64)
|
||||
fingerprints = append(fingerprints, &graphql.FingerprintQueryInput{
|
||||
Hash: phashStr,
|
||||
Algorithm: graphql.FingerprintAlgorithmPhash,
|
||||
})
|
||||
fpToScene[phashStr] = append(fpToScene[phashStr], index)
|
||||
phashToScene[scene.Phash.Int64] = append(phashToScene[scene.Phash.Int64], index)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allScenes, err := c.findStashBoxScenesByFingerprints(ctx, fingerprints)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// set the matched scenes back in their original order
|
||||
ret := make([][]*models.ScrapedScene, len(sceneIDs))
|
||||
for _, s := range allScenes {
|
||||
var addedTo []int
|
||||
|
||||
addScene := func(sceneIndexes []int) {
|
||||
for _, index := range sceneIndexes {
|
||||
if !intslice.IntInclude(addedTo, index) {
|
||||
addedTo = append(addedTo, index)
|
||||
ret[index] = append(ret[index], s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, fp := range s.Fingerprints {
|
||||
addScene(fpToScene[fp.Hash])
|
||||
|
||||
// HACK - we really need stash-box to return specific hash-to-result sets
|
||||
if fp.Algorithm == graphql.FingerprintAlgorithmPhash.String() {
|
||||
hash, err := utils.StringToPhash(fp.Hash)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for phash, sceneIndexes := range phashToScene {
|
||||
if phashMatches(hash, phash) {
|
||||
addScene(sceneIndexes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// FindStashBoxScenesByFingerprintsFlat queries stash-box for scenes using every
|
||||
// scene's MD5/OSHASH checksum, or PHash, and returns results a flat slice.
|
||||
func (c Client) FindStashBoxScenesByFingerprintsFlat(ctx context.Context, sceneIDs []string) ([]*models.ScrapedScene, error) {
|
||||
ids, err := stringslice.StringSliceToIntSlice(sceneIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var fingerprints []*graphql.FingerprintQueryInput
|
||||
func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, ids []int) ([][]*models.ScrapedScene, error) {
|
||||
var fingerprints [][]*graphql.FingerprintQueryInput
|
||||
|
||||
if err := c.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
|
||||
qb := r.Scene()
|
||||
|
|
@ -213,26 +105,31 @@ func (c Client) FindStashBoxScenesByFingerprintsFlat(ctx context.Context, sceneI
|
|||
return fmt.Errorf("scene with id %d not found", sceneID)
|
||||
}
|
||||
|
||||
var sceneFPs []*graphql.FingerprintQueryInput
|
||||
|
||||
if scene.Checksum.Valid {
|
||||
fingerprints = append(fingerprints, &graphql.FingerprintQueryInput{
|
||||
sceneFPs = append(sceneFPs, &graphql.FingerprintQueryInput{
|
||||
Hash: scene.Checksum.String,
|
||||
Algorithm: graphql.FingerprintAlgorithmMd5,
|
||||
})
|
||||
}
|
||||
|
||||
if scene.OSHash.Valid {
|
||||
fingerprints = append(fingerprints, &graphql.FingerprintQueryInput{
|
||||
sceneFPs = append(sceneFPs, &graphql.FingerprintQueryInput{
|
||||
Hash: scene.OSHash.String,
|
||||
Algorithm: graphql.FingerprintAlgorithmOshash,
|
||||
})
|
||||
}
|
||||
|
||||
if scene.Phash.Valid {
|
||||
fingerprints = append(fingerprints, &graphql.FingerprintQueryInput{
|
||||
Hash: utils.PhashToString(scene.Phash.Int64),
|
||||
phashStr := utils.PhashToString(scene.Phash.Int64)
|
||||
sceneFPs = append(sceneFPs, &graphql.FingerprintQueryInput{
|
||||
Hash: phashStr,
|
||||
Algorithm: graphql.FingerprintAlgorithmPhash,
|
||||
})
|
||||
}
|
||||
|
||||
fingerprints = append(fingerprints, sceneFPs)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -243,27 +140,29 @@ func (c Client) FindStashBoxScenesByFingerprintsFlat(ctx context.Context, sceneI
|
|||
return c.findStashBoxScenesByFingerprints(ctx, fingerprints)
|
||||
}
|
||||
|
||||
func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, fingerprints []*graphql.FingerprintQueryInput) ([]*models.ScrapedScene, error) {
|
||||
var ret []*models.ScrapedScene
|
||||
for i := 0; i < len(fingerprints); i += 100 {
|
||||
end := i + 100
|
||||
if end > len(fingerprints) {
|
||||
end = len(fingerprints)
|
||||
func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, scenes [][]*graphql.FingerprintQueryInput) ([][]*models.ScrapedScene, error) {
|
||||
var ret [][]*models.ScrapedScene
|
||||
for i := 0; i < len(scenes); i += 40 {
|
||||
end := i + 40
|
||||
if end > len(scenes) {
|
||||
end = len(scenes)
|
||||
}
|
||||
scenes, err := c.client.FindScenesByFullFingerprints(ctx, fingerprints[i:end])
|
||||
scenes, err := c.client.FindScenesBySceneFingerprints(ctx, scenes[i:end])
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sceneFragments := scenes.FindScenesByFullFingerprints
|
||||
|
||||
for _, s := range sceneFragments {
|
||||
ss, err := c.sceneFragmentToScrapedScene(ctx, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
for _, sceneFragments := range scenes.FindScenesBySceneFingerprints {
|
||||
var sceneResults []*models.ScrapedScene
|
||||
for _, scene := range sceneFragments {
|
||||
ss, err := c.sceneFragmentToScrapedScene(ctx, scene)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sceneResults = append(sceneResults, ss)
|
||||
}
|
||||
ret = append(ret, ss)
|
||||
ret = append(ret, sceneResults)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -926,6 +825,19 @@ func (c Client) SubmitSceneDraft(ctx context.Context, sceneID int, endpoint stri
|
|||
}
|
||||
}
|
||||
|
||||
stashIDs, err := qb.GetStashIDs(sceneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var stashID *string
|
||||
for _, v := range stashIDs {
|
||||
if v.Endpoint == endpoint {
|
||||
stashID = &v.StashID
|
||||
break
|
||||
}
|
||||
}
|
||||
draft.ID = stashID
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -1011,6 +923,19 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf
|
|||
draft.Urls = urls
|
||||
}
|
||||
|
||||
stashIDs, err := pqb.GetStashIDs(performer.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var stashID *string
|
||||
for _, v := range stashIDs {
|
||||
if v.Endpoint == endpoint {
|
||||
stashID = &v.StashID
|
||||
break
|
||||
}
|
||||
}
|
||||
draft.ID = stashID
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -81,6 +81,24 @@ func (qb *savedFilterQueryBuilder) Find(id int) (*models.SavedFilter, error) {
|
|||
return &ret, nil
|
||||
}
|
||||
|
||||
func (qb *savedFilterQueryBuilder) FindMany(ids []int, ignoreNotFound bool) ([]*models.SavedFilter, error) {
|
||||
var filters []*models.SavedFilter
|
||||
for _, id := range ids {
|
||||
filter, err := qb.Find(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if filter == nil && !ignoreNotFound {
|
||||
return nil, fmt.Errorf("filter with id %d not found", id)
|
||||
}
|
||||
|
||||
filters = append(filters, filter)
|
||||
}
|
||||
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func (qb *savedFilterQueryBuilder) FindByMode(mode models.FilterMode) ([]*models.SavedFilter, error) {
|
||||
// exclude empty-named filters - these are the internal default filters
|
||||
|
||||
|
|
@ -108,3 +126,12 @@ func (qb *savedFilterQueryBuilder) FindDefault(mode models.FilterMode) (*models.
|
|||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (qb *savedFilterQueryBuilder) All() ([]*models.SavedFilter, error) {
|
||||
var ret models.SavedFilters
|
||||
if err := qb.query(selectAll(savedFilterTable), nil, &ret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*models.SavedFilter(ret), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<link rel="manifest" crossorigin="use-credentials" href="manifest.json" />
|
||||
<title>Stash</title>
|
||||
<script>window.STASH_BASE_URL = "/%BASE_URL%/"</script>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
"i18n-iso-countries": "^6.4.0",
|
||||
"intersection-observer": "^0.12.0",
|
||||
"localforage": "1.9.0",
|
||||
"lodash": "^4.17.20",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mousetrap-pause": "^1.0.0",
|
||||
"normalize-url": "^4.5.1",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"@types/apollo-upload-client": "^14.1.0",
|
||||
"@types/classnames": "^2.2.11",
|
||||
"@types/fslightbox-react": "^1.4.0",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/mousetrap": "^1.6.5",
|
||||
"@types/node": "14.14.22",
|
||||
"@types/react": "17.0.31",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import React, { useEffect } from "react";
|
||||
import React, { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { Route, Switch, useRouteMatch } from "react-router-dom";
|
||||
import { IntlProvider, CustomFormats } from "react-intl";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { mergeWith } from "lodash";
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import mergeWith from "lodash-es/mergeWith";
|
||||
import { ToastProvider } from "src/hooks/Toast";
|
||||
import LightboxProvider from "src/hooks/Lightbox/context";
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { fas } from "@fortawesome/free-solid-svg-icons";
|
||||
import { initPolyfills } from "src/polyfills";
|
||||
|
||||
import locales from "src/locales";
|
||||
|
|
@ -15,41 +14,48 @@ import { flattenMessages } from "src/utils";
|
|||
import Mousetrap from "mousetrap";
|
||||
import MousetrapPause from "mousetrap-pause";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import Galleries from "./components/Galleries/Galleries";
|
||||
import { MainNavbar } from "./components/MainNavbar";
|
||||
import { PageNotFound } from "./components/PageNotFound";
|
||||
import Performers from "./components/Performers/Performers";
|
||||
import Recommendations from "./components/Recommendations/Recommendations";
|
||||
import Scenes from "./components/Scenes/Scenes";
|
||||
import { Settings } from "./components/Settings/Settings";
|
||||
import { Stats } from "./components/Stats";
|
||||
import Studios from "./components/Studios/Studios";
|
||||
import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser";
|
||||
import { SceneDuplicateChecker } from "./components/SceneDuplicateChecker/SceneDuplicateChecker";
|
||||
import Movies from "./components/Movies/Movies";
|
||||
import Tags from "./components/Tags/Tags";
|
||||
import Images from "./components/Images/Images";
|
||||
import { Setup } from "./components/Setup/Setup";
|
||||
import { Migrate } from "./components/Setup/Migrate";
|
||||
import * as GQL from "./core/generated-graphql";
|
||||
import { LoadingIndicator, TITLE_SUFFIX } from "./components/Shared";
|
||||
|
||||
import { ConfigurationProvider } from "./hooks/Config";
|
||||
import { ManualProvider } from "./components/Help/Manual";
|
||||
import { ManualProvider } from "./components/Help/context";
|
||||
import { InteractiveProvider } from "./hooks/Interactive/context";
|
||||
|
||||
const Performers = lazy(() => import("./components/Performers/Performers"));
|
||||
const FrontPage = lazy(() => import("./components/FrontPage/FrontPage"));
|
||||
const Scenes = lazy(() => import("./components/Scenes/Scenes"));
|
||||
const Settings = lazy(() => import("./components/Settings/Settings"));
|
||||
const Stats = lazy(() => import("./components/Stats"));
|
||||
const Studios = lazy(() => import("./components/Studios/Studios"));
|
||||
const Galleries = lazy(() => import("./components/Galleries/Galleries"));
|
||||
|
||||
const Movies = lazy(() => import("./components/Movies/Movies"));
|
||||
const Tags = lazy(() => import("./components/Tags/Tags"));
|
||||
const Images = lazy(() => import("./components/Images/Images"));
|
||||
const Setup = lazy(() => import("./components/Setup/Setup"));
|
||||
const Migrate = lazy(() => import("./components/Setup/Migrate"));
|
||||
|
||||
const SceneFilenameParser = lazy(
|
||||
() => import("./components/SceneFilenameParser/SceneFilenameParser")
|
||||
);
|
||||
const SceneDuplicateChecker = lazy(
|
||||
() => import("./components/SceneDuplicateChecker/SceneDuplicateChecker")
|
||||
);
|
||||
|
||||
initPolyfills();
|
||||
|
||||
MousetrapPause(Mousetrap);
|
||||
|
||||
// Set fontawesome/free-solid-svg as default fontawesome icons
|
||||
library.add(fas);
|
||||
|
||||
const intlFormats: CustomFormats = {
|
||||
date: {
|
||||
long: { year: "numeric", month: "long", day: "numeric" },
|
||||
},
|
||||
};
|
||||
|
||||
const defaultLocale = "en-GB";
|
||||
|
||||
function languageMessageString(language: string) {
|
||||
return language.replace(/-/, "");
|
||||
}
|
||||
|
|
@ -57,25 +63,32 @@ function languageMessageString(language: string) {
|
|||
export const App: React.FC = () => {
|
||||
const config = useConfiguration();
|
||||
const { data: systemStatusData } = useSystemStatus();
|
||||
const defaultLocale = "en-GB";
|
||||
|
||||
const language =
|
||||
config.data?.configuration?.interface?.language ?? defaultLocale;
|
||||
const defaultMessageLanguage = languageMessageString(defaultLocale);
|
||||
const messageLanguage = languageMessageString(language);
|
||||
|
||||
// use en-GB as default messages if any messages aren't found in the chosen language
|
||||
const mergedMessages = mergeWith(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(locales as any)[defaultMessageLanguage],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(locales as any)[messageLanguage],
|
||||
(objVal, srcVal) => {
|
||||
if (srcVal === "") {
|
||||
return objVal;
|
||||
}
|
||||
}
|
||||
);
|
||||
const messages = flattenMessages(mergedMessages);
|
||||
const [messages, setMessages] = useState<{}>();
|
||||
|
||||
useEffect(() => {
|
||||
const setLocale = async () => {
|
||||
const defaultMessageLanguage = languageMessageString(defaultLocale);
|
||||
const messageLanguage = languageMessageString(language);
|
||||
|
||||
const defaultMessages = await locales[defaultMessageLanguage]();
|
||||
const mergedMessages = cloneDeep(Object.assign({}, defaultMessages));
|
||||
const chosenMessages = await locales[messageLanguage]();
|
||||
mergeWith(mergedMessages, chosenMessages, (objVal, srcVal) => {
|
||||
if (srcVal === "") {
|
||||
return objVal;
|
||||
}
|
||||
});
|
||||
|
||||
setMessages(flattenMessages(mergedMessages));
|
||||
};
|
||||
|
||||
setLocale();
|
||||
}, [language]);
|
||||
|
||||
const setupMatch = useRouteMatch(["/setup", "/migrate"]);
|
||||
|
||||
|
|
@ -118,52 +131,64 @@ export const App: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/" component={Recommendations} />
|
||||
<Route path="/scenes" component={Scenes} />
|
||||
<Route path="/images" component={Images} />
|
||||
<Route path="/galleries" component={Galleries} />
|
||||
<Route path="/performers" component={Performers} />
|
||||
<Route path="/tags" component={Tags} />
|
||||
<Route path="/studios" component={Studios} />
|
||||
<Route path="/movies" component={Movies} />
|
||||
<Route path="/stats" component={Stats} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/sceneFilenameParser" component={SceneFilenameParser} />
|
||||
<Route
|
||||
path="/sceneDuplicateChecker"
|
||||
component={SceneDuplicateChecker}
|
||||
/>
|
||||
<Route path="/setup" component={Setup} />
|
||||
<Route path="/migrate" component={Migrate} />
|
||||
<Route component={PageNotFound} />
|
||||
</Switch>
|
||||
<Suspense fallback={<LoadingIndicator />}>
|
||||
<Switch>
|
||||
<Route exact path="/" component={FrontPage} />
|
||||
<Route path="/scenes" component={Scenes} />
|
||||
<Route path="/images" component={Images} />
|
||||
<Route path="/galleries" component={Galleries} />
|
||||
<Route path="/performers" component={Performers} />
|
||||
<Route path="/tags" component={Tags} />
|
||||
<Route path="/studios" component={Studios} />
|
||||
<Route path="/movies" component={Movies} />
|
||||
<Route path="/stats" component={Stats} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/sceneFilenameParser" component={SceneFilenameParser} />
|
||||
<Route
|
||||
path="/sceneDuplicateChecker"
|
||||
component={SceneDuplicateChecker}
|
||||
/>
|
||||
<Route path="/setup" component={Setup} />
|
||||
<Route path="/migrate" component={Migrate} />
|
||||
<Route component={PageNotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<IntlProvider locale={language} messages={messages} formats={intlFormats}>
|
||||
<ConfigurationProvider
|
||||
configuration={config.data?.configuration}
|
||||
loading={config.loading}
|
||||
{messages ? (
|
||||
<IntlProvider
|
||||
locale={language}
|
||||
messages={messages}
|
||||
formats={intlFormats}
|
||||
>
|
||||
<ToastProvider>
|
||||
<LightboxProvider>
|
||||
<ManualProvider>
|
||||
<InteractiveProvider>
|
||||
<Helmet
|
||||
titleTemplate={`%s ${TITLE_SUFFIX}`}
|
||||
defaultTitle="Stash"
|
||||
/>
|
||||
{maybeRenderNavbar()}
|
||||
<div className="main container-fluid">{renderContent()}</div>
|
||||
</InteractiveProvider>
|
||||
</ManualProvider>
|
||||
</LightboxProvider>
|
||||
</ToastProvider>
|
||||
</ConfigurationProvider>
|
||||
</IntlProvider>
|
||||
<ConfigurationProvider
|
||||
configuration={config.data?.configuration}
|
||||
loading={config.loading}
|
||||
>
|
||||
<ToastProvider>
|
||||
<Suspense fallback={<LoadingIndicator />}>
|
||||
<LightboxProvider>
|
||||
<ManualProvider>
|
||||
<InteractiveProvider>
|
||||
<Helmet
|
||||
titleTemplate={`%s ${TITLE_SUFFIX}`}
|
||||
defaultTitle="Stash"
|
||||
/>
|
||||
{maybeRenderNavbar()}
|
||||
<div className="main container-fluid">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</InteractiveProvider>
|
||||
</ManualProvider>
|
||||
</LightboxProvider>
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
</ConfigurationProvider>
|
||||
</IntlProvider>
|
||||
) : null}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import V0130 from "./versions/v0130.md";
|
|||
import V0131 from "./versions/v0131.md";
|
||||
import V0140 from "./versions/v0140.md";
|
||||
import V0150 from "./versions/v0150.md";
|
||||
import V0160 from "./versions/v0160.md";
|
||||
import { MarkdownPage } from "../Shared/MarkdownPage";
|
||||
|
||||
// to avoid use of explicit any
|
||||
|
|
@ -57,9 +58,9 @@ const Changelog: React.FC = () => {
|
|||
// after new release:
|
||||
// add entry to releases, using the current* fields
|
||||
// then update the current fields.
|
||||
const currentVersion = stashVersion || "v0.15.0";
|
||||
const currentVersion = stashVersion || "v0.16.0";
|
||||
const currentDate = buildDate;
|
||||
const currentPage = V0150;
|
||||
const currentPage = V0160;
|
||||
|
||||
const releases: IStashRelease[] = [
|
||||
{
|
||||
|
|
@ -68,6 +69,11 @@ const Changelog: React.FC = () => {
|
|||
page: currentPage,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
version: "v0.15.0",
|
||||
date: "2022-05-18",
|
||||
page: V0150,
|
||||
},
|
||||
{
|
||||
version: "v0.14.0",
|
||||
date: "2022-04-11",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { faAngleDown, faAngleUp } from "@fortawesome/free-solid-svg-icons";
|
||||
import React, { useState } from "react";
|
||||
import { Button, Card, Collapse } from "react-bootstrap";
|
||||
import { FormattedDate, FormattedMessage } from "react-intl";
|
||||
|
|
@ -33,7 +34,7 @@ const Version: React.FC<IVersionProps> = ({
|
|||
<Card.Header>
|
||||
<h4 className="changelog-version-header d-flex align-items-center">
|
||||
<Button onClick={updateState} variant="link">
|
||||
<Icon icon={open ? "angle-up" : "angle-down"} className="mr-3" />
|
||||
<Icon icon={open ? faAngleUp : faAngleDown} className="mr-3" />
|
||||
{version} (
|
||||
{date ? (
|
||||
<FormattedDate value={date} timeZone="utc" />
|
||||
|
|
|
|||
19
ui/v2.5/src/components/Changelog/versions/v0160.md
Normal file
19
ui/v2.5/src/components/Changelog/versions/v0160.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
### ✨ New Features
|
||||
* Added hotkeys to scrub scene by 10% duration. ([#2678](https://github.com/stashapp/stash/pull/2678))
|
||||
* Added support for customizing recommendations on home page. ([#2592](https://github.com/stashapp/stash/pull/2592))
|
||||
* Support submitting stash-box scene updates for scenes with stash ids. ([#2577](https://github.com/stashapp/stash/pull/2577))
|
||||
|
||||
### 🎨 Improvements
|
||||
* Moved Filter and Saved Filters buttons out of the query input field. ([#2668](https://github.com/stashapp/stash/pull/2668))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
* Fix fields disappearing after creating missing objects in the scrape dialog. ([#2702](https://github.com/stashapp/stash/pull/2702))
|
||||
* Fix saved filters with uppercase characters not appearing in filtered results. ([#2698](https://github.com/stashapp/stash/pull/2698))
|
||||
* Fix query results not updating when clearing search query field. ([#2686](https://github.com/stashapp/stash/pull/2686))
|
||||
* Fix incorrect field name in movie export json. ([#2664](https://github.com/stashapp/stash/pull/2664))
|
||||
* Fix ffprobe showing window on some systems. ([#2685](https://github.com/stashapp/stash/pull/2685))
|
||||
* Fix portrait videos orienting incorrectly in full-screen mode. ([#2665](https://github.com/stashapp/stash/pull/2665))
|
||||
* Fix scene scrubber stopping scrolling after editing scene or marker. ([#2600](https://github.com/stashapp/stash/pull/2600))
|
||||
* Fix folder-based galleries not auto-tagging correctly if folder name contains `.` characters. ([#2658](https://github.com/stashapp/stash/pull/2658))
|
||||
* Fix scene cover in scene edit panel not being updated when changing scenes. ([#2657](https://github.com/stashapp/stash/pull/2657))
|
||||
* Fix moved gallery zip files not being rescanned. ([#2611](https://github.com/stashapp/stash/pull/2611))
|
||||
|
|
@ -10,6 +10,7 @@ import { Manual } from "../Help/Manual";
|
|||
import { withoutTypename } from "src/utils";
|
||||
import { GenerateOptions } from "../Settings/Tasks/GenerateOptions";
|
||||
import { SettingSection } from "../Settings/SettingSection";
|
||||
import { faCogs, faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface ISceneGenerateDialog {
|
||||
selectedIds?: string[];
|
||||
|
|
@ -171,7 +172,7 @@ export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
|
|||
<Modal
|
||||
show
|
||||
modalProps={{ animation, size: "lg" }}
|
||||
icon="cogs"
|
||||
icon={faCogs}
|
||||
header={intl.formatMessage({ id: "actions.generate" })}
|
||||
accept={{
|
||||
onClick: onGenerate,
|
||||
|
|
@ -188,7 +189,7 @@ export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
|
|||
className="minimal help-button"
|
||||
onClick={() => onShowManual()}
|
||||
>
|
||||
<Icon icon="question-circle" />
|
||||
<Icon icon={faQuestionCircle} />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
|
|
@ -205,3 +206,5 @@ export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
|
|||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerateDialog;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import * as GQL from "src/core/generated-graphql";
|
|||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { multiValueSceneFields, SceneField, sceneFields } from "./constants";
|
||||
import { ThreeStateBoolean } from "./ThreeStateBoolean";
|
||||
import {
|
||||
faCheck,
|
||||
faPencilAlt,
|
||||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IFieldOptionsEditor {
|
||||
options: GQL.IdentifyFieldOptions | undefined;
|
||||
|
|
@ -148,10 +153,10 @@ const FieldOptionsEditor: React.FC<IFieldOptionsEditor> = ({
|
|||
return intl.formatMessage({ id: "actions.use_default" });
|
||||
}
|
||||
if (value) {
|
||||
return <Icon icon="check" className="text-success" />;
|
||||
return <Icon icon={faCheck} className="text-success" />;
|
||||
}
|
||||
|
||||
return <Icon icon="times" className="text-danger" />;
|
||||
return <Icon icon={faTimes} className="text-danger" />;
|
||||
}
|
||||
|
||||
const defaultVal = defaultOptions?.fieldOptions?.find(
|
||||
|
|
@ -212,7 +217,7 @@ const FieldOptionsEditor: React.FC<IFieldOptionsEditor> = ({
|
|||
className="minimal text-success"
|
||||
onClick={() => onEditOptions()}
|
||||
>
|
||||
<Icon icon="check" />
|
||||
<Icon icon={faCheck} />
|
||||
</Button>
|
||||
<Button
|
||||
className="minimal text-danger"
|
||||
|
|
@ -221,13 +226,13 @@ const FieldOptionsEditor: React.FC<IFieldOptionsEditor> = ({
|
|||
resetOptions();
|
||||
}}
|
||||
>
|
||||
<Icon icon="times" />
|
||||
<Icon icon={faTimes} />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button className="minimal" onClick={() => editField()}>
|
||||
<Icon icon="pencil-alt" />
|
||||
<Icon icon={faPencilAlt} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ import { Manual } from "src/components/Help/Manual";
|
|||
import { IScraperSource } from "./constants";
|
||||
import { OptionsEditor } from "./Options";
|
||||
import { SourcesEditor, SourcesList } from "./Sources";
|
||||
import {
|
||||
faCogs,
|
||||
faFolderOpen,
|
||||
faQuestionCircle,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const autoTagScraperID = "builtin_autotag";
|
||||
|
||||
|
|
@ -167,7 +172,7 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
|
|||
title={intl.formatMessage({ id: "actions.select_folders" })}
|
||||
onClick={() => onClick()}
|
||||
>
|
||||
<Icon icon="folder-open" />
|
||||
<Icon icon={faFolderOpen} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -403,7 +408,7 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
|
|||
<Modal
|
||||
modalProps={{ animation, size: "lg" }}
|
||||
show
|
||||
icon="cogs"
|
||||
icon={faCogs}
|
||||
header={intl.formatMessage({ id: "actions.identify" })}
|
||||
accept={{
|
||||
onClick: onIdentify,
|
||||
|
|
@ -430,7 +435,7 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
|
|||
className="minimal help-button"
|
||||
onClick={() => onShowManual()}
|
||||
>
|
||||
<Icon icon="question-circle" />
|
||||
<Icon icon={faQuestionCircle} />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ import { FormattedMessage, useIntl } from "react-intl";
|
|||
import * as GQL from "src/core/generated-graphql";
|
||||
import { IScraperSource } from "./constants";
|
||||
import { OptionsEditor } from "./Options";
|
||||
import {
|
||||
faCog,
|
||||
faGripVertical,
|
||||
faMinus,
|
||||
faPencilAlt,
|
||||
faPlus,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface ISourceEditor {
|
||||
isNew: boolean;
|
||||
|
|
@ -50,7 +57,7 @@ export const SourcesEditor: React.FC<ISourceEditor> = ({
|
|||
dialogClassName="identify-source-editor"
|
||||
modalProps={{ animation: false, size: "lg" }}
|
||||
show
|
||||
icon={isNew ? "plus" : "pencil-alt"}
|
||||
icon={isNew ? faPlus : faPencilAlt}
|
||||
header={intl.formatMessage(
|
||||
{ id: headerMsgId },
|
||||
{
|
||||
|
|
@ -184,19 +191,19 @@ export const SourcesList: React.FC<ISourcesList> = ({
|
|||
onMouseEnter={() => setMouseOverIndex(index)}
|
||||
onMouseLeave={() => setMouseOverIndex(undefined)}
|
||||
>
|
||||
<Icon icon="grip-vertical" />
|
||||
<Icon icon={faGripVertical} />
|
||||
</div>
|
||||
{s.displayName}
|
||||
</div>
|
||||
<div>
|
||||
<Button className="minimal" onClick={() => editSource(s)}>
|
||||
<Icon icon="cog" />
|
||||
<Icon icon={faCog} />
|
||||
</Button>
|
||||
<Button
|
||||
className="minimal text-danger"
|
||||
onClick={() => removeSource(index)}
|
||||
>
|
||||
<Icon icon="minus" />
|
||||
<Icon icon={faMinus} />
|
||||
</Button>
|
||||
</div>
|
||||
</ListGroup.Item>
|
||||
|
|
@ -208,7 +215,7 @@ export const SourcesList: React.FC<ISourcesList> = ({
|
|||
className="minimal add-scraper-source-button"
|
||||
onClick={() => editSource()}
|
||||
>
|
||||
<Icon icon="plus" />
|
||||
<Icon icon={faPlus} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,16 @@ import * as GQL from "src/core/generated-graphql";
|
|||
import { Modal } from "src/components/Shared";
|
||||
import { getStashboxBase } from "src/utils";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { faPaperPlane } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IProps {
|
||||
show: boolean;
|
||||
entity: { name?: string | null; id: string; title?: string | null };
|
||||
entity: {
|
||||
name?: string | null;
|
||||
id: string;
|
||||
title?: string | null;
|
||||
stash_ids: { stash_id: string; endpoint: string }[];
|
||||
};
|
||||
boxes: Pick<GQL.StashBox, "name" | "endpoint">[];
|
||||
query: DocumentNode;
|
||||
onHide: () => void;
|
||||
|
|
@ -59,9 +65,15 @@ export const SubmitStashBoxDraft: React.FC<IProps> = ({
|
|||
const handleSelectBox = (e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setSelectedBox(Number.parseInt(e.currentTarget.value) ?? 0);
|
||||
|
||||
// If the scene has an attached stash_id from that endpoint, the operation will be an update
|
||||
const isUpdate =
|
||||
entity.stash_ids.find(
|
||||
(id) => id.endpoint === boxes[selectedBox].endpoint
|
||||
) !== undefined;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
icon="paper-plane"
|
||||
icon={faPaperPlane}
|
||||
header={intl.formatMessage({ id: "actions.submit_stash_box" })}
|
||||
isRunning={loading}
|
||||
show={show}
|
||||
|
|
@ -87,10 +99,24 @@ export const SubmitStashBoxDraft: React.FC<IProps> = ({
|
|||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
<Button onClick={handleSubmit}>
|
||||
<FormattedMessage id="actions.submit" />{" "}
|
||||
{`"${entity.name ?? entity.title}"`}
|
||||
</Button>
|
||||
<div className="text-right">
|
||||
{isUpdate && (
|
||||
<span className="mr-2">
|
||||
<FormattedMessage
|
||||
id="stashbox.submit_update"
|
||||
values={{ endpoint_name: boxes[selectedBox].name }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant={isUpdate ? "primary" : "success"}
|
||||
>
|
||||
<FormattedMessage
|
||||
id={`actions.${isUpdate ? "submit_update" : "submit"}`}
|
||||
/>{" "}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -124,3 +150,5 @@ export const SubmitStashBoxDraft: React.FC<IProps> = ({
|
|||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubmitStashBoxDraft;
|
||||
|
|
|
|||
177
ui/v2.5/src/components/FrontPage/Control.tsx
Normal file
177
ui/v2.5/src/components/FrontPage/Control.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import React, { useMemo } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import {
|
||||
FrontPageContent,
|
||||
ICustomFilter,
|
||||
ISavedFilterRow,
|
||||
} from "src/core/config";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useFindSavedFilter } from "src/core/StashService";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { GalleryRecommendationRow } from "../Galleries/GalleryRecommendationRow";
|
||||
import { ImageRecommendationRow } from "../Images/ImageRecommendationRow";
|
||||
import { MovieRecommendationRow } from "../Movies/MovieRecommendationRow";
|
||||
import { PerformerRecommendationRow } from "../Performers/PerformerRecommendationRow";
|
||||
import { SceneRecommendationRow } from "../Scenes/SceneRecommendationRow";
|
||||
import { StudioRecommendationRow } from "../Studios/StudioRecommendationRow";
|
||||
import { TagRecommendationRow } from "../Tags/TagRecommendationRow";
|
||||
|
||||
interface IFilter {
|
||||
mode: GQL.FilterMode;
|
||||
filter: ListFilterModel;
|
||||
header: string;
|
||||
}
|
||||
|
||||
const RecommendationRow: React.FC<IFilter> = ({ mode, filter, header }) => {
|
||||
function isTouchEnabled() {
|
||||
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
const isTouch = isTouchEnabled();
|
||||
|
||||
switch (mode) {
|
||||
case GQL.FilterMode.Scenes:
|
||||
return (
|
||||
<SceneRecommendationRow
|
||||
isTouch={isTouch}
|
||||
filter={filter}
|
||||
header={header}
|
||||
/>
|
||||
);
|
||||
case GQL.FilterMode.Studios:
|
||||
return (
|
||||
<StudioRecommendationRow
|
||||
isTouch={isTouch}
|
||||
filter={filter}
|
||||
header={header}
|
||||
/>
|
||||
);
|
||||
case GQL.FilterMode.Movies:
|
||||
return (
|
||||
<MovieRecommendationRow
|
||||
isTouch={isTouch}
|
||||
filter={filter}
|
||||
header={header}
|
||||
/>
|
||||
);
|
||||
case GQL.FilterMode.Performers:
|
||||
return (
|
||||
<PerformerRecommendationRow
|
||||
isTouch={isTouch}
|
||||
filter={filter}
|
||||
header={header}
|
||||
/>
|
||||
);
|
||||
case GQL.FilterMode.Galleries:
|
||||
return (
|
||||
<GalleryRecommendationRow
|
||||
isTouch={isTouch}
|
||||
filter={filter}
|
||||
header={header}
|
||||
/>
|
||||
);
|
||||
case GQL.FilterMode.Images:
|
||||
return (
|
||||
<ImageRecommendationRow
|
||||
isTouch={isTouch}
|
||||
filter={filter}
|
||||
header={header}
|
||||
/>
|
||||
);
|
||||
case GQL.FilterMode.Tags:
|
||||
return (
|
||||
<TagRecommendationRow
|
||||
isTouch={isTouch}
|
||||
filter={filter}
|
||||
header={header}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
interface ISavedFilterResults {
|
||||
savedFilterID: string;
|
||||
}
|
||||
|
||||
const SavedFilterResults: React.FC<ISavedFilterResults> = ({
|
||||
savedFilterID,
|
||||
}) => {
|
||||
const { loading, data } = useFindSavedFilter(savedFilterID.toString());
|
||||
|
||||
const filter = useMemo(() => {
|
||||
if (!data?.findSavedFilter) return;
|
||||
|
||||
const { mode, filter: filterJSON } = data.findSavedFilter;
|
||||
|
||||
const ret = new ListFilterModel(mode);
|
||||
ret.currentPage = 1;
|
||||
ret.configureFromQueryParameters(JSON.parse(filterJSON));
|
||||
ret.randomSeed = -1;
|
||||
return ret;
|
||||
}, [data?.findSavedFilter]);
|
||||
|
||||
if (loading || !data?.findSavedFilter || !filter) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const { name, mode } = data.findSavedFilter;
|
||||
|
||||
return <RecommendationRow mode={mode} filter={filter} header={name} />;
|
||||
};
|
||||
|
||||
interface ICustomFilterProps {
|
||||
customFilter: ICustomFilter;
|
||||
}
|
||||
|
||||
const CustomFilterResults: React.FC<ICustomFilterProps> = ({
|
||||
customFilter,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const filter = useMemo(() => {
|
||||
const itemsPerPage = 25;
|
||||
const ret = new ListFilterModel(customFilter.mode);
|
||||
ret.sortBy = customFilter.sortBy;
|
||||
ret.sortDirection = customFilter.direction;
|
||||
ret.itemsPerPage = itemsPerPage;
|
||||
ret.currentPage = 1;
|
||||
ret.randomSeed = -1;
|
||||
return ret;
|
||||
}, [customFilter]);
|
||||
|
||||
const header = customFilter.message
|
||||
? intl.formatMessage(
|
||||
{ id: customFilter.message.id },
|
||||
customFilter.message.values
|
||||
)
|
||||
: customFilter.title ?? "";
|
||||
|
||||
return (
|
||||
<RecommendationRow
|
||||
mode={customFilter.mode}
|
||||
filter={filter}
|
||||
header={header}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
content: FrontPageContent;
|
||||
}
|
||||
|
||||
export const Control: React.FC<IProps> = ({ content }) => {
|
||||
switch (content.__typename) {
|
||||
case "SavedFilter":
|
||||
return (
|
||||
<SavedFilterResults
|
||||
savedFilterID={(content as ISavedFilterRow).savedFilterId.toString()}
|
||||
/>
|
||||
);
|
||||
case "CustomFilter":
|
||||
return <CustomFilterResults customFilter={content as ICustomFilter} />;
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
82
ui/v2.5/src/components/FrontPage/FrontPage.tsx
Normal file
82
ui/v2.5/src/components/FrontPage/FrontPage.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import React, { useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useConfigureUI } from "src/core/StashService";
|
||||
import { LoadingIndicator } from "src/components/Shared";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { FrontPageConfig } from "./FrontPageConfig";
|
||||
import { useToast } from "src/hooks";
|
||||
import { Control } from "./Control";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import {
|
||||
FrontPageContent,
|
||||
generateDefaultFrontPageContent,
|
||||
IUIConfig,
|
||||
} from "src/core/config";
|
||||
|
||||
const FrontPage: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [saveUI] = useConfigureUI();
|
||||
|
||||
const { configuration, loading } = React.useContext(ConfigurationContext);
|
||||
|
||||
async function onUpdateConfig(content?: FrontPageContent[]) {
|
||||
setIsEditing(false);
|
||||
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveUI({
|
||||
variables: {
|
||||
input: {
|
||||
frontPageContent: content,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
if (loading || saving) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return <FrontPageConfig onClose={(content) => onUpdateConfig(content)} />;
|
||||
}
|
||||
|
||||
const ui = (configuration?.ui ?? {}) as IUIConfig;
|
||||
|
||||
if (!ui.frontPageContent) {
|
||||
const defaultContent = generateDefaultFrontPageContent(intl);
|
||||
onUpdateConfig(defaultContent);
|
||||
}
|
||||
|
||||
const { frontPageContent } = ui;
|
||||
|
||||
return (
|
||||
<div className="recommendations-container">
|
||||
<div>
|
||||
{frontPageContent?.map((content: FrontPageContent, i) => (
|
||||
<Control key={i} content={content} />
|
||||
))}
|
||||
</div>
|
||||
<div className="recommendations-footer">
|
||||
<Button onClick={() => setIsEditing(true)}>
|
||||
<FormattedMessage id={"actions.customise"} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FrontPage;
|
||||
407
ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx
Normal file
407
ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedMessage, IntlShape, useIntl } from "react-intl";
|
||||
import { useFindSavedFilters } from "src/core/StashService";
|
||||
import { LoadingIndicator } from "src/components/Shared";
|
||||
import { Button, Form, Modal } from "react-bootstrap";
|
||||
import {
|
||||
FilterMode,
|
||||
FindSavedFiltersQuery,
|
||||
SavedFilter,
|
||||
} from "src/core/generated-graphql";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import {
|
||||
IUIConfig,
|
||||
ISavedFilterRow,
|
||||
ICustomFilter,
|
||||
FrontPageContent,
|
||||
generatePremadeFrontPageContent,
|
||||
} from "src/core/config";
|
||||
|
||||
interface IAddSavedFilterModalProps {
|
||||
onClose: (content?: FrontPageContent) => void;
|
||||
existingSavedFilterIDs: string[];
|
||||
candidates: FindSavedFiltersQuery;
|
||||
}
|
||||
|
||||
const FilterModeToMessageID = {
|
||||
[FilterMode.Galleries]: "galleries",
|
||||
[FilterMode.Images]: "images",
|
||||
[FilterMode.Movies]: "movies",
|
||||
[FilterMode.Performers]: "performers",
|
||||
[FilterMode.SceneMarkers]: "markers",
|
||||
[FilterMode.Scenes]: "scenes",
|
||||
[FilterMode.Studios]: "studios",
|
||||
[FilterMode.Tags]: "tags",
|
||||
};
|
||||
|
||||
function filterTitle(intl: IntlShape, f: Pick<SavedFilter, "mode" | "name">) {
|
||||
return `${intl.formatMessage({ id: FilterModeToMessageID[f.mode] })}: ${
|
||||
f.name
|
||||
}`;
|
||||
}
|
||||
|
||||
const AddContentModal: React.FC<IAddSavedFilterModalProps> = ({
|
||||
onClose,
|
||||
existingSavedFilterIDs,
|
||||
candidates,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const premadeFilterOptions = useMemo(
|
||||
() => generatePremadeFrontPageContent(intl),
|
||||
[intl]
|
||||
);
|
||||
|
||||
const [contentType, setContentType] = useState(
|
||||
"front_page.types.premade_filter"
|
||||
);
|
||||
const [premadeFilterIndex, setPremadeFilterIndex] = useState<
|
||||
number | undefined
|
||||
>(0);
|
||||
const [savedFilter, setSavedFilter] = useState<string | undefined>();
|
||||
|
||||
function onTypeSelected(t: string) {
|
||||
setContentType(t);
|
||||
|
||||
switch (t) {
|
||||
case "front_page.types.premade_filter":
|
||||
setPremadeFilterIndex(0);
|
||||
setSavedFilter(undefined);
|
||||
break;
|
||||
case "front_page.types.saved_filter":
|
||||
setPremadeFilterIndex(undefined);
|
||||
setSavedFilter(undefined);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function isValid() {
|
||||
switch (contentType) {
|
||||
case "front_page.types.premade_filter":
|
||||
return premadeFilterIndex !== undefined;
|
||||
case "front_page.types.saved_filter":
|
||||
return savedFilter !== undefined;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const savedFilterOptions = useMemo(() => {
|
||||
const ret = [
|
||||
{
|
||||
value: "",
|
||||
text: "",
|
||||
},
|
||||
].concat(
|
||||
candidates.findSavedFilters
|
||||
.filter((f) => {
|
||||
// markers not currently supported
|
||||
return (
|
||||
f.mode !== FilterMode.SceneMarkers &&
|
||||
!existingSavedFilterIDs.includes(f.id)
|
||||
);
|
||||
})
|
||||
.map((f) => {
|
||||
return {
|
||||
value: f.id,
|
||||
text: filterTitle(intl, f),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
ret.sort((a, b) => {
|
||||
return a.text.localeCompare(b.text);
|
||||
});
|
||||
|
||||
return ret;
|
||||
}, [candidates, existingSavedFilterIDs, intl]);
|
||||
|
||||
function renderTypeSelect() {
|
||||
const options = [
|
||||
"front_page.types.premade_filter",
|
||||
"front_page.types.saved_filter",
|
||||
];
|
||||
return (
|
||||
<Form.Group controlId="filter">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="type" />
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
value={contentType}
|
||||
onChange={(e) => onTypeSelected(e.target.value)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{options.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{intl.formatMessage({ id: c })}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderPremadeFiltersSelect() {
|
||||
if (contentType !== "front_page.types.premade_filter") return;
|
||||
|
||||
return (
|
||||
<Form.Group controlId="premade-filter">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="front_page.types.premade_filter" />
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
value={premadeFilterIndex}
|
||||
onChange={(e) => setPremadeFilterIndex(parseInt(e.target.value))}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{premadeFilterOptions.map((c, i) => (
|
||||
<option key={i} value={i}>
|
||||
{intl.formatMessage({ id: c.message!.id }, c.message!.values)}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderSavedFiltersSelect() {
|
||||
if (contentType !== "front_page.types.saved_filter") return;
|
||||
return (
|
||||
<Form.Group controlId="filter">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="search_filter.name" />
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
value={savedFilter}
|
||||
onChange={(e) => setSavedFilter(e.target.value)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{savedFilterOptions.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.text}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function doAdd() {
|
||||
switch (contentType) {
|
||||
case "front_page.types.premade_filter":
|
||||
onClose(premadeFilterOptions[premadeFilterIndex!]);
|
||||
return;
|
||||
case "front_page.types.saved_filter":
|
||||
onClose({
|
||||
__typename: "SavedFilter",
|
||||
savedFilterId: parseInt(savedFilter!),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal show onHide={() => onClose()}>
|
||||
<Modal.Header>
|
||||
<FormattedMessage id="actions.add" />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div className="dialog-content">
|
||||
{renderTypeSelect()}
|
||||
{maybeRenderSavedFiltersSelect()}
|
||||
{maybeRenderPremadeFiltersSelect()}
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => onClose()}>
|
||||
<FormattedMessage id="actions.cancel" />
|
||||
</Button>
|
||||
<Button onClick={() => doAdd()} disabled={!isValid()}>
|
||||
<FormattedMessage id="actions.add" />
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IFilterRowProps {
|
||||
content: FrontPageContent;
|
||||
allSavedFilters: Pick<SavedFilter, "id" | "mode" | "name">[];
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const ContentRow: React.FC<IFilterRowProps> = (props: IFilterRowProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
function title() {
|
||||
switch (props.content.__typename) {
|
||||
case "SavedFilter":
|
||||
const savedFilter = props.allSavedFilters.find(
|
||||
(f) =>
|
||||
f.id === (props.content as ISavedFilterRow).savedFilterId.toString()
|
||||
);
|
||||
if (!savedFilter) return "";
|
||||
return filterTitle(intl, savedFilter);
|
||||
case "CustomFilter":
|
||||
const asCustomFilter = props.content as ICustomFilter;
|
||||
if (asCustomFilter.message)
|
||||
return intl.formatMessage(
|
||||
{ id: asCustomFilter.message.id },
|
||||
asCustomFilter.message.values
|
||||
);
|
||||
return asCustomFilter.title ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recommendation-row">
|
||||
<div className="recommendation-row-head">
|
||||
<div>
|
||||
<h2>{title()}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
title={intl.formatMessage({ id: "actions.delete" })}
|
||||
onClick={() => props.onDelete()}
|
||||
>
|
||||
<FormattedMessage id="actions.delete" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IFrontPageConfigProps {
|
||||
onClose: (content?: FrontPageContent[]) => void;
|
||||
}
|
||||
|
||||
export const FrontPageConfig: React.FC<IFrontPageConfigProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const { configuration, loading } = React.useContext(ConfigurationContext);
|
||||
|
||||
const ui = configuration?.ui as IUIConfig;
|
||||
|
||||
const { data: allFilters, loading: loading2 } = useFindSavedFilters();
|
||||
|
||||
const [isAdd, setIsAdd] = useState(false);
|
||||
const [currentContent, setCurrentContent] = useState<FrontPageContent[]>([]);
|
||||
const [dragIndex, setDragIndex] = useState<number | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!allFilters?.findSavedFilters) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ui?.frontPageContent) {
|
||||
setCurrentContent(ui.frontPageContent);
|
||||
}
|
||||
}, [allFilters, ui]);
|
||||
|
||||
function onDragStart(event: React.DragEvent<HTMLElement>, index: number) {
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
setDragIndex(index);
|
||||
}
|
||||
|
||||
function onDragOver(event: React.DragEvent<HTMLElement>, index?: number) {
|
||||
if (dragIndex !== undefined && index !== undefined && index !== dragIndex) {
|
||||
const newFilters = [...currentContent];
|
||||
const moved = newFilters.splice(dragIndex, 1);
|
||||
newFilters.splice(index, 0, moved[0]);
|
||||
setCurrentContent(newFilters);
|
||||
setDragIndex(index);
|
||||
}
|
||||
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function onDragOverDefault(event: React.DragEvent<HTMLDivElement>) {
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function onDrop() {
|
||||
// assume we've already set the temp filter list
|
||||
// feed it up
|
||||
setDragIndex(undefined);
|
||||
}
|
||||
|
||||
if (loading || loading2) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
const existingSavedFilterIDs = currentContent
|
||||
.filter((f) => f.__typename === "SavedFilter")
|
||||
.map((f) => (f as ISavedFilterRow).savedFilterId.toString());
|
||||
|
||||
function addSavedFilter(content?: FrontPageContent) {
|
||||
setIsAdd(false);
|
||||
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentContent([...currentContent, content]);
|
||||
}
|
||||
|
||||
function deleteSavedFilter(index: number) {
|
||||
setCurrentContent(currentContent.filter((f, i) => i !== index));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isAdd && allFilters && (
|
||||
<AddContentModal
|
||||
candidates={allFilters}
|
||||
existingSavedFilterIDs={existingSavedFilterIDs}
|
||||
onClose={addSavedFilter}
|
||||
/>
|
||||
)}
|
||||
<div className="recommendations-container recommendations-container-edit">
|
||||
<div onDragOver={onDragOverDefault}>
|
||||
{currentContent.map((content, index) => (
|
||||
<div
|
||||
key={index}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, index)}
|
||||
onDragEnter={(e) => onDragOver(e, index)}
|
||||
onDrop={() => onDrop()}
|
||||
>
|
||||
<ContentRow
|
||||
key={index}
|
||||
allSavedFilters={allFilters!.findSavedFilters}
|
||||
content={content}
|
||||
onDelete={() => deleteSavedFilter(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="recommendation-row recommendation-row-add">
|
||||
<div className="recommendation-row-head">
|
||||
<Button
|
||||
className="recommendations-add"
|
||||
variant="primary"
|
||||
onClick={() => setIsAdd(true)}
|
||||
>
|
||||
<FormattedMessage id="actions.add" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recommendations-footer">
|
||||
<Button onClick={() => onClose()} variant="secondary">
|
||||
<FormattedMessage id={"actions.cancel"} />
|
||||
</Button>
|
||||
<Button onClick={() => onClose(currentContent)}>
|
||||
<FormattedMessage id={"actions.save"} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
24
ui/v2.5/src/components/FrontPage/RecommendationRow.tsx
Normal file
24
ui/v2.5/src/components/FrontPage/RecommendationRow.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
interface IProps {
|
||||
className?: string;
|
||||
header: string;
|
||||
link: JSX.Element;
|
||||
}
|
||||
|
||||
export const RecommendationRow: React.FC<PropsWithChildren<IProps>> = ({
|
||||
className,
|
||||
header,
|
||||
link,
|
||||
children,
|
||||
}) => (
|
||||
<div className={`recommendation-row ${className}`}>
|
||||
<div className="recommendation-row-head">
|
||||
<div>
|
||||
<h2>{header}</h2>
|
||||
</div>
|
||||
{link}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -6,6 +6,17 @@
|
|||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.recommendations-footer {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
margin-bottom: 1em;
|
||||
margin-top: 1em;
|
||||
|
||||
button:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-recommendations {
|
||||
|
|
@ -24,6 +35,25 @@
|
|||
padding: 15px 0;
|
||||
}
|
||||
|
||||
.recommendations-container-edit {
|
||||
.recommendation-row {
|
||||
background-color: $secondary;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:not(.recommendation-row-add) {
|
||||
cursor: grab;
|
||||
}
|
||||
}
|
||||
|
||||
.recommendation-row-add .recommendation-row-head {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.recommendation-row-head {
|
||||
padding: 15px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.recommendation-row-head h2 {
|
||||
display: inline-flex;
|
||||
font-size: 1.25rem;
|
||||
|
|
@ -41,10 +71,111 @@
|
|||
|
||||
.recommendations-container .studio-card hr,
|
||||
.recommendations-container .movie-card hr,
|
||||
.recommendations-container .gallery-card hr {
|
||||
.recommendations-container .gallery-card hr,
|
||||
.recommendations-container .image-card hr,
|
||||
.recommendations-container .tag-card hr {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* skeletons */
|
||||
.skeleton-card {
|
||||
-webkit-animation: cardLoadingAnimation 2s infinite ease-in-out;
|
||||
-moz-animation: cardLoadingAnimation 2s infinite ease-in-out;
|
||||
-o-animation: cardLoadingAnimation 2s infinite ease-in-out;
|
||||
animation: cardLoadingAnimation 2s infinite ease-in-out;
|
||||
background-clip: border-box;
|
||||
background-color: #30404d;
|
||||
border: 1px solid rgba(0, 0, 0, 0.13);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 0 1px #10161a66, 0 0 #10161a00, 0 0 #10161a00;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 5px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@keyframes cardLoadingAnimation {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.scene-skeleton {
|
||||
max-width: 320px;
|
||||
min-height: 365px;
|
||||
min-width: 320px;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
max-width: 20rem;
|
||||
min-height: 25.2rem;
|
||||
min-width: 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
.movie-skeleton {
|
||||
max-width: 240px;
|
||||
min-height: 540px;
|
||||
min-width: 240px;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
max-width: 16rem;
|
||||
min-height: 34rem;
|
||||
min-width: 16rem;
|
||||
}
|
||||
}
|
||||
|
||||
.performer-skeleton {
|
||||
max-width: 20rem;
|
||||
min-height: 39.1rem;
|
||||
min-width: 20rem;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
max-width: 16rem;
|
||||
min-height: 33.1rem;
|
||||
min-width: 16rem;
|
||||
}
|
||||
}
|
||||
|
||||
.image-skeleton,
|
||||
.gallery-skeleton {
|
||||
max-width: 320px;
|
||||
min-height: 403.5px;
|
||||
min-width: 320px;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
max-width: 20rem;
|
||||
min-height: 38.5rem;
|
||||
min-width: 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
.studio-skeleton {
|
||||
max-width: 360px;
|
||||
min-height: 278px;
|
||||
min-width: 360px;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
max-width: 20rem;
|
||||
min-height: 19.8rem;
|
||||
min-width: 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-skeleton {
|
||||
max-width: 240px;
|
||||
min-height: 365px;
|
||||
min-width: 240px;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
max-width: 16rem;
|
||||
min-height: 26rem;
|
||||
min-width: 16rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Slider */
|
||||
.slick-slider {
|
||||
box-sizing: border-box;
|
||||
|
|
@ -310,7 +441,6 @@
|
|||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { Modal } from "src/components/Shared";
|
|||
import { useToast } from "src/hooks";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IDeleteGalleryDialogProps {
|
||||
selected: GQL.SlimGalleryDataFragment[];
|
||||
|
|
@ -114,7 +115,7 @@ export const DeleteGalleriesDialog: React.FC<IDeleteGalleryDialogProps> = (
|
|||
return (
|
||||
<Modal
|
||||
show
|
||||
icon="trash-alt"
|
||||
icon={faTrashAlt}
|
||||
header={header}
|
||||
accept={{
|
||||
variant: "danger",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Form, Col, Row } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import _ from "lodash";
|
||||
import isEqual from "lodash-es/isEqual";
|
||||
import { useBulkGalleryUpdate } from "src/core/StashService";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { StudioSelect, Modal } from "src/components/Shared";
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
getAggregateStudioId,
|
||||
getAggregateTagIds,
|
||||
} from "src/utils/bulkUpdate";
|
||||
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IListOperationProps {
|
||||
selected: GQL.SlimGalleryDataFragment[];
|
||||
|
|
@ -141,10 +142,10 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||
if (GalleriestudioID !== updateStudioID) {
|
||||
updateStudioID = undefined;
|
||||
}
|
||||
if (!_.isEqual(galleryPerformerIDs, updatePerformerIds)) {
|
||||
if (!isEqual(galleryPerformerIDs, updatePerformerIds)) {
|
||||
updatePerformerIds = [];
|
||||
}
|
||||
if (!_.isEqual(galleryTagIDs, updateTagIds)) {
|
||||
if (!isEqual(galleryTagIDs, updateTagIds)) {
|
||||
updateTagIds = [];
|
||||
}
|
||||
if (gallery.organized !== updateOrganized) {
|
||||
|
|
@ -229,7 +230,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||
return (
|
||||
<Modal
|
||||
show
|
||||
icon="pencil-alt"
|
||||
icon={faPencilAlt}
|
||||
header={intl.formatMessage(
|
||||
{ id: "dialogs.edit_entity_title" },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { NavUtils, TextUtils } from "src/utils";
|
|||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton";
|
||||
import { RatingBanner } from "../Shared/RatingBanner";
|
||||
import { faBox, faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IProps {
|
||||
gallery: GQL.SlimGalleryDataFragment;
|
||||
|
|
@ -41,7 +42,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
|||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon="play-circle" />
|
||||
<Icon icon={faPlayCircle} />
|
||||
<span>{props.gallery.scenes.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
|
|
@ -62,7 +63,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
|||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon="tag" />
|
||||
<Icon icon={faTag} />
|
||||
<span>{props.gallery.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
|
|
@ -113,7 +114,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
|||
return (
|
||||
<div className="organized">
|
||||
<Button className="minimal">
|
||||
<Icon icon="box" />
|
||||
<Icon icon={faBox} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -167,12 +168,16 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
|||
}
|
||||
overlays={maybeRenderSceneStudioOverlay()}
|
||||
details={
|
||||
<>
|
||||
<span>{props.gallery.date}</span>
|
||||
<div className="gallery-card__details">
|
||||
<span className="gallery-card__date">{props.gallery.date}</span>
|
||||
<p>
|
||||
<TruncatedText text={props.gallery.details} lineCount={3} />
|
||||
<TruncatedText
|
||||
className="gallery-card__description"
|
||||
text={props.gallery.details}
|
||||
lineCount={3}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
popovers={maybeRenderPopoverButtonGroup()}
|
||||
selected={props.selected}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { GalleryImagesPanel } from "./GalleryImagesPanel";
|
|||
import { GalleryAddPanel } from "./GalleryAddPanel";
|
||||
import { GalleryFileInfoPanel } from "./GalleryFileInfoPanel";
|
||||
import { GalleryScenesPanel } from "./GalleryScenesPanel";
|
||||
import { faEllipsisV } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IProps {
|
||||
gallery: GQL.GalleryDataFragment;
|
||||
|
|
@ -116,7 +117,7 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
|
|||
className="minimal"
|
||||
title={intl.formatMessage({ id: "operations" })}
|
||||
>
|
||||
<Icon icon="ellipsis-v" />
|
||||
<Icon icon={faEllipsisV} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="bg-secondary text-white">
|
||||
{gallery.path ? (
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { mutateAddGalleryImages } from "src/core/StashService";
|
|||
import { useToast } from "src/hooks";
|
||||
import { TextUtils } from "src/utils";
|
||||
import { useIntl } from "react-intl";
|
||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IGalleryAddProps {
|
||||
gallery: GQL.GalleryDataFragment;
|
||||
|
|
@ -88,7 +88,7 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => {
|
|||
onClick: addImages,
|
||||
isDisplayed: showWhenSelected,
|
||||
postRefetch: true,
|
||||
icon: "plus" as IconProp,
|
||||
icon: faPlus,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { useFormik } from "formik";
|
|||
import { FormUtils, TextUtils } from "src/utils";
|
||||
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
|
||||
import { GalleryScrapeDialog } from "./GalleryScrapeDialog";
|
||||
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IProps {
|
||||
isVisible: boolean;
|
||||
|
|
@ -314,7 +315,7 @@ export const GalleryEditPanel: React.FC<
|
|||
))}
|
||||
<Dropdown.Item onClick={() => onReloadScrapers()}>
|
||||
<span className="fa-icon">
|
||||
<Icon icon="sync-alt" />
|
||||
<Icon icon={faSyncAlt} />
|
||||
</span>
|
||||
<span>
|
||||
<FormattedMessage id="actions.reload_scrapers" />
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { showWhenSelected, PersistanceLevel } from "src/hooks/ListHook";
|
|||
import { useToast } from "src/hooks";
|
||||
import { TextUtils } from "src/utils";
|
||||
import { useIntl } from "react-intl";
|
||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IGalleryDetailsProps {
|
||||
gallery: GQL.GalleryDataFragment;
|
||||
|
|
@ -82,7 +82,7 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> = ({
|
|||
onClick: removeImages,
|
||||
isDisplayed: showWhenSelected,
|
||||
postRefetch: true,
|
||||
icon: "minus" as IconProp,
|
||||
icon: faMinus,
|
||||
buttonVariant: "danger",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
ScrapedInputGroupRow,
|
||||
ScrapedTextAreaRow,
|
||||
} from "src/components/Shared/ScrapeDialog";
|
||||
import _ from "lodash";
|
||||
import clone from "lodash-es/clone";
|
||||
import {
|
||||
useStudioCreate,
|
||||
usePerformerCreate,
|
||||
|
|
@ -235,7 +235,7 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
|
|||
return;
|
||||
}
|
||||
|
||||
const ret = _.clone(idList);
|
||||
const ret = clone(idList);
|
||||
// sort by id numerically
|
||||
ret.sort((a, b) => {
|
||||
return parseInt(a, 10) - parseInt(b, 10);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import _ from "lodash";
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import { Table } from "react-bootstrap";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import Mousetrap from "mousetrap";
|
||||
|
|
@ -84,7 +84,7 @@ export const GalleryList: React.FC<IGalleryList> = ({
|
|||
const { count } = result.data.findGalleries;
|
||||
|
||||
const index = Math.floor(Math.random() * count);
|
||||
const filterCopy = _.cloneDeep(filter);
|
||||
const filterCopy = cloneDeep(filter);
|
||||
filterCopy.itemsPerPage = 1;
|
||||
filterCopy.currentPage = index + 1;
|
||||
const singleResult = await queryFindGalleries(filterCopy);
|
||||
|
|
|
|||
|
|
@ -1,37 +1,55 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { FindGalleriesQueryResult } from "src/core/generated-graphql";
|
||||
import { useFindGalleries } from "src/core/StashService";
|
||||
import Slider from "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";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
filter: ListFilterModel;
|
||||
result: FindGalleriesQueryResult;
|
||||
header: String;
|
||||
linkText: String;
|
||||
header: string;
|
||||
}
|
||||
|
||||
export const GalleryRecommendationRow: FunctionComponent<IProps> = (
|
||||
props: IProps
|
||||
) => {
|
||||
const cardCount = props.result.data?.findGalleries.count;
|
||||
const result = useFindGalleries(props.filter);
|
||||
const cardCount = result.data?.findGalleries.count;
|
||||
|
||||
if (!result.loading && !cardCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recommendation-row gallery-recommendations">
|
||||
<div className="recommendation-row-head">
|
||||
<div>
|
||||
<h2>{props.header}</h2>
|
||||
</div>
|
||||
<RecommendationRow
|
||||
className="gallery-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<a href={`/galleries?${props.filter.makeQueryParameters()}`}>
|
||||
{props.linkText}
|
||||
<FormattedMessage id="view_all" />
|
||||
</a>
|
||||
</div>
|
||||
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}>
|
||||
{props.result.data?.findGalleries.galleries.map((gallery) => (
|
||||
<GalleryCard key={gallery.id} gallery={gallery} zoomIndex={1} />
|
||||
))}
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</RecommendationRow>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,3 +38,5 @@ export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GalleryViewer;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, PropsWithChildren, useEffect } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Modal, Container, Row, Col, Nav, Tab } from "react-bootstrap";
|
||||
import Introduction from "src/docs/en/Introduction.md";
|
||||
import Tasks from "src/docs/en/Tasks.md";
|
||||
|
|
@ -239,62 +239,4 @@ export const Manual: React.FC<IManualProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
interface IManualContextState {
|
||||
openManual: (tab?: string) => void;
|
||||
}
|
||||
|
||||
export const ManualStateContext = React.createContext<IManualContextState>({
|
||||
openManual: () => {},
|
||||
});
|
||||
|
||||
export const ManualProvider: React.FC = ({ children }) => {
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
const [manualLink, setManualLink] = useState<string | undefined>();
|
||||
|
||||
function openManual(tab?: string) {
|
||||
setManualLink(tab);
|
||||
setShowManual(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (manualLink) setManualLink(undefined);
|
||||
}, [manualLink]);
|
||||
|
||||
return (
|
||||
<ManualStateContext.Provider
|
||||
value={{
|
||||
openManual,
|
||||
}}
|
||||
>
|
||||
<Manual
|
||||
show={showManual}
|
||||
onClose={() => setShowManual(false)}
|
||||
defaultActiveTab={manualLink}
|
||||
/>
|
||||
{children}
|
||||
</ManualStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface IManualLink {
|
||||
tab: string;
|
||||
}
|
||||
|
||||
export const ManualLink: React.FC<PropsWithChildren<IManualLink>> = ({
|
||||
tab,
|
||||
children,
|
||||
}) => {
|
||||
const { openManual } = React.useContext(ManualStateContext);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/help/${tab}.md`}
|
||||
onClick={(e) => {
|
||||
openManual(`${tab}.md`);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
export default Manual;
|
||||
|
|
|
|||
73
ui/v2.5/src/components/Help/context.tsx
Normal file
73
ui/v2.5/src/components/Help/context.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import React, {
|
||||
lazy,
|
||||
PropsWithChildren,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const Manual = lazy(() => import("./Manual"));
|
||||
|
||||
interface IManualContextState {
|
||||
openManual: (tab?: string) => void;
|
||||
}
|
||||
|
||||
export const ManualStateContext = React.createContext<IManualContextState>({
|
||||
openManual: () => {},
|
||||
});
|
||||
|
||||
export const ManualProvider: React.FC = ({ children }) => {
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
const [manualLink, setManualLink] = useState<string | undefined>();
|
||||
|
||||
function openManual(tab?: string) {
|
||||
setManualLink(tab);
|
||||
setShowManual(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (manualLink) setManualLink(undefined);
|
||||
}, [manualLink]);
|
||||
|
||||
return (
|
||||
<ManualStateContext.Provider
|
||||
value={{
|
||||
openManual,
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<></>}>
|
||||
{showManual && (
|
||||
<Manual
|
||||
show={showManual}
|
||||
onClose={() => setShowManual(false)}
|
||||
defaultActiveTab={manualLink}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
{children}
|
||||
</ManualStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface IManualLink {
|
||||
tab: string;
|
||||
}
|
||||
|
||||
export const ManualLink: React.FC<PropsWithChildren<IManualLink>> = ({
|
||||
tab,
|
||||
children,
|
||||
}) => {
|
||||
const { openManual } = React.useContext(ManualStateContext);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/help/${tab}.md`}
|
||||
onClick={(e) => {
|
||||
openManual(`${tab}.md`);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
|
@ -6,6 +6,7 @@ import { Modal } from "src/components/Shared";
|
|||
import { useToast } from "src/hooks";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IDeleteImageDialogProps {
|
||||
selected: GQL.SlimImageDataFragment[];
|
||||
|
|
@ -106,7 +107,7 @@ export const DeleteImagesDialog: React.FC<IDeleteImageDialogProps> = (
|
|||
return (
|
||||
<Modal
|
||||
show
|
||||
icon="trash-alt"
|
||||
icon={faTrashAlt}
|
||||
header={header}
|
||||
accept={{
|
||||
variant: "danger",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Form, Col, Row } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import _ from "lodash";
|
||||
import isEqual from "lodash-es/isEqual";
|
||||
import { useBulkImageUpdate } from "src/core/StashService";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { StudioSelect, Modal } from "src/components/Shared";
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
getAggregateStudioId,
|
||||
getAggregateTagIds,
|
||||
} from "src/utils/bulkUpdate";
|
||||
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IListOperationProps {
|
||||
selected: GQL.SlimImageDataFragment[];
|
||||
|
|
@ -132,10 +133,10 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||
if (imageStudioID !== updateStudioID) {
|
||||
updateStudioID = undefined;
|
||||
}
|
||||
if (!_.isEqual(imagePerformerIDs, updatePerformerIds)) {
|
||||
if (!isEqual(imagePerformerIDs, updatePerformerIds)) {
|
||||
updatePerformerIds = [];
|
||||
}
|
||||
if (!_.isEqual(imageTagIDs, updateTagIds)) {
|
||||
if (!isEqual(imageTagIDs, updateTagIds)) {
|
||||
updateTagIds = [];
|
||||
}
|
||||
if (image.organized !== updateOrganized) {
|
||||
|
|
@ -219,7 +220,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||
return (
|
||||
<Modal
|
||||
show
|
||||
icon="pencil-alt"
|
||||
icon={faPencilAlt}
|
||||
header={intl.formatMessage(
|
||||
{ id: "dialogs.edit_entity_title" },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,13 +7,19 @@ import { TextUtils } from "src/utils";
|
|||
import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton";
|
||||
import { GridCard } from "../Shared/GridCard";
|
||||
import { RatingBanner } from "../Shared/RatingBanner";
|
||||
import {
|
||||
faBox,
|
||||
faImages,
|
||||
faSearch,
|
||||
faTag,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IImageCardProps {
|
||||
image: GQL.SlimImageDataFragment;
|
||||
selecting?: boolean;
|
||||
selected: boolean | undefined;
|
||||
selected?: boolean | undefined;
|
||||
zoomIndex: number;
|
||||
onSelectedChanged: (selected: boolean, shiftKey: boolean) => void;
|
||||
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
|
||||
onPreview?: (ev: MouseEvent) => void;
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +40,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
|
|||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon="tag" />
|
||||
<Icon icon={faTag} />
|
||||
<span>{props.image.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
|
|
@ -76,7 +82,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
|
|||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon="images" />
|
||||
<Icon icon={faImages} />
|
||||
<span>{props.image.galleries.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
|
|
@ -88,7 +94,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
|
|||
return (
|
||||
<div className="organized">
|
||||
<Button className="minimal">
|
||||
<Icon icon="box" />
|
||||
<Icon icon={faBox} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -146,7 +152,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
|
|||
{props.onPreview ? (
|
||||
<div className="preview-button">
|
||||
<Button onClick={props.onPreview}>
|
||||
<Icon icon="search" />
|
||||
<Icon icon={faSearch} />
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { ImageFileInfoPanel } from "./ImageFileInfoPanel";
|
|||
import { ImageEditPanel } from "./ImageEditPanel";
|
||||
import { ImageDetailPanel } from "./ImageDetailPanel";
|
||||
import { DeleteImagesDialog } from "../DeleteImagesDialog";
|
||||
import { faEllipsisV } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IImageParams {
|
||||
id?: string;
|
||||
|
|
@ -132,7 +133,7 @@ export const Image: React.FC = () => {
|
|||
className="minimal"
|
||||
title="Operations"
|
||||
>
|
||||
<Icon icon="ellipsis-v" />
|
||||
<Icon icon={faEllipsisV} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="bg-secondary text-white">
|
||||
<Dropdown.Item
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useState, useMemo, MouseEvent } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import _ from "lodash";
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import Mousetrap from "mousetrap";
|
||||
import {
|
||||
|
|
@ -253,7 +253,7 @@ export const ImageList: React.FC<IImageList> = ({
|
|||
const { count } = result.data.findImages;
|
||||
|
||||
const index = Math.floor(Math.random() * count);
|
||||
const filterCopy = _.cloneDeep(filter);
|
||||
const filterCopy = cloneDeep(filter);
|
||||
filterCopy.itemsPerPage = 1;
|
||||
filterCopy.currentPage = index + 1;
|
||||
const singleResult = await queryFindImages(filterCopy);
|
||||
|
|
|
|||
52
ui/v2.5/src/components/Images/ImageRecommendationRow.tsx
Normal file
52
ui/v2.5/src/components/Images/ImageRecommendationRow.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { useFindImages } from "src/core/StashService";
|
||||
import Slider from "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";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
filter: ListFilterModel;
|
||||
header: string;
|
||||
}
|
||||
|
||||
export const ImageRecommendationRow: FunctionComponent<IProps> = (
|
||||
props: IProps
|
||||
) => {
|
||||
const result = useFindImages(props.filter);
|
||||
const cardCount = result.data?.findImages.count;
|
||||
|
||||
if (!result.loading && !cardCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RecommendationRow
|
||||
className="images-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<a href={`/images?${props.filter.makeQueryParameters()}`}>
|
||||
<FormattedMessage id="view_all" />
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import _ from "lodash";
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Button, Form, Modal } from "react-bootstrap";
|
||||
import { CriterionModifier } from "src/core/generated-graphql";
|
||||
|
|
@ -80,13 +80,13 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
|
|||
function onChangedModifierSelect(
|
||||
event: React.ChangeEvent<HTMLSelectElement>
|
||||
) {
|
||||
const newCriterion = _.cloneDeep(criterion);
|
||||
const newCriterion = cloneDeep(criterion);
|
||||
newCriterion.modifier = event.target.value as CriterionModifier;
|
||||
setCriterion(newCriterion);
|
||||
}
|
||||
|
||||
function onValueChanged(value: CriterionValue) {
|
||||
const newCriterion = _.cloneDeep(criterion);
|
||||
const newCriterion = cloneDeep(criterion);
|
||||
newCriterion.value = value;
|
||||
setCriterion(newCriterion);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from "src/models/list-filter/criteria/criterion";
|
||||
import { useIntl } from "react-intl";
|
||||
import { Icon } from "../Shared";
|
||||
import { faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IFilterTagsProps {
|
||||
criteria: Criterion<CriterionValue>[];
|
||||
|
|
@ -48,7 +49,7 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
|||
variant="secondary"
|
||||
onClick={($event) => onRemoveCriterionTag(criterion, $event)}
|
||||
>
|
||||
<Icon icon="times" />
|
||||
<Icon icon={faTimes} />
|
||||
</Button>
|
||||
</Badge>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import _, { debounce } from "lodash";
|
||||
import debounce from "lodash-es/debounce";
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import React, { HTMLAttributes, useEffect, useRef, useState } from "react";
|
||||
import cx from "classnames";
|
||||
import Mousetrap from "mousetrap";
|
||||
|
|
@ -23,6 +24,15 @@ import { ListFilterOptions } from "src/models/list-filter/filter-options";
|
|||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { PersistanceLevel } from "src/hooks/ListHook";
|
||||
import { SavedFilterList } from "./SavedFilterList";
|
||||
import {
|
||||
faBookmark,
|
||||
faCaretDown,
|
||||
faCaretUp,
|
||||
faCheck,
|
||||
faFilter,
|
||||
faRandom,
|
||||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const maxPageSize = 1000;
|
||||
interface IListFilterProps {
|
||||
|
|
@ -53,7 +63,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
|||
const [perPageInput, perPageFocus] = useFocus();
|
||||
|
||||
const searchCallback = debounce((value: string) => {
|
||||
const newFilter = _.cloneDeep(filter);
|
||||
const newFilter = cloneDeep(filter);
|
||||
newFilter.searchTerm = value;
|
||||
newFilter.currentPage = 1;
|
||||
onFilterUpdate(newFilter);
|
||||
|
|
@ -101,7 +111,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
|||
pp = maxPageSize;
|
||||
}
|
||||
|
||||
const newFilter = _.cloneDeep(filter);
|
||||
const newFilter = cloneDeep(filter);
|
||||
newFilter.itemsPerPage = pp;
|
||||
newFilter.currentPage = 1;
|
||||
onFilterUpdate(newFilter);
|
||||
|
|
@ -120,7 +130,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
|||
}
|
||||
|
||||
function onChangeSortDirection() {
|
||||
const newFilter = _.cloneDeep(filter);
|
||||
const newFilter = cloneDeep(filter);
|
||||
if (filter.sortDirection === SortDirectionEnum.Asc) {
|
||||
newFilter.sortDirection = SortDirectionEnum.Desc;
|
||||
} else {
|
||||
|
|
@ -131,14 +141,14 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
|||
}
|
||||
|
||||
function onChangeSortBy(eventKey: string | null) {
|
||||
const newFilter = _.cloneDeep(filter);
|
||||
const newFilter = cloneDeep(filter);
|
||||
newFilter.sortBy = eventKey ?? undefined;
|
||||
newFilter.currentPage = 1;
|
||||
onFilterUpdate(newFilter);
|
||||
}
|
||||
|
||||
function onReshuffleRandomSort() {
|
||||
const newFilter = _.cloneDeep(filter);
|
||||
const newFilter = cloneDeep(filter);
|
||||
newFilter.currentPage = 1;
|
||||
newFilter.randomSeed = -1;
|
||||
onFilterUpdate(newFilter);
|
||||
|
|
@ -207,28 +217,8 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex mb-1">
|
||||
<InputGroup className="mr-2 flex-grow-1">
|
||||
<InputGroup.Prepend>
|
||||
<Dropdown>
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
<Tooltip id="filter-tooltip">
|
||||
<FormattedMessage id="search_filter.saved_filters" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Dropdown.Toggle variant="secondary">
|
||||
<Icon icon="bookmark" />
|
||||
</Dropdown.Toggle>
|
||||
</OverlayTrigger>
|
||||
<Dropdown.Menu
|
||||
as={SavedFilterDropdown}
|
||||
className="saved-filter-list-menu"
|
||||
/>
|
||||
</Dropdown>
|
||||
</InputGroup.Prepend>
|
||||
<div className="mb-2 mr-2 d-flex">
|
||||
<div className="flex-grow-1 query-text-field-group">
|
||||
<FormControl
|
||||
ref={queryRef}
|
||||
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
|
||||
|
|
@ -245,30 +235,49 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
|||
queryClearShowing ? "" : "d-none"
|
||||
)}
|
||||
>
|
||||
<Icon icon="times" />
|
||||
<Icon icon={faTimes} />
|
||||
</Button>
|
||||
<InputGroup.Append>
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
<Tooltip id="filter-tooltip">
|
||||
<FormattedMessage id="search_filter.name" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openFilterDialog()}
|
||||
active={filterDialogOpen}
|
||||
>
|
||||
<Icon icon="filter" />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
</InputGroup.Append>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dropdown as={ButtonGroup} className="mr-2 mb-1">
|
||||
<ButtonGroup className="mr-2 mb-2">
|
||||
<Dropdown>
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
<Tooltip id="filter-tooltip">
|
||||
<FormattedMessage id="search_filter.saved_filters" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Dropdown.Toggle variant="secondary">
|
||||
<Icon icon={faBookmark} />
|
||||
</Dropdown.Toggle>
|
||||
</OverlayTrigger>
|
||||
<Dropdown.Menu
|
||||
as={SavedFilterDropdown}
|
||||
className="saved-filter-list-menu"
|
||||
/>
|
||||
</Dropdown>
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
<Tooltip id="filter-tooltip">
|
||||
<FormattedMessage id="search_filter.name" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openFilterDialog()}
|
||||
active={filterDialogOpen}
|
||||
>
|
||||
<Icon icon={faFilter} />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
</ButtonGroup>
|
||||
|
||||
<Dropdown as={ButtonGroup} className="mr-2 mb-2">
|
||||
<InputGroup.Prepend>
|
||||
<Dropdown.Toggle variant="secondary">
|
||||
{currentSortBy
|
||||
|
|
@ -292,8 +301,8 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
|||
<Icon
|
||||
icon={
|
||||
filter.sortDirection === SortDirectionEnum.Asc
|
||||
? "caret-up"
|
||||
: "caret-down"
|
||||
? faCaretUp
|
||||
: faCaretDown
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
|
|
@ -307,19 +316,19 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
|||
}
|
||||
>
|
||||
<Button variant="secondary" onClick={onReshuffleRandomSort}>
|
||||
<Icon icon="random" />
|
||||
<Icon icon={faRandom} />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</Dropdown>
|
||||
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<Form.Control
|
||||
as="select"
|
||||
ref={perPageSelect}
|
||||
onChange={(e) => onChangePageSize(e.target.value)}
|
||||
value={filter.itemsPerPage.toString()}
|
||||
className="btn-secondary mx-1 mb-1"
|
||||
className="btn-secondary"
|
||||
>
|
||||
{pageSizeOptions.map((s) => (
|
||||
<option value={s.value} key={s.value}>
|
||||
|
|
@ -363,7 +372,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
|||
)
|
||||
}
|
||||
>
|
||||
<Icon icon="check" />
|
||||
<Icon icon={faCheck} />
|
||||
</Button>
|
||||
</InputGroup.Append>
|
||||
</InputGroup>
|
||||
|
|
|
|||
|
|
@ -8,14 +8,19 @@ import {
|
|||
} from "react-bootstrap";
|
||||
import Mousetrap from "mousetrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { Icon } from "../Shared";
|
||||
import {
|
||||
faEllipsisH,
|
||||
faPencilAlt,
|
||||
faTrash,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IListFilterOperation {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
isDisplayed?: () => boolean;
|
||||
icon?: IconProp;
|
||||
icon?: IconDefinition;
|
||||
buttonVariant?: string;
|
||||
}
|
||||
|
||||
|
|
@ -78,14 +83,14 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
|
|||
if (itemsSelected) {
|
||||
if (onEdit) {
|
||||
buttons.push({
|
||||
icon: "pencil-alt",
|
||||
icon: faPencilAlt,
|
||||
text: intl.formatMessage({ id: "actions.edit" }),
|
||||
onClick: onEdit,
|
||||
});
|
||||
}
|
||||
if (onDelete) {
|
||||
buttons.push({
|
||||
icon: "trash",
|
||||
icon: faTrash,
|
||||
text: intl.formatMessage({ id: "actions.delete" }),
|
||||
onClick: onDelete,
|
||||
buttonVariant: "danger",
|
||||
|
|
@ -95,7 +100,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
|
|||
|
||||
if (buttons.length > 0) {
|
||||
return (
|
||||
<ButtonGroup className="ml-2 mb-1">
|
||||
<ButtonGroup className="ml-2 mb-2">
|
||||
{buttons.map((button) => {
|
||||
return (
|
||||
<OverlayTrigger
|
||||
|
|
@ -106,7 +111,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
|
|||
variant={button.buttonVariant ?? "secondary"}
|
||||
onClick={button.onClick}
|
||||
>
|
||||
<Icon icon={button.icon as IconProp} />
|
||||
{button.icon ? <Icon icon={button.icon} /> : undefined}
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
|
|
@ -171,9 +176,9 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
|
|||
|
||||
if (options.length > 0) {
|
||||
return (
|
||||
<Dropdown className="mb-1">
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle variant="secondary" id="more-menu">
|
||||
<Icon icon="ellipsis-h" />
|
||||
<Icon icon={faEllipsisH} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="bg-secondary text-white">
|
||||
{options}
|
||||
|
|
@ -187,7 +192,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
|
|||
<>
|
||||
{maybeRenderButtons()}
|
||||
|
||||
<div className="mx-2">{renderMore()}</div>
|
||||
<div className="mx-2 mb-2">{renderMore()}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ import {
|
|||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
import { useIntl } from "react-intl";
|
||||
import { Icon } from "../Shared";
|
||||
import {
|
||||
faList,
|
||||
faSquare,
|
||||
faTags,
|
||||
faThLarge,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IListViewOptionsProps {
|
||||
zoomIndex?: number;
|
||||
|
|
@ -71,13 +77,13 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
|
|||
function getIcon(option: DisplayMode) {
|
||||
switch (option) {
|
||||
case DisplayMode.Grid:
|
||||
return "th-large";
|
||||
return faThLarge;
|
||||
case DisplayMode.List:
|
||||
return "list";
|
||||
return faList;
|
||||
case DisplayMode.Wall:
|
||||
return "square";
|
||||
return faSquare;
|
||||
case DisplayMode.Tagger:
|
||||
return "tags";
|
||||
return faTags;
|
||||
}
|
||||
}
|
||||
function getLabel(option: DisplayMode) {
|
||||
|
|
@ -104,7 +110,7 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<ButtonGroup className="mb-2">
|
||||
{displayModeOptions.map((option) => (
|
||||
<OverlayTrigger
|
||||
key={option}
|
||||
|
|
@ -134,9 +140,9 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
|
|||
function maybeRenderZoom() {
|
||||
if (onSetZoom && displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<div className="align-middle">
|
||||
<div className="ml-2 mb-2 d-none d-sm-inline-flex">
|
||||
<Form.Control
|
||||
className="zoom-slider d-none d-sm-inline-flex ml-3"
|
||||
className="zoom-slider ml-1"
|
||||
type="range"
|
||||
min={minZoom}
|
||||
max={maxZoom}
|
||||
|
|
@ -152,7 +158,7 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<ButtonGroup>{maybeRenderDisplayModeOptions()}</ButtonGroup>
|
||||
{maybeRenderDisplayModeOptions()}
|
||||
{maybeRenderZoom()}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { LoadingIndicator } from "src/components/Shared";
|
|||
import { PersistanceLevel } from "src/hooks/ListHook";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Icon } from "../Shared";
|
||||
import { faSave, faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface ISavedFilterListProps {
|
||||
filter: ListFilterModel;
|
||||
|
|
@ -191,7 +192,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
|||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Icon icon="save" />
|
||||
<Icon icon={faSave} />
|
||||
</Button>
|
||||
<Button
|
||||
className="delete-button"
|
||||
|
|
@ -203,7 +204,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
|||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Icon icon="times" />
|
||||
<Icon icon={faTimes} />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
|
@ -291,7 +292,9 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
|||
<ul className="saved-filter-list">
|
||||
{savedFilters
|
||||
.filter(
|
||||
(f) => !filterName || f.name.toLowerCase().includes(filterName)
|
||||
(f) =>
|
||||
!filterName ||
|
||||
f.name.toLowerCase().includes(filterName.toLowerCase())
|
||||
)
|
||||
.map((f) => (
|
||||
<SavedFilterItem key={f.name} item={f} />
|
||||
|
|
@ -344,7 +347,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
|||
onSaveFilter(filterName);
|
||||
}}
|
||||
>
|
||||
<Icon icon="save" />
|
||||
<Icon icon={faSave} />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
</InputGroup.Append>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ input[type="range"].zoom-slider {
|
|||
padding-right: 0;
|
||||
}
|
||||
|
||||
.query-text-field-group {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.query-text-field {
|
||||
border: 0;
|
||||
width: 50%;
|
||||
|
|
@ -41,7 +48,7 @@ input[type="range"].zoom-slider {
|
|||
margin: $btn-padding-y $btn-padding-x;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
right: 3em;
|
||||
right: 0;
|
||||
z-index: 4;
|
||||
|
||||
&:hover,
|
||||
|
|
|
|||
|
|
@ -6,22 +6,38 @@ import {
|
|||
useIntl,
|
||||
} from "react-intl";
|
||||
import { Nav, Navbar, Button, Fade } from "react-bootstrap";
|
||||
import { IconName } from "@fortawesome/fontawesome-svg-core";
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { LinkContainer } from "react-router-bootstrap";
|
||||
import { Link, NavLink, useLocation, useHistory } from "react-router-dom";
|
||||
import Mousetrap from "mousetrap";
|
||||
|
||||
import { SessionUtils } from "src/utils";
|
||||
import { Icon } from "src/components/Shared";
|
||||
import Icon from "src/components/Shared/Icon";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { ManualStateContext } from "./Help/Manual";
|
||||
import { ManualStateContext } from "./Help/context";
|
||||
import { SettingsButton } from "./SettingsButton";
|
||||
import {
|
||||
faBars,
|
||||
faChartBar,
|
||||
faFilm,
|
||||
faHeart,
|
||||
faImage,
|
||||
faImages,
|
||||
faMapMarkerAlt,
|
||||
faPlayCircle,
|
||||
faQuestionCircle,
|
||||
faSignOutAlt,
|
||||
faTag,
|
||||
faTimes,
|
||||
faUser,
|
||||
faVideo,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IMenuItem {
|
||||
name: string;
|
||||
message: MessageDescriptor;
|
||||
href: string;
|
||||
icon: IconName;
|
||||
icon: IconDefinition;
|
||||
hotkey: string;
|
||||
userCreatable?: boolean;
|
||||
}
|
||||
|
|
@ -77,21 +93,21 @@ const allMenuItems: IMenuItem[] = [
|
|||
name: "scenes",
|
||||
message: messages.scenes,
|
||||
href: "/scenes",
|
||||
icon: "play-circle",
|
||||
icon: faPlayCircle,
|
||||
hotkey: "g s",
|
||||
},
|
||||
{
|
||||
name: "images",
|
||||
message: messages.images,
|
||||
href: "/images",
|
||||
icon: "image",
|
||||
icon: faImage,
|
||||
hotkey: "g i",
|
||||
},
|
||||
{
|
||||
name: "movies",
|
||||
message: messages.movies,
|
||||
href: "/movies",
|
||||
icon: "film",
|
||||
icon: faFilm,
|
||||
hotkey: "g v",
|
||||
userCreatable: true,
|
||||
},
|
||||
|
|
@ -99,14 +115,14 @@ const allMenuItems: IMenuItem[] = [
|
|||
name: "markers",
|
||||
message: messages.markers,
|
||||
href: "/scenes/markers",
|
||||
icon: "map-marker-alt",
|
||||
icon: faMapMarkerAlt,
|
||||
hotkey: "g k",
|
||||
},
|
||||
{
|
||||
name: "galleries",
|
||||
message: messages.galleries,
|
||||
href: "/galleries",
|
||||
icon: "images",
|
||||
icon: faImages,
|
||||
hotkey: "g l",
|
||||
userCreatable: true,
|
||||
},
|
||||
|
|
@ -114,7 +130,7 @@ const allMenuItems: IMenuItem[] = [
|
|||
name: "performers",
|
||||
message: messages.performers,
|
||||
href: "/performers",
|
||||
icon: "user",
|
||||
icon: faUser,
|
||||
hotkey: "g p",
|
||||
userCreatable: true,
|
||||
},
|
||||
|
|
@ -122,7 +138,7 @@ const allMenuItems: IMenuItem[] = [
|
|||
name: "studios",
|
||||
message: messages.studios,
|
||||
href: "/studios",
|
||||
icon: "video",
|
||||
icon: faVideo,
|
||||
hotkey: "g u",
|
||||
userCreatable: true,
|
||||
},
|
||||
|
|
@ -130,7 +146,7 @@ const allMenuItems: IMenuItem[] = [
|
|||
name: "tags",
|
||||
message: messages.tags,
|
||||
href: "/tags",
|
||||
icon: "tag",
|
||||
icon: faTag,
|
||||
hotkey: "g t",
|
||||
userCreatable: true,
|
||||
},
|
||||
|
|
@ -236,7 +252,7 @@ export const MainNavbar: React.FC = () => {
|
|||
href="/logout"
|
||||
title={intl.formatMessage({ id: "actions.logout" })}
|
||||
>
|
||||
<Icon icon="sign-out-alt" />
|
||||
<Icon icon={faSignOutAlt} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -257,7 +273,7 @@ export const MainNavbar: React.FC = () => {
|
|||
className="minimal donate"
|
||||
title={intl.formatMessage({ id: "donate" })}
|
||||
>
|
||||
<Icon icon="heart" />
|
||||
<Icon icon={faHeart} />
|
||||
<span className="d-none d-sm-inline">
|
||||
{intl.formatMessage(messages.donate)}
|
||||
</span>
|
||||
|
|
@ -273,7 +289,7 @@ export const MainNavbar: React.FC = () => {
|
|||
className="minimal d-flex align-items-center h-100"
|
||||
title={intl.formatMessage({ id: "statistics" })}
|
||||
>
|
||||
<Icon icon="chart-bar" />
|
||||
<Icon icon={faChartBar} />
|
||||
</Button>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
|
|
@ -289,7 +305,7 @@ export const MainNavbar: React.FC = () => {
|
|||
onClick={() => openManual()}
|
||||
title={intl.formatMessage({ id: "help" })}
|
||||
>
|
||||
<Icon icon="question-circle" />
|
||||
<Icon icon={faQuestionCircle} />
|
||||
</Button>
|
||||
{maybeRenderLogout()}
|
||||
</>
|
||||
|
|
@ -355,7 +371,7 @@ export const MainNavbar: React.FC = () => {
|
|||
)}
|
||||
{renderUtilityButtons()}
|
||||
<Navbar.Toggle className="nav-menu-toggle ml-sm-2">
|
||||
<Icon icon={expanded ? "times" : "bars"} />
|
||||
<Icon icon={expanded ? faTimes : faBars} />
|
||||
</Navbar.Toggle>
|
||||
</Nav>
|
||||
</Navbar>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
getAggregateRating,
|
||||
getAggregateStudioId,
|
||||
} from "src/utils/bulkUpdate";
|
||||
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IListOperationProps {
|
||||
selected: GQL.MovieDataFragment[];
|
||||
|
|
@ -101,7 +102,7 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
|
|||
return (
|
||||
<Modal
|
||||
show
|
||||
icon="pencil-alt"
|
||||
icon={faPencilAlt}
|
||||
header={intl.formatMessage(
|
||||
{ id: "actions.edit_entity" },
|
||||
{ entityType: intl.formatMessage({ id: "movies" }) }
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from "src/components/Shared";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { RatingBanner } from "../Shared/RatingBanner";
|
||||
import { faPlayCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IProps {
|
||||
movie: GQL.MovieDataFragment;
|
||||
|
|
@ -47,7 +48,7 @@ export const MovieCard: FunctionComponent<IProps> = (props: IProps) => {
|
|||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon="play-circle" />
|
||||
<Icon icon={faPlayCircle} />
|
||||
<span>{props.movie.scenes.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { useToast } from "src/hooks";
|
|||
import { MovieScenesPanel } from "./MovieScenesPanel";
|
||||
import { MovieDetailsPanel } from "./MovieDetailsPanel";
|
||||
import { MovieEditPanel } from "./MovieEditPanel";
|
||||
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IProps {
|
||||
movie: GQL.MovieDataFragment;
|
||||
|
|
@ -110,7 +111,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||
return (
|
||||
<Modal
|
||||
show={isDeleteAlertOpen}
|
||||
icon="trash-alt"
|
||||
icon={faTrashAlt}
|
||||
accept={{
|
||||
text: intl.formatMessage({ id: "actions.delete" }),
|
||||
variant: "danger",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import _ from "lodash";
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import Mousetrap from "mousetrap";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import {
|
||||
|
|
@ -103,7 +103,7 @@ export const MovieList: React.FC<IMovieList> = ({ filterHook }) => {
|
|||
const { count } = result.data.findMovies;
|
||||
|
||||
const index = Math.floor(Math.random() * count);
|
||||
const filterCopy = _.cloneDeep(filter);
|
||||
const filterCopy = cloneDeep(filter);
|
||||
filterCopy.itemsPerPage = 1;
|
||||
filterCopy.currentPage = index + 1;
|
||||
const singleResult = await queryFindMovies(filterCopy);
|
||||
|
|
|
|||
|
|
@ -1,37 +1,50 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { FindMoviesQueryResult } from "src/core/generated-graphql";
|
||||
import React from "react";
|
||||
import { useFindMovies } from "src/core/StashService";
|
||||
import Slider from "react-slick";
|
||||
import { MovieCard } from "./MovieCard";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { getSlickSliderSettings } from "src/core/recommendations";
|
||||
import { RecommendationRow } from "../FrontPage/RecommendationRow";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
filter: ListFilterModel;
|
||||
result: FindMoviesQueryResult;
|
||||
header: String;
|
||||
linkText: String;
|
||||
header: string;
|
||||
}
|
||||
|
||||
export const MovieRecommendationRow: FunctionComponent<IProps> = (
|
||||
props: IProps
|
||||
) => {
|
||||
const cardCount = props.result.data?.findMovies.count;
|
||||
export const MovieRecommendationRow: React.FC<IProps> = (props: IProps) => {
|
||||
const result = useFindMovies(props.filter);
|
||||
const cardCount = result.data?.findMovies.count;
|
||||
|
||||
if (!result.loading && !cardCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recommendation-row movie-recommendations">
|
||||
<div className="recommendation-row-head">
|
||||
<div>
|
||||
<h2>{props.header}</h2>
|
||||
</div>
|
||||
<RecommendationRow
|
||||
className="movie-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<a href={`/movies?${props.filter.makeQueryParameters()}`}>
|
||||
{props.linkText}
|
||||
<FormattedMessage id="view_all" />
|
||||
</a>
|
||||
</div>
|
||||
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}>
|
||||
{props.result.data?.findMovies.movies.map((p) => (
|
||||
<MovieCard key={p.id} movie={p} />
|
||||
))}
|
||||
}
|
||||
>
|
||||
<Slider
|
||||
{...getSlickSliderSettings(
|
||||
cardCount ? cardCount : props.filter.itemsPerPage,
|
||||
props.isTouch
|
||||
)}
|
||||
>
|
||||
{result.loading
|
||||
? [...Array(props.filter.itemsPerPage)].map((i) => (
|
||||
<div key={`_${i}`} className="movie-skeleton skeleton-card"></div>
|
||||
))
|
||||
: result.data?.findMovies.movies.map((m) => (
|
||||
<MovieCard key={m.id} movie={m} />
|
||||
))}
|
||||
</Slider>
|
||||
</div>
|
||||
</RecommendationRow>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
} from "src/utils/gender";
|
||||
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
|
||||
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
|
||||
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IListOperationProps {
|
||||
selected: GQL.SlimPerformerDataFragment[];
|
||||
|
|
@ -183,7 +184,7 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
|
|||
return (
|
||||
<Modal
|
||||
show
|
||||
icon="pencil-alt"
|
||||
icon={faPencilAlt}
|
||||
header={intl.formatMessage(
|
||||
{ id: "actions.edit_entity" },
|
||||
{ entityType: intl.formatMessage({ id: "performers" }) }
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from "src/models/list-filter/criteria/criterion";
|
||||
import { PopoverCountButton } from "../Shared/PopoverCountButton";
|
||||
import GenderIcon from "./GenderIcon";
|
||||
import { faHeart, faTag } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export interface IPerformerCardExtraCriteria {
|
||||
scenes: Criterion<CriterionValue>[];
|
||||
|
|
@ -65,7 +66,7 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
|
|||
}
|
||||
return (
|
||||
<div className="favorite">
|
||||
<Icon icon="heart" size="2x" />
|
||||
<Icon icon={faHeart} size="2x" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -122,7 +123,7 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
|
|||
return (
|
||||
<HoverPopover placement="bottom" content={popoverContent}>
|
||||
<Button className="minimal tag-count">
|
||||
<Icon icon="tag" />
|
||||
<Icon icon={faTag} />
|
||||
<span>{performer.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ import { PerformerImagesPanel } from "./PerformerImagesPanel";
|
|||
import { PerformerEditPanel } from "./PerformerEditPanel";
|
||||
import { PerformerSubmitButton } from "./PerformerSubmitButton";
|
||||
import GenderIcon from "../GenderIcon";
|
||||
import {
|
||||
faCamera,
|
||||
faDove,
|
||||
faHeart,
|
||||
faLink,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IProps {
|
||||
performer: GQL.PerformerDataFragment;
|
||||
|
|
@ -325,7 +331,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||
)}
|
||||
onClick={() => setFavorite(!performer.favorite)}
|
||||
>
|
||||
<Icon icon="heart" />
|
||||
<Icon icon={faHeart} />
|
||||
</Button>
|
||||
{performer.url && (
|
||||
<Button className="minimal icon-link">
|
||||
|
|
@ -335,7 +341,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon="link" />
|
||||
<Icon icon={faLink} />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -350,7 +356,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon="dove" />
|
||||
<Icon icon={faDove} />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -365,7 +371,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon="camera" />
|
||||
<Icon icon={faCamera} />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ import { PerformerScrapeDialog } from "./PerformerScrapeDialog";
|
|||
import PerformerScrapeModal from "./PerformerScrapeModal";
|
||||
import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal";
|
||||
import cx from "classnames";
|
||||
import {
|
||||
faPlus,
|
||||
faSyncAlt,
|
||||
faTrashAlt,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const isScraper = (
|
||||
scraper: GQL.Scraper | GQL.StashBox
|
||||
|
|
@ -192,7 +197,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
>
|
||||
{t.name}
|
||||
<Button className="minimal ml-2">
|
||||
<Icon className="fa-fw" icon="plus" />
|
||||
<Icon className="fa-fw" icon={faPlus} />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
|
|
@ -594,7 +599,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
onClick={() => onReloadScrapers()}
|
||||
>
|
||||
<span className="fa-icon">
|
||||
<Icon icon="sync-alt" />
|
||||
<Icon icon={faSyncAlt} />
|
||||
</span>
|
||||
<span>
|
||||
<FormattedMessage id="actions.reload_scrapers" />
|
||||
|
|
@ -781,7 +786,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
title={intl.formatMessage({ id: "actions.delete_stashid" })}
|
||||
onClick={() => removeStashID(stashID)}
|
||||
>
|
||||
<Icon icon="trash-alt" />
|
||||
<Icon icon={faTrashAlt} />
|
||||
</Button>
|
||||
{link}
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { useTagCreate } from "src/core/StashService";
|
|||
import { Form } from "react-bootstrap";
|
||||
import { TagSelect } from "src/components/Shared";
|
||||
import { useToast } from "src/hooks";
|
||||
import _ from "lodash";
|
||||
import clone from "lodash-es/clone";
|
||||
import {
|
||||
genderStrings,
|
||||
genderToString,
|
||||
|
|
@ -285,7 +285,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
|
|||
return;
|
||||
}
|
||||
|
||||
const ret = _.clone(idList);
|
||||
const ret = clone(idList);
|
||||
// sort by id numerically
|
||||
ret.sort((a, b) => {
|
||||
return parseInt(a, 10) - parseInt(b, 10);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { debounce } from "lodash";
|
||||
import debounce from "lodash-es/debounce";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { debounce } from "lodash";
|
||||
import debounce from "lodash-es/debounce";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import _ from "lodash";
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import React, { useState } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
|
@ -138,7 +138,7 @@ export const PerformerList: React.FC<IPerformerList> = ({
|
|||
if (result.data?.findPerformers) {
|
||||
const { count } = result.data.findPerformers;
|
||||
const index = Math.floor(Math.random() * count);
|
||||
const filterCopy = _.cloneDeep(filter);
|
||||
const filterCopy = cloneDeep(filter);
|
||||
filterCopy.itemsPerPage = 1;
|
||||
filterCopy.currentPage = index + 1;
|
||||
const singleResult = await queryFindPerformers(filterCopy);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Link } from "react-router-dom";
|
|||
import * as GQL from "src/core/generated-graphql";
|
||||
import { Icon } from "src/components/Shared";
|
||||
import { NavUtils } from "src/utils";
|
||||
import { faHeart } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface IPerformerListTableProps {
|
||||
performers: GQL.PerformerDataFragment[];
|
||||
|
|
@ -37,7 +38,7 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (
|
|||
<td>
|
||||
{performer.favorite && (
|
||||
<Button disabled className="favorite">
|
||||
<Icon icon="heart" />
|
||||
<Icon icon={faHeart} />
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,55 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { FindPerformersQueryResult } from "src/core/generated-graphql";
|
||||
import { useFindPerformers } from "src/core/StashService";
|
||||
import Slider from "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";
|
||||
|
||||
interface IProps {
|
||||
isTouch: boolean;
|
||||
filter: ListFilterModel;
|
||||
result: FindPerformersQueryResult;
|
||||
header: String;
|
||||
linkText: String;
|
||||
header: string;
|
||||
}
|
||||
|
||||
export const PerformerRecommendationRow: FunctionComponent<IProps> = (
|
||||
props: IProps
|
||||
) => {
|
||||
const cardCount = props.result.data?.findPerformers.count;
|
||||
const result = useFindPerformers(props.filter);
|
||||
const cardCount = result.data?.findPerformers.count;
|
||||
|
||||
if (!result.loading && !cardCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recommendation-row performer-recommendations">
|
||||
<div className="recommendation-row-head">
|
||||
<div>
|
||||
<h2>{props.header}</h2>
|
||||
</div>
|
||||
<RecommendationRow
|
||||
className="performer-recommendations"
|
||||
header={props.header}
|
||||
link={
|
||||
<a href={`/performers?${props.filter.makeQueryParameters()}`}>
|
||||
{props.linkText}
|
||||
<FormattedMessage id="view_all" />
|
||||
</a>
|
||||
</div>
|
||||
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}>
|
||||
{props.result.data?.findPerformers.performers.map((p) => (
|
||||
<PerformerCard key={p.id} performer={p} />
|
||||
))}
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</RecommendationRow>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
import * as GQL from "src/core/generated-graphql";
|
||||
import { defineMessages, useIntl } from "react-intl";
|
||||
import React from "react";
|
||||
import {
|
||||
useFindScenes,
|
||||
useFindMovies,
|
||||
useFindStudios,
|
||||
useFindGalleries,
|
||||
useFindPerformers,
|
||||
} from "src/core/StashService";
|
||||
import { SceneRecommendationRow } from "src/components/Scenes/SceneRecommendationRow";
|
||||
import { StudioRecommendationRow } from "src/components/Studios/StudioRecommendationRow";
|
||||
import { MovieRecommendationRow } from "src/components/Movies/MovieRecommendationRow";
|
||||
import { PerformerRecommendationRow } from "src/components/Performers/PerformerRecommendationRow";
|
||||
import { GalleryRecommendationRow } from "src/components/Galleries/GalleryRecommendationRow";
|
||||
import { SceneQueue } from "src/models/sceneQueue";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { LoadingIndicator } from "src/components/Shared";
|
||||
|
||||
const Recommendations: React.FC = () => {
|
||||
function isTouchEnabled() {
|
||||
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
const isTouch = isTouchEnabled();
|
||||
|
||||
const intl = useIntl();
|
||||
const itemsPerPage = 25;
|
||||
const scenefilter = new ListFilterModel(GQL.FilterMode.Scenes);
|
||||
scenefilter.sortBy = "date";
|
||||
scenefilter.sortDirection = GQL.SortDirectionEnum.Desc;
|
||||
scenefilter.itemsPerPage = itemsPerPage;
|
||||
const sceneResult = useFindScenes(scenefilter);
|
||||
const hasScenes = !!sceneResult?.data?.findScenes?.count;
|
||||
|
||||
const studiofilter = new ListFilterModel(GQL.FilterMode.Studios);
|
||||
studiofilter.sortBy = "created_at";
|
||||
studiofilter.sortDirection = GQL.SortDirectionEnum.Desc;
|
||||
studiofilter.itemsPerPage = itemsPerPage;
|
||||
const studioResult = useFindStudios(studiofilter);
|
||||
const hasStudios = !!studioResult?.data?.findStudios?.count;
|
||||
|
||||
const moviefilter = new ListFilterModel(GQL.FilterMode.Movies);
|
||||
moviefilter.sortBy = "date";
|
||||
moviefilter.sortDirection = GQL.SortDirectionEnum.Desc;
|
||||
moviefilter.itemsPerPage = itemsPerPage;
|
||||
const movieResult = useFindMovies(moviefilter);
|
||||
const hasMovies = !!movieResult?.data?.findMovies?.count;
|
||||
|
||||
const performerfilter = new ListFilterModel(GQL.FilterMode.Performers);
|
||||
performerfilter.sortBy = "created_at";
|
||||
performerfilter.sortDirection = GQL.SortDirectionEnum.Desc;
|
||||
performerfilter.itemsPerPage = itemsPerPage;
|
||||
const performerResult = useFindPerformers(performerfilter);
|
||||
const hasPerformers = !!performerResult?.data?.findPerformers?.count;
|
||||
|
||||
const galleryfilter = new ListFilterModel(GQL.FilterMode.Galleries);
|
||||
galleryfilter.sortBy = "date";
|
||||
galleryfilter.sortDirection = GQL.SortDirectionEnum.Desc;
|
||||
galleryfilter.itemsPerPage = itemsPerPage;
|
||||
const galleryResult = useFindGalleries(galleryfilter);
|
||||
const hasGalleries = !!galleryResult?.data?.findGalleries?.count;
|
||||
|
||||
const messages = defineMessages({
|
||||
emptyServer: {
|
||||
id: "empty_server",
|
||||
defaultMessage:
|
||||
"Add some scenes to your server to view recommendations on this page.",
|
||||
},
|
||||
recentlyAddedStudios: {
|
||||
id: "recently_added_studios",
|
||||
defaultMessage: "Recently Added Studios",
|
||||
},
|
||||
recentlyAddedPerformers: {
|
||||
id: "recently_added_performers",
|
||||
defaultMessage: "Recently Added Performers",
|
||||
},
|
||||
recentlyReleasedGalleries: {
|
||||
id: "recently_released_galleries",
|
||||
defaultMessage: "Recently Released Galleries",
|
||||
},
|
||||
recentlyReleasedMovies: {
|
||||
id: "recently_released_movies",
|
||||
defaultMessage: "Recently Released Movies",
|
||||
},
|
||||
recentlyReleasedScenes: {
|
||||
id: "recently_released_scenes",
|
||||
defaultMessage: "Recently Released Scenes",
|
||||
},
|
||||
viewAll: {
|
||||
id: "view_all",
|
||||
defaultMessage: "View All",
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
sceneResult.loading ||
|
||||
studioResult.loading ||
|
||||
movieResult.loading ||
|
||||
performerResult.loading ||
|
||||
galleryResult.loading
|
||||
) {
|
||||
return <LoadingIndicator />;
|
||||
} else {
|
||||
return (
|
||||
<div className="recommendations-container">
|
||||
{!hasScenes &&
|
||||
!hasStudios &&
|
||||
!hasMovies &&
|
||||
!hasPerformers &&
|
||||
!hasGalleries ? (
|
||||
<div className="no-recommendations">
|
||||
{intl.formatMessage(messages.emptyServer)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{hasScenes && (
|
||||
<SceneRecommendationRow
|
||||
isTouch={isTouch}
|
||||
filter={scenefilter}
|
||||
result={sceneResult}
|
||||
queue={SceneQueue.fromListFilterModel(scenefilter)}
|
||||
header={intl.formatMessage(messages.recentlyReleasedScenes)}
|
||||
linkText={intl.formatMessage(messages.viewAll)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasStudios && (
|
||||
<StudioRecommendationRow
|
||||
isTouch={isTouch}
|
||||
filter={studiofilter}
|
||||
result={studioResult}
|
||||
header={intl.formatMessage(messages.recentlyAddedStudios)}
|
||||
linkText={intl.formatMessage(messages.viewAll)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasMovies && (
|
||||
<MovieRecommendationRow
|
||||
isTouch={isTouch}
|
||||
filter={moviefilter}
|
||||
result={movieResult}
|
||||
header={intl.formatMessage(messages.recentlyReleasedMovies)}
|
||||
linkText={intl.formatMessage(messages.viewAll)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasPerformers && (
|
||||
<PerformerRecommendationRow
|
||||
isTouch={isTouch}
|
||||
filter={performerfilter}
|
||||
result={performerResult}
|
||||
header={intl.formatMessage(messages.recentlyAddedPerformers)}
|
||||
linkText={intl.formatMessage(messages.viewAll)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasGalleries && (
|
||||
<GalleryRecommendationRow
|
||||
isTouch={isTouch}
|
||||
filter={galleryfilter}
|
||||
result={galleryResult}
|
||||
header={intl.formatMessage(messages.recentlyReleasedGalleries)}
|
||||
linkText={intl.formatMessage(messages.viewAll)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Recommendations;
|
||||
|
|
@ -28,6 +28,16 @@ import { TextUtils } from "src/utils";
|
|||
import { DeleteScenesDialog } from "src/components/Scenes/DeleteScenesDialog";
|
||||
import { EditScenesDialog } from "../Scenes/EditScenesDialog";
|
||||
import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton";
|
||||
import {
|
||||
faBox,
|
||||
faExclamationTriangle,
|
||||
faFilm,
|
||||
faImages,
|
||||
faMapMarkerAlt,
|
||||
faPencilAlt,
|
||||
faTag,
|
||||
faTrash,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const CLASSNAME = "duplicate-checker";
|
||||
|
||||
|
|
@ -144,7 +154,7 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||
if (missingPhashes > 0) {
|
||||
return (
|
||||
<p className="lead">
|
||||
<Icon icon="exclamation-triangle" className="text-warning" />
|
||||
<Icon icon={faExclamationTriangle} className="text-warning" />
|
||||
Missing phashes for {missingPhashes} scenes. Please run the phash
|
||||
generation task.
|
||||
</p>
|
||||
|
|
@ -173,7 +183,7 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||
return (
|
||||
<HoverPopover placement="bottom" content={popoverContent}>
|
||||
<Button className="minimal">
|
||||
<Icon icon="tag" />
|
||||
<Icon icon={faTag} />
|
||||
<span>{scene.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
|
|
@ -216,7 +226,7 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||
className="tag-tooltip"
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon="film" />
|
||||
<Icon icon={faFilm} />
|
||||
<span>{scene.movies.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
|
|
@ -236,7 +246,7 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||
return (
|
||||
<HoverPopover placement="bottom" content={popoverContent}>
|
||||
<Button className="minimal">
|
||||
<Icon icon="map-marker-alt" />
|
||||
<Icon icon={faMapMarkerAlt} />
|
||||
<span>{scene.scene_markers.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
|
|
@ -268,7 +278,7 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||
return (
|
||||
<HoverPopover placement="bottom" content={popoverContent}>
|
||||
<Button className="minimal">
|
||||
<Icon icon="images" />
|
||||
<Icon icon={faImages} />
|
||||
<span>{scene.galleries.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
|
|
@ -280,7 +290,7 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||
return (
|
||||
<div>
|
||||
<Button className="minimal">
|
||||
<Icon icon="box" />
|
||||
<Icon icon={faBox} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -332,7 +342,7 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||
}
|
||||
>
|
||||
<Button variant="secondary" onClick={onEdit}>
|
||||
<Icon icon="pencil-alt" />
|
||||
<Icon icon={faPencilAlt} />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
<OverlayTrigger
|
||||
|
|
@ -343,7 +353,7 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||
}
|
||||
>
|
||||
<Button variant="danger" onClick={handleDeleteChecked}>
|
||||
<Icon icon="trash" />
|
||||
<Icon icon={faTrash} />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
</ButtonGroup>
|
||||
|
|
@ -550,3 +560,5 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SceneDuplicateChecker;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { Button, Card, Form, Table } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import _ from "lodash";
|
||||
import clone from "lodash-es/clone";
|
||||
import {
|
||||
queryParseSceneFilenames,
|
||||
useScenesUpdate,
|
||||
|
|
@ -155,7 +155,7 @@ export const SceneFilenameParser: React.FC = () => {
|
|||
}, [parserInput, parseSceneFilenames, prevParserInput]);
|
||||
|
||||
function onPageSizeChanged(newSize: number) {
|
||||
const newInput = _.clone(parserInput);
|
||||
const newInput = clone(parserInput);
|
||||
newInput.page = 1;
|
||||
newInput.pageSize = newSize;
|
||||
setParserInput(newInput);
|
||||
|
|
@ -163,14 +163,14 @@ export const SceneFilenameParser: React.FC = () => {
|
|||
|
||||
function onPageChanged(newPage: number) {
|
||||
if (newPage !== parserInput.page) {
|
||||
const newInput = _.clone(parserInput);
|
||||
const newInput = clone(parserInput);
|
||||
newInput.page = newPage;
|
||||
setParserInput(newInput);
|
||||
}
|
||||
}
|
||||
|
||||
function onFindClicked(input: IParserInput) {
|
||||
const newInput = _.clone(input);
|
||||
const newInput = clone(input);
|
||||
newInput.page = 1;
|
||||
newInput.findClicked = true;
|
||||
setParserInput(newInput);
|
||||
|
|
@ -423,3 +423,5 @@ export const SceneFilenameParser: React.FC = () => {
|
|||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SceneFilenameParser;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import isEqual from "lodash-es/isEqual";
|
||||
import clone from "lodash-es/clone";
|
||||
import { Form } from "react-bootstrap";
|
||||
import {
|
||||
ParseSceneFilenamesQuery,
|
||||
|
|
@ -26,7 +27,7 @@ class ParserResult<T> {
|
|||
public setValue(value?: T) {
|
||||
if (value) {
|
||||
this.value = value;
|
||||
this.isSet = !_.isEqual(this.value, this.originalValue);
|
||||
this.isSet = !isEqual(this.value, this.originalValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -332,44 +333,44 @@ interface ISceneParserRowProps {
|
|||
|
||||
export const SceneParserRow = (props: ISceneParserRowProps) => {
|
||||
function changeParser<T>(result: ParserResult<T>, isSet: boolean, value?: T) {
|
||||
const newParser = _.clone(result);
|
||||
const newParser = clone(result);
|
||||
newParser.isSet = isSet;
|
||||
newParser.value = value;
|
||||
return newParser;
|
||||
}
|
||||
|
||||
function onTitleChanged(set: boolean, value: string) {
|
||||
const newResult = _.clone(props.scene);
|
||||
const newResult = clone(props.scene);
|
||||
newResult.title = changeParser(newResult.title, set, value);
|
||||
props.onChange(newResult);
|
||||
}
|
||||
|
||||
function onDateChanged(set: boolean, value: string) {
|
||||
const newResult = _.clone(props.scene);
|
||||
const newResult = clone(props.scene);
|
||||
newResult.date = changeParser(newResult.date, set, value);
|
||||
props.onChange(newResult);
|
||||
}
|
||||
|
||||
function onRatingChanged(set: boolean, value?: number) {
|
||||
const newResult = _.clone(props.scene);
|
||||
const newResult = clone(props.scene);
|
||||
newResult.rating = changeParser(newResult.rating, set, value);
|
||||
props.onChange(newResult);
|
||||
}
|
||||
|
||||
function onPerformerIdsChanged(set: boolean, value: string[]) {
|
||||
const newResult = _.clone(props.scene);
|
||||
const newResult = clone(props.scene);
|
||||
newResult.performers = changeParser(newResult.performers, set, value);
|
||||
props.onChange(newResult);
|
||||
}
|
||||
|
||||
function onTagIdsChanged(set: boolean, value: string[]) {
|
||||
const newResult = _.clone(props.scene);
|
||||
const newResult = clone(props.scene);
|
||||
newResult.tags = changeParser(newResult.tags, set, value);
|
||||
props.onChange(newResult);
|
||||
}
|
||||
|
||||
function onStudioIdChanged(set: boolean, value: string) {
|
||||
const newResult = _.clone(props.scene);
|
||||
const newResult = clone(props.scene);
|
||||
newResult.studio = changeParser(newResult.studio, set, value);
|
||||
props.onChange(newResult);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import {
|
||||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronRight,
|
||||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, { useState } from "react";
|
||||
import { Button, Collapse } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
|
|
@ -26,7 +32,7 @@ export const ShowFields = (props: IShowFieldsProps) => {
|
|||
handleClick(label);
|
||||
}}
|
||||
>
|
||||
<Icon icon={enabled ? "check" : "times"} />
|
||||
<Icon icon={enabled ? faCheck : faTimes} />
|
||||
<span>{label}</span>
|
||||
</Button>
|
||||
));
|
||||
|
|
@ -34,7 +40,7 @@ export const ShowFields = (props: IShowFieldsProps) => {
|
|||
return (
|
||||
<div>
|
||||
<Button onClick={() => setOpen(!open)} className="minimal">
|
||||
<Icon icon={open ? "chevron-down" : "chevron-right"} />
|
||||
<Icon icon={open ? faChevronDown : faChevronRight} />
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
id: "config.tools.scene_filename_parser.display_fields",
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@ import {
|
|||
} from "src/hooks/Interactive/context";
|
||||
import { SceneInteractiveStatus } from "src/hooks/Interactive/status";
|
||||
import { languageMap } from "src/utils/caption";
|
||||
|
||||
export const VIDEO_PLAYER_ID = "VideoJsPlayer";
|
||||
import { VIDEO_PLAYER_ID } from "./util";
|
||||
|
||||
function handleHotkeys(player: VideoJsPlayer, event: VideoJS.KeyboardEvent) {
|
||||
function seekPercent(percent: number) {
|
||||
|
|
@ -37,6 +36,14 @@ function handleHotkeys(player: VideoJsPlayer, event: VideoJS.KeyboardEvent) {
|
|||
player.currentTime(time);
|
||||
}
|
||||
|
||||
function seekPercentRelative(percent: number) {
|
||||
const duration = player.duration();
|
||||
const currentTime = player.currentTime();
|
||||
const time = currentTime + duration * percent;
|
||||
if (time > duration) return;
|
||||
player.currentTime(time);
|
||||
}
|
||||
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -96,6 +103,12 @@ function handleHotkeys(player: VideoJsPlayer, event: VideoJS.KeyboardEvent) {
|
|||
case 57: // 9
|
||||
seekPercent(0.9);
|
||||
break;
|
||||
case 221: // ]
|
||||
seekPercentRelative(0.1);
|
||||
break;
|
||||
case 219: // [
|
||||
seekPercentRelative(-0.1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -185,15 +198,6 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|||
});
|
||||
settings.updateDisplay();
|
||||
|
||||
(player as any).landscapeFullscreen({
|
||||
fullscreen: {
|
||||
enterOnRotate: true,
|
||||
exitOnRotate: true,
|
||||
alwaysInLandscapeMode: true,
|
||||
iOS: false,
|
||||
},
|
||||
});
|
||||
|
||||
(player as any).markers();
|
||||
(player as any).offset();
|
||||
(player as any).sourceSelector();
|
||||
|
|
@ -417,21 +421,100 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// always stop the interactive client on initialisation
|
||||
interactiveClient.pause();
|
||||
interactiveReady.current = false;
|
||||
function loadstart(this: VideoJsPlayer) {
|
||||
// handle offset after loading so that we get the correct current source
|
||||
handleOffset(this);
|
||||
}
|
||||
|
||||
if (!scene || scene.id === sceneId.current) return;
|
||||
sceneId.current = scene.id;
|
||||
function onPlay(this: VideoJsPlayer) {
|
||||
this.poster("");
|
||||
if (scene?.interactive && interactiveReady.current) {
|
||||
interactiveClient.play(this.currentTime());
|
||||
}
|
||||
}
|
||||
|
||||
function pause() {
|
||||
interactiveClient.pause();
|
||||
}
|
||||
|
||||
function timeupdate(this: VideoJsPlayer) {
|
||||
if (scene?.interactive && interactiveReady.current) {
|
||||
interactiveClient.ensurePlaying(this.currentTime());
|
||||
}
|
||||
setTime(this.currentTime());
|
||||
}
|
||||
|
||||
function seeking(this: VideoJsPlayer) {
|
||||
this.play();
|
||||
}
|
||||
|
||||
function error() {
|
||||
handleError(true);
|
||||
}
|
||||
|
||||
// changing source (eg when seeking) resets the playback rate
|
||||
// so set the default in addition to the current rate
|
||||
function ratechange(this: VideoJsPlayer) {
|
||||
this.defaultPlaybackRate(this.playbackRate());
|
||||
}
|
||||
|
||||
function loadedmetadata(this: VideoJsPlayer) {
|
||||
if (!this.videoWidth() && !this.videoHeight()) {
|
||||
// Occurs during preload when videos with supported audio/unsupported video are preloaded.
|
||||
// Treat this as a decoding error and try the next source without playing.
|
||||
// However on Safari we get an media event when m3u8 is loaded which needs to be ignored.
|
||||
const currentFile = this.currentSrc();
|
||||
if (currentFile != null && !currentFile.includes("m3u8")) {
|
||||
// const play = !player.paused();
|
||||
// handleError(play);
|
||||
this.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const player = playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
// always initialise event handlers since these are destroyed when the
|
||||
// component is destroyed
|
||||
player.on("loadstart", loadstart);
|
||||
player.on("play", onPlay);
|
||||
player.on("pause", pause);
|
||||
player.on("timeupdate", timeupdate);
|
||||
player.on("seeking", seeking);
|
||||
player.on("error", error);
|
||||
player.on("ratechange", ratechange);
|
||||
player.on("loadedmetadata", loadedmetadata);
|
||||
|
||||
// don't re-initialise the player unless the scene has changed
|
||||
if (!scene || scene.id === sceneId.current) return;
|
||||
sceneId.current = scene.id;
|
||||
|
||||
// always stop the interactive client on initialisation
|
||||
interactiveClient.pause();
|
||||
interactiveReady.current = false;
|
||||
|
||||
const auto =
|
||||
autoplay || (config?.autostartVideo ?? false) || initialTimestamp > 0;
|
||||
if (!auto && scene.paths?.screenshot) player.poster(scene.paths.screenshot);
|
||||
else player.poster("");
|
||||
|
||||
const isLandscape =
|
||||
scene.file.height &&
|
||||
scene.file.width &&
|
||||
scene.file.width > scene.file.height;
|
||||
|
||||
if (isLandscape) {
|
||||
(player as any).landscapeFullscreen({
|
||||
fullscreen: {
|
||||
enterOnRotate: true,
|
||||
exitOnRotate: true,
|
||||
alwaysInLandscapeMode: true,
|
||||
iOS: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// clear the offset before loading anything new.
|
||||
// otherwise, the offset will be applied to the next file when
|
||||
// currentTime is called.
|
||||
|
|
@ -474,66 +557,6 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|||
player.loop(looping);
|
||||
interactiveClient.setLooping(looping);
|
||||
|
||||
function loadstart(this: VideoJsPlayer) {
|
||||
// handle offset after loading so that we get the correct current source
|
||||
handleOffset(this);
|
||||
}
|
||||
|
||||
player.on("loadstart", loadstart);
|
||||
|
||||
function onPlay(this: VideoJsPlayer) {
|
||||
this.poster("");
|
||||
if (scene?.interactive && interactiveReady.current) {
|
||||
interactiveClient.play(this.currentTime());
|
||||
}
|
||||
}
|
||||
player.on("play", onPlay);
|
||||
|
||||
function pause() {
|
||||
interactiveClient.pause();
|
||||
}
|
||||
player.on("pause", pause);
|
||||
|
||||
function timeupdate(this: VideoJsPlayer) {
|
||||
if (scene?.interactive && interactiveReady.current) {
|
||||
interactiveClient.ensurePlaying(this.currentTime());
|
||||
}
|
||||
setTime(this.currentTime());
|
||||
}
|
||||
player.on("timeupdate", timeupdate);
|
||||
|
||||
function seeking(this: VideoJsPlayer) {
|
||||
this.play();
|
||||
}
|
||||
player.on("seeking", seeking);
|
||||
|
||||
function error() {
|
||||
handleError(true);
|
||||
}
|
||||
player.on("error", error);
|
||||
|
||||
// changing source (eg when seeking) resets the playback rate
|
||||
// so set the default in addition to the current rate
|
||||
function ratechange(this: VideoJsPlayer) {
|
||||
this.defaultPlaybackRate(this.playbackRate());
|
||||
}
|
||||
player.on("ratechange", ratechange);
|
||||
|
||||
function loadedmetadata(this: VideoJsPlayer) {
|
||||
if (!this.videoWidth() && !this.videoHeight()) {
|
||||
// Occurs during preload when videos with supported audio/unsupported video are preloaded.
|
||||
// Treat this as a decoding error and try the next source without playing.
|
||||
// However on Safari we get an media event when m3u8 is loaded which needs to be ignored.
|
||||
const currentFile = this.currentSrc();
|
||||
if (currentFile != null && !currentFile.includes("m3u8")) {
|
||||
// const play = !player.paused();
|
||||
// handleError(play);
|
||||
this.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
player.on("loadedmetadata", loadedmetadata);
|
||||
|
||||
player.load();
|
||||
|
||||
if ((player as any).vttThumbnails?.src)
|
||||
|
|
@ -655,5 +678,4 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const getPlayerPosition = () =>
|
||||
VideoJS.getPlayer(VIDEO_PLAYER_ID).currentTime();
|
||||
export default ScenePlayer;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue