Merge branch 'develop' into feature/studio-image-from-stash-id

This commit is contained in:
RyanAtNight 2026-01-13 20:54:44 -08:00 committed by GitHub
commit 58ba5b9862
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 1798 additions and 937 deletions

11
go.mod
View file

@ -9,8 +9,8 @@ require (
github.com/anacrolix/dms v1.2.2
github.com/antchfx/htmlquery v1.3.5
github.com/asticode/go-astisub v0.25.1
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617
github.com/chromedp/chromedp v0.9.2
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.2
github.com/corona10/goimagehash v1.1.0
github.com/disintegration/imaging v1.6.2
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
@ -72,17 +72,18 @@ require (
github.com/antchfx/xpath v1.3.5 // indirect
github.com/asticode/go-astikit v0.20.0 // indirect
github.com/asticode/go-astits v1.8.0 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.3.0 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
@ -90,10 +91,8 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect

24
go.sum
View file

@ -116,13 +116,12 @@ github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617 h1:/5dwcyi5WOawM1Iz6MjrYqB90TRIdZv3O0fVHEJb86w=
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw=
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
@ -206,6 +205,8 @@ github.com/go-chi/httplog v0.3.1/go.mod h1:UoiQQ/MTZH5V6JbNB2FzF0DynTh5okpXxlhsy
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
@ -224,9 +225,8 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0=
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@ -380,8 +380,6 @@ github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@ -433,8 +431,6 @@ github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc8
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=

View file

@ -84,13 +84,23 @@ input PHashDuplicationCriterionInput {
input StashIDCriterionInput {
"""
If present, this value is treated as a predicate.
That is, it will filter based on stash_ids with the matching endpoint
That is, it will filter based on stash_id with the matching endpoint
"""
endpoint: String
stash_id: String
modifier: CriterionModifier!
}
input StashIDsCriterionInput {
"""
If present, this value is treated as a predicate.
That is, it will filter based on stash_ids with the matching endpoint
"""
endpoint: String
stash_ids: [String]
modifier: CriterionModifier!
}
input CustomFieldCriterionInput {
field: String!
value: [Any!]
@ -156,6 +166,9 @@ input PerformerFilterType {
o_counter: IntCriterionInput
"Filter by StashID"
stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashIDs"
stash_ids_endpoint: StashIDsCriterionInput
# rating expressed as 1-100
rating100: IntCriterionInput
"Filter by url"
@ -292,6 +305,9 @@ input SceneFilterType {
performer_count: IntCriterionInput
"Filter by StashID"
stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashIDs"
stash_ids_endpoint: StashIDsCriterionInput
"Filter by url"
url: StringCriterionInput
"Filter by interactive"
@ -432,6 +448,9 @@ input StudioFilterType {
parents: MultiCriterionInput
"Filter by StashID"
stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashIDs"
stash_ids_endpoint: StashIDsCriterionInput
"Filter to only include studios with these tags"
tags: HierarchicalMultiCriterionInput
"Filter to only include studios missing this property"
@ -608,6 +627,10 @@ input TagFilterType {
"Filter by StashID"
stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashID"
stash_ids_endpoint: StashIDsCriterionInput
"Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType

View file

@ -297,6 +297,7 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
}
var coverImageData []byte
coverImageIncluded := translator.hasField("cover_image")
if input.CoverImage != nil {
var err error
coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)
@ -310,21 +311,21 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
return nil, err
}
if err := r.sceneUpdateCoverImage(ctx, scene, coverImageData); err != nil {
return nil, err
if coverImageIncluded {
if err := r.sceneUpdateCoverImage(ctx, scene, coverImageData); err != nil {
return nil, err
}
}
return scene, nil
}
func (r *mutationResolver) sceneUpdateCoverImage(ctx context.Context, s *models.Scene, coverImageData []byte) error {
if len(coverImageData) > 0 {
qb := r.repository.Scene
qb := r.repository.Scene
// update cover table
if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
return err
}
// update cover table - empty data will clear the cover
if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
return err
}
return nil

View file

@ -12,6 +12,7 @@ import (
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/internal/static"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/file/video"
"github.com/stashapp/stash/pkg/fsutil"
@ -243,6 +244,12 @@ func (rs sceneRoutes) streamSegment(w http.ResponseWriter, r *http.Request, stre
}
func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
// if default flag is set, return the default image
if r.URL.Query().Get("default") == "true" {
utils.ServeImage(w, r, static.ReadAll(static.DefaultSceneImage))
return
}
scene := r.Context().Value(sceneKey).(*models.Scene)
ss := manager.SceneServer{

View file

@ -82,16 +82,6 @@ func (s *streamSession) percentWatched() float64 {
return timeBasedPercent
}
// estimatedPlayDuration returns the estimated play duration in seconds.
// Uses elapsed time from session start to last activity, capped by video duration.
func (s *streamSession) estimatedPlayDuration() float64 {
elapsed := s.LastActivity.Sub(s.StartTime).Seconds()
if s.VideoDuration > 0 && elapsed > s.VideoDuration {
return s.VideoDuration
}
return elapsed
}
// estimatedResumeTime calculates the estimated resume time based on elapsed time.
// Since DLNA clients buffer aggressively, byte positions don't correlate with playback.
// Instead, we estimate based on how long the session has been active.
@ -271,14 +261,13 @@ func (t *ActivityTracker) processExpiredSessions() {
// processCompletedSession saves activity data for a completed streaming session.
func (t *ActivityTracker) processCompletedSession(session *streamSession) {
percentWatched := session.percentWatched()
playDuration := session.estimatedPlayDuration()
resumeTime := session.estimatedResumeTime()
logger.Debugf("[DLNA Activity] Session completed: scene=%d, client=%s, duration=%.1fs, startTime=%s, lastActivity=%s, percent=%.1f%%, duration=%.1fs, resume=%.1fs",
session.SceneID, session.ClientIP, session.VideoDuration, session.StartTime.String(), session.LastActivity.String(), percentWatched, playDuration, resumeTime)
logger.Debugf("[DLNA Activity] Session completed: scene=%d, client=%s, videoDuration=%.1fs, percent=%.1f%%, resume=%.1fs",
session.SceneID, session.ClientIP, session.VideoDuration, percentWatched, resumeTime)
// Only save if there was meaningful activity (at least 1% watched or 5 seconds)
if percentWatched < 1 && playDuration < 5 {
// Only save if there was meaningful activity (at least 1% watched)
if percentWatched < 1 {
logger.Debugf("[DLNA Activity] Session too short, skipping save")
return
}
@ -289,38 +278,41 @@ func (t *ActivityTracker) processCompletedSession(session *streamSession) {
return
}
ctx := context.Background()
// Determine what needs to be saved
shouldSaveResume := resumeTime > 0
shouldAddView := !session.PlayCountAdded && percentWatched >= float64(t.getMinimumPlayPercent())
// Save activity (resume time and play duration)
if playDuration > 0 || resumeTime > 0 {
var resumeTimePtr *float64
if resumeTime > 0 {
resumeTimePtr = &resumeTime
}
if err := txn.WithTxn(ctx, t.txnManager, func(ctx context.Context) error {
_, err := t.sceneWriter.SaveActivity(ctx, session.SceneID, resumeTimePtr, &playDuration)
return err
}); err != nil {
logger.Warnf("[DLNA Activity] Failed to save activity for scene %d: %v", session.SceneID, err)
}
// Nothing to save
if !shouldSaveResume && !shouldAddView {
return
}
// Increment play count if threshold met
if !session.PlayCountAdded {
minPercent := t.getMinimumPlayPercent()
if percentWatched >= float64(minPercent) {
if err := txn.WithTxn(ctx, t.txnManager, func(ctx context.Context) error {
_, err := t.sceneWriter.AddViews(ctx, session.SceneID, []time.Time{time.Now()})
return err
}); err != nil {
logger.Warnf("[DLNA Activity] Failed to increment play count for scene %d: %v", session.SceneID, err)
} else {
logger.Debugf("[DLNA Activity] Incremented play count for scene %d (%.1f%% watched)",
session.SceneID, percentWatched)
session.PlayCountAdded = true
// Save everything in a single transaction
ctx := context.Background()
if err := txn.WithTxn(ctx, t.txnManager, func(ctx context.Context) error {
// Save resume time only. DLNA clients buffer aggressively and don't report
// playback position, so we can't accurately track play duration - saving
// guesses would corrupt analytics. Resume time is still useful as a
// "continue watching" hint even if imprecise.
if shouldSaveResume {
if _, err := t.sceneWriter.SaveActivity(ctx, session.SceneID, &resumeTime, nil); err != nil {
return fmt.Errorf("save resume time: %w", err)
}
}
// Increment play count (also updates last_played_at via view date)
if shouldAddView {
if _, err := t.sceneWriter.AddViews(ctx, session.SceneID, []time.Time{time.Now()}); err != nil {
return fmt.Errorf("add view: %w", err)
}
session.PlayCountAdded = true
logger.Debugf("[DLNA Activity] Incremented play count for scene %d (%.1f%% watched)",
session.SceneID, percentWatched)
}
return nil
}); err != nil {
logger.Warnf("[DLNA Activity] Failed to save activity for scene %d: %v", session.SceneID, err)
}
}

View file

@ -129,52 +129,6 @@ func TestStreamSession_PercentWatched(t *testing.T) {
}
}
func TestStreamSession_EstimatedPlayDuration(t *testing.T) {
now := time.Now()
tests := []struct {
name string
startTime time.Time
lastActivity time.Time
videoDuration float64
expected float64
}{
{
name: "elapsed less than duration",
startTime: now.Add(-30 * time.Second),
lastActivity: now,
videoDuration: 120,
expected: 30.0,
},
{
name: "elapsed exceeds duration - capped",
startTime: now.Add(-180 * time.Second),
lastActivity: now,
videoDuration: 120,
expected: 120.0,
},
{
name: "no duration limit",
startTime: now.Add(-300 * time.Second),
lastActivity: now,
videoDuration: 0,
expected: 300.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
session := &streamSession{
StartTime: tt.startTime,
LastActivity: tt.lastActivity,
VideoDuration: tt.videoDuration,
}
result := session.estimatedPlayDuration()
assert.InDelta(t, tt.expected, result, 1.0) // Allow 1 second tolerance
})
}
}
func TestStreamSession_EstimatedResumeTime(t *testing.T) {
now := time.Now()
@ -455,12 +409,12 @@ func TestActivityTracker_ShortSessionIgnored(t *testing.T) {
// Verify percent watched is below threshold (1s / 120s = 0.83%)
assert.InDelta(t, 0.83, session.percentWatched(), 0.1)
// Verify play duration is short
assert.InDelta(t, 1.0, session.estimatedPlayDuration(), 0.5)
// Verify elapsed time is short
elapsed := session.LastActivity.Sub(session.StartTime).Seconds()
assert.InDelta(t, 1.0, elapsed, 0.5)
// Both are below the minimum thresholds (1% and 5 seconds)
percentWatched := session.percentWatched()
playDuration := session.estimatedPlayDuration()
shouldSkip := percentWatched < 1 && playDuration < 5
shouldSkip := percentWatched < 1 && elapsed < 5
assert.True(t, shouldSkip, "Short session should be skipped")
}

View file

@ -895,7 +895,8 @@ func (s *scanJob) getFileFS(f *models.BaseFile) (models.FS, error) {
}
zipPath := f.ZipFile.Base().Path
return fs.OpenZip(zipPath, f.Size)
zipSize := f.ZipFile.Base().Size
return fs.OpenZip(zipPath, zipSize)
}
func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) {

View file

@ -166,6 +166,8 @@ type PerformerFilterType struct {
StashID *StringCriterionInput `json:"stash_id"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by StashIDs Endpoint
StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by url

View file

@ -79,6 +79,8 @@ type SceneFilterType struct {
StashID *StringCriterionInput `json:"stash_id"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by StashIDs Endpoint
StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"`
// Filter by url
URL *StringCriterionInput `json:"url"`
// Filter by interactive

View file

@ -129,8 +129,16 @@ func (u *UpdateStashIDs) Set(v StashID) {
type StashIDCriterionInput struct {
// If present, this value is treated as a predicate.
// That is, it will filter based on stash_ids with the matching endpoint
// That is, it will filter based on stash_id with the matching endpoint
Endpoint *string `json:"endpoint"`
StashID *string `json:"stash_id"`
Modifier CriterionModifier `json:"modifier"`
}
type StashIDsCriterionInput struct {
// If present, this value is treated as a predicate.
// That is, it will filter based on stash_ids with the matching endpoint
Endpoint *string `json:"endpoint"`
StashIDs []*string `json:"stash_ids"`
Modifier CriterionModifier `json:"modifier"`
}

View file

@ -10,6 +10,8 @@ type StudioFilterType struct {
StashID *StringCriterionInput `json:"stash_id"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by StashIDs Endpoint
StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"`
// Filter to only include studios missing this property
IsMissing *string `json:"is_missing"`
// Filter by rating expressed as 1-100

View file

@ -42,6 +42,8 @@ type TagFilterType struct {
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by StashIDs Endpoint
StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"`
// Filter by related scenes that meet this criteria
ScenesFilter *SceneFilterType `json:"scenes_filter"`
// Filter by related images that meet this criteria

View file

@ -1012,6 +1012,41 @@ func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder)
return
}
var stashIDs []*string
if h.c.StashID != nil {
stashIDs = []*string{h.c.StashID}
} else {
stashIDs = nil
}
convertedInput := &models.StashIDsCriterionInput{
Endpoint: h.c.Endpoint,
StashIDs: stashIDs,
Modifier: h.c.Modifier,
}
convertedHandler := stashIDsCriterionHandler{
c: convertedInput,
stashIDRepository: h.stashIDRepository,
stashIDTableAs: h.stashIDTableAs,
parentIDCol: h.parentIDCol,
}
convertedHandler.handle(ctx, f)
}
type stashIDsCriterionHandler struct {
c *models.StashIDsCriterionInput
stashIDRepository *stashIDRepository
stashIDTableAs string
parentIDCol string
}
func (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder) {
if h.c == nil {
return
}
stashIDRepo := h.stashIDRepository
t := stashIDRepo.tableName
if h.stashIDTableAs != "" {
@ -1025,15 +1060,33 @@ func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder)
f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause)
v := ""
if h.c.StashID != nil {
v = *h.c.StashID
}
if len(h.c.StashIDs) == 0 {
stringCriterionHandler(&models.StringCriterionInput{
Value: "",
Modifier: h.c.Modifier,
}, t+".stash_id")(ctx, f)
} else {
b := f
for _, n := range h.c.StashIDs {
query := &filterBuilder{}
v := ""
if n != nil {
v = *n
}
stringCriterionHandler(&models.StringCriterionInput{
Value: v,
Modifier: h.c.Modifier,
}, t+".stash_id")(ctx, f)
stringCriterionHandler(&models.StringCriterionInput{
Value: v,
Modifier: h.c.Modifier,
}, t+".stash_id")(ctx, query)
if h.c.Modifier == models.CriterionModifierNotEquals {
b.and(query)
} else {
b.or(query)
}
b = query
}
}
}
type relatedFilterHandler struct {

View file

@ -148,6 +148,12 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler {
stashIDTableAs: "performer_stash_ids",
parentIDCol: "performers.id",
},
&stashIDsCriterionHandler{
c: filter.StashIDsEndpoint,
stashIDRepository: &performerRepository.stashIDs,
stashIDTableAs: "performer_stash_ids",
parentIDCol: "performers.id",
},
qb.aliasCriterionHandler(filter.Aliases),

View file

@ -1069,6 +1069,8 @@ func TestPerformerQuery(t *testing.T) {
var (
endpoint = performerStashID(performerIdxWithGallery).Endpoint
stashID = performerStashID(performerIdxWithGallery).StashID
stashID2 = performerStashID(performerIdx1WithGallery).StashID
stashIDs = []*string{&stashID, &stashID2}
)
tests := []struct {
@ -1133,6 +1135,60 @@ func TestPerformerQuery(t *testing.T) {
nil,
false,
},
{
"stash ids with endpoint",
nil,
&models.PerformerFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
StashIDs: stashIDs,
Modifier: models.CriterionModifierEquals,
},
},
[]int{performerIdxWithGallery, performerIdx1WithGallery},
nil,
false,
},
{
"exclude stash ids with endpoint",
nil,
&models.PerformerFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
StashIDs: stashIDs,
Modifier: models.CriterionModifierNotEquals,
},
},
nil,
[]int{performerIdxWithGallery, performerIdx1WithGallery},
false,
},
{
"null stash ids with endpoint",
nil,
&models.PerformerFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierIsNull,
},
},
nil,
[]int{performerIdxWithGallery, performerIdx1WithGallery},
false,
},
{
"not null stash ids with endpoint",
nil,
&models.PerformerFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierNotNull,
},
},
[]int{performerIdxWithGallery, performerIdx1WithGallery},
nil,
false,
},
{
"circumcised (cut)",
nil,

View file

@ -114,13 +114,18 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler {
stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")(ctx, f)
}
}),
&stashIDCriterionHandler{
c: sceneFilter.StashIDEndpoint,
stashIDRepository: &sceneRepository.stashIDs,
stashIDTableAs: "scene_stash_ids",
parentIDCol: "scenes.id",
},
&stashIDsCriterionHandler{
c: sceneFilter.StashIDsEndpoint,
stashIDRepository: &sceneRepository.stashIDs,
stashIDTableAs: "scene_stash_ids",
parentIDCol: "scenes.id",
},
boolCriterionHandler(sceneFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable),
intCriterionHandler(sceneFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable),

View file

@ -2098,6 +2098,8 @@ func TestSceneQuery(t *testing.T) {
var (
endpoint = sceneStashID(sceneIdxWithGallery).Endpoint
stashID = sceneStashID(sceneIdxWithGallery).StashID
stashID2 = sceneStashID(sceneIdxWithPerformer).StashID
stashIDs = []*string{&stashID, &stashID2}
depth = -1
)
@ -2203,6 +2205,60 @@ func TestSceneQuery(t *testing.T) {
nil,
false,
},
{
"stash ids with endpoint",
nil,
&models.SceneFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
StashIDs: stashIDs,
Modifier: models.CriterionModifierEquals,
},
},
[]int{sceneIdxWithGallery, sceneIdxWithPerformer},
nil,
false,
},
{
"exclude stash ids with endpoint",
nil,
&models.SceneFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
StashIDs: stashIDs,
Modifier: models.CriterionModifierNotEquals,
},
},
nil,
[]int{sceneIdxWithGallery, sceneIdxWithPerformer},
false,
},
{
"null stash ids with endpoint",
nil,
&models.SceneFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierIsNull,
},
},
nil,
[]int{sceneIdxWithGallery, sceneIdxWithPerformer},
false,
},
{
"not null stash ids with endpoint",
nil,
&models.SceneFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierNotNull,
},
},
[]int{sceneIdxWithGallery, sceneIdxWithPerformer},
nil,
false,
},
{
"with studio id 0 including child studios",
nil,

View file

@ -1079,7 +1079,7 @@ func getObjectDate(index int) *models.Date {
func sceneStashID(i int) models.StashID {
return models.StashID{
StashID: getSceneStringValue(i, "stashid"),
Endpoint: getSceneStringValue(i, "endpoint"),
Endpoint: getSceneStringValue(0, "endpoint"),
UpdatedAt: epochTime,
}
}
@ -1547,7 +1547,7 @@ func getIgnoreAutoTag(index int) bool {
func performerStashID(i int) models.StashID {
return models.StashID{
StashID: getPerformerStringValue(i, "stashid"),
Endpoint: getPerformerStringValue(i, "endpoint"),
Endpoint: getPerformerStringValue(0, "endpoint"),
}
}
@ -1700,7 +1700,7 @@ func getTagChildCount(id int) int {
func tagStashID(i int) models.StashID {
return models.StashID{
StashID: getTagStringValue(i, "stashid"),
Endpoint: getTagStringValue(i, "endpoint"),
Endpoint: getTagStringValue(0, "endpoint"),
}
}

View file

@ -72,6 +72,12 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler {
stashIDTableAs: "studio_stash_ids",
parentIDCol: "studios.id",
},
&stashIDsCriterionHandler{
c: studioFilter.StashIDsEndpoint,
stashIDRepository: &studioRepository.stashIDs,
stashIDTableAs: "studio_stash_ids",
parentIDCol: "studios.id",
},
qb.isMissingCriterionHandler(studioFilter.IsMissing),
qb.tagCountCriterionHandler(studioFilter.TagCount),

View file

@ -91,6 +91,12 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler {
stashIDTableAs: "tag_stash_ids",
parentIDCol: "tags.id",
},
&stashIDsCriterionHandler{
c: tagFilter.StashIDsEndpoint,
stashIDRepository: &tagRepository.stashIDs,
stashIDTableAs: "tag_stash_ids",
parentIDCol: "tags.id",
},
&timestampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil},
&timestampCriterionHandler{tagFilter.UpdatedAt, "tags.updated_at", nil},

View file

@ -356,6 +356,8 @@ func TestTagQuery(t *testing.T) {
var (
endpoint = tagStashID(tagIdxWithPerformer).Endpoint
stashID = tagStashID(tagIdxWithPerformer).StashID
stashID2 = tagStashID(tagIdx1WithPerformer).StashID
stashIDs = []*string{&stashID, &stashID2}
)
tests := []struct {
@ -420,6 +422,60 @@ func TestTagQuery(t *testing.T) {
nil,
false,
},
{
"stash ids with endpoint",
nil,
&models.TagFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
StashIDs: stashIDs,
Modifier: models.CriterionModifierEquals,
},
},
[]int{tagIdxWithPerformer, tagIdx1WithPerformer},
nil,
false,
},
{
"exclude stash ids with endpoint",
nil,
&models.TagFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
StashIDs: stashIDs,
Modifier: models.CriterionModifierNotEquals,
},
},
nil,
[]int{tagIdxWithPerformer, tagIdx1WithPerformer},
false,
},
{
"null stash ids with endpoint",
nil,
&models.TagFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierIsNull,
},
},
nil,
[]int{tagIdxWithPerformer, tagIdx1WithPerformer},
false,
},
{
"not null stash ids with endpoint",
nil,
&models.TagFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierNotNull,
},
},
[]int{tagIdxWithPerformer, tagIdx1WithPerformer},
nil,
false,
},
}
for _, tt := range tests {

View file

@ -27,11 +27,11 @@
"@formatjs/intl-locale": "^3.0.11",
"@formatjs/intl-numberformat": "^8.3.3",
"@formatjs/intl-pluralrules": "^5.1.8",
"@fortawesome/fontawesome-svg-core": "^6.3.0",
"@fortawesome/free-brands-svg-icons": "^6.3.0",
"@fortawesome/free-regular-svg-icons": "^6.3.0",
"@fortawesome/free-solid-svg-icons": "^6.3.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^0.2.6",
"@react-hook/resize-observer": "^1.2.6",
"@silvermine/videojs-airplay": "^1.2.0",
"@silvermine/videojs-chromecast": "^1.4.1",

View file

@ -27,20 +27,20 @@ importers:
specifier: ^5.1.8
version: 5.4.6
'@fortawesome/fontawesome-svg-core':
specifier: ^6.3.0
version: 6.7.2
specifier: ^7.1.0
version: 7.1.0
'@fortawesome/free-brands-svg-icons':
specifier: ^6.3.0
version: 6.7.2
specifier: ^7.1.0
version: 7.1.0
'@fortawesome/free-regular-svg-icons':
specifier: ^6.3.0
version: 6.7.2
specifier: ^7.1.0
version: 7.1.0
'@fortawesome/free-solid-svg-icons':
specifier: ^6.3.0
version: 6.7.2
specifier: ^7.1.0
version: 7.1.0
'@fortawesome/react-fontawesome':
specifier: ^0.2.0
version: 0.2.6(@fortawesome/fontawesome-svg-core@6.7.2)(react@17.0.2)
specifier: ^0.2.6
version: 0.2.6(@fortawesome/fontawesome-svg-core@7.1.0)(react@17.0.2)
'@react-hook/resize-observer':
specifier: ^1.2.6
version: 1.2.6(react@17.0.2)
@ -1262,28 +1262,29 @@ packages:
ts-jest:
optional: true
'@fortawesome/fontawesome-common-types@6.7.2':
resolution: {integrity: sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==}
'@fortawesome/fontawesome-common-types@7.1.0':
resolution: {integrity: sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==}
engines: {node: '>=6'}
'@fortawesome/fontawesome-svg-core@6.7.2':
resolution: {integrity: sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==}
'@fortawesome/fontawesome-svg-core@7.1.0':
resolution: {integrity: sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==}
engines: {node: '>=6'}
'@fortawesome/free-brands-svg-icons@6.7.2':
resolution: {integrity: sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==}
'@fortawesome/free-brands-svg-icons@7.1.0':
resolution: {integrity: sha512-9byUd9bgNfthsZAjBl6GxOu1VPHgBuRUP9juI7ZoM98h8xNPTCTagfwUFyYscdZq4Hr7gD1azMfM9s5tIWKZZA==}
engines: {node: '>=6'}
'@fortawesome/free-regular-svg-icons@6.7.2':
resolution: {integrity: sha512-7Z/ur0gvCMW8G93dXIQOkQqHo2M5HLhYrRVC0//fakJXxcF1VmMPsxnG6Ee8qEylA8b8Q3peQXWMNZ62lYF28g==}
'@fortawesome/free-regular-svg-icons@7.1.0':
resolution: {integrity: sha512-0e2fdEyB4AR+e6kU4yxwA/MonnYcw/CsMEP9lH82ORFi9svA6/RhDyhxIv5mlJaldmaHLLYVTb+3iEr+PDSZuQ==}
engines: {node: '>=6'}
'@fortawesome/free-solid-svg-icons@6.7.2':
resolution: {integrity: sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==}
'@fortawesome/free-solid-svg-icons@7.1.0':
resolution: {integrity: sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==}
engines: {node: '>=6'}
'@fortawesome/react-fontawesome@0.2.6':
resolution: {integrity: sha512-mtBFIi1UsYQo7rYonYFkjgYKGoL8T+fEH6NGUpvuqtY3ytMsAoDaPo5rk25KuMtKDipY4bGYM/CkmCHA1N3FUg==}
deprecated: v0.2.x is no longer supported. Unless you are still using FontAwesome 5, please update to v3.1.1 or greater.
peerDependencies:
'@fortawesome/fontawesome-svg-core': ~1 || ~6 || ~7
react: ^16.3 || ^17.0.0 || ^18.0.0 || ^19.0.0
@ -6485,27 +6486,27 @@ snapshots:
tslib: 2.8.1
typescript: 4.8.4
'@fortawesome/fontawesome-common-types@6.7.2': {}
'@fortawesome/fontawesome-common-types@7.1.0': {}
'@fortawesome/fontawesome-svg-core@6.7.2':
'@fortawesome/fontawesome-svg-core@7.1.0':
dependencies:
'@fortawesome/fontawesome-common-types': 6.7.2
'@fortawesome/fontawesome-common-types': 7.1.0
'@fortawesome/free-brands-svg-icons@6.7.2':
'@fortawesome/free-brands-svg-icons@7.1.0':
dependencies:
'@fortawesome/fontawesome-common-types': 6.7.2
'@fortawesome/fontawesome-common-types': 7.1.0
'@fortawesome/free-regular-svg-icons@6.7.2':
'@fortawesome/free-regular-svg-icons@7.1.0':
dependencies:
'@fortawesome/fontawesome-common-types': 6.7.2
'@fortawesome/fontawesome-common-types': 7.1.0
'@fortawesome/free-solid-svg-icons@6.7.2':
'@fortawesome/free-solid-svg-icons@7.1.0':
dependencies:
'@fortawesome/fontawesome-common-types': 6.7.2
'@fortawesome/fontawesome-common-types': 7.1.0
'@fortawesome/react-fontawesome@0.2.6(@fortawesome/fontawesome-svg-core@6.7.2)(react@17.0.2)':
'@fortawesome/react-fontawesome@0.2.6(@fortawesome/fontawesome-svg-core@7.1.0)(react@17.0.2)':
dependencies:
'@fortawesome/fontawesome-svg-core': 6.7.2
'@fortawesome/fontawesome-svg-core': 7.1.0
prop-types: 15.8.1
react: 17.0.2

View file

@ -1,4 +1,5 @@
import React, { PropsWithChildren } from "react";
import { PatchComponent } from "src/patch";
interface IProps {
className?: string;
@ -6,19 +7,18 @@ interface IProps {
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>
export const RecommendationRow: React.FC<PropsWithChildren<IProps>> =
PatchComponent(
"RecommendationRow",
({ className, header, link, children }) => (
<div className={`recommendation-row ${className}`}>
<div className="recommendation-row-head">
<div>
<h2>{header}</h2>
</div>
{link}
</div>
{children}
</div>
{link}
</div>
{children}
</div>
);
)
);

View file

@ -0,0 +1,43 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { GalleryCard } from "./GalleryCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { PatchComponent } from "src/patch";
interface IGalleryCardGrid {
galleries: GQL.SlimGalleryDataFragment[];
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
const zoomWidths = [280, 340, 480, 640];
export const GalleryCardGrid: React.FC<IGalleryCardGrid> = PatchComponent(
"GalleryCardGrid",
({ galleries, selectedIds, zoomIndex, onSelectChange }) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{galleries.map((gallery) => (
<GalleryCard
key={gallery.id}
cardWidth={cardWidth}
gallery={gallery}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(gallery.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(gallery.id, selected, shiftKey)
}
/>
))}
</div>
);
}
);

View file

@ -19,12 +19,14 @@ const GalleryCreate: React.FC = () => {
const [createGallery] = useGalleryCreate();
async function onSave(input: GQL.GalleryCreateInput) {
async function onSave(input: GQL.GalleryCreateInput, andNew?: boolean) {
const result = await createGallery({
variables: { input },
});
if (result.data?.galleryCreate) {
history.push(`/galleries/${result.data.galleryCreate.id}`);
if (!andNew) {
history.push(`/galleries/${result.data.galleryCreate.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View file

@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Prompt } from "react-router-dom";
import { Button, Form, Col, Row } from "react-bootstrap";
import { Button, Dropdown, Form, Col, Row, SplitButton } from "react-bootstrap";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
@ -35,7 +35,7 @@ import { ScraperMenu } from "src/components/Shared/ScraperMenu";
interface IProps {
gallery: Partial<GQL.GalleryDataFragment>;
isVisible: boolean;
onSubmit: (input: GQL.GalleryCreateInput) => Promise<void>;
onSubmit: (input: GQL.GalleryCreateInput, andNew?: boolean) => Promise<void>;
onDelete: () => void;
}
@ -177,10 +177,10 @@ export const GalleryEditPanel: React.FC<IProps> = ({
return <div></div>;
}, [gallery?.paths?.cover, intl]);
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@ -188,6 +188,11 @@ export const GalleryEditPanel: React.FC<IProps> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
onSave(input, true);
}
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
if (!gallery || !gallery.id) return;
@ -445,16 +450,31 @@ export const GalleryEditPanel: React.FC<IProps> = ({
<Form noValidate onSubmit={formik.handleSubmit}>
<Row className="form-container edit-buttons-container px-3 pt-3">
<div className="edit-buttons mb-3 pl-0">
<Button
className="edit-button"
variant="primary"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
{isNew ? (
<SplitButton
id="gallery-save-split-button"
className="edit-button"
variant="primary"
disabled={!isEqual(formik.errors, {})}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => formik.submitForm()}
>
<Dropdown.Item onClick={() => onSaveAndNewClick()}>
<FormattedMessage id="actions.save_and_new" />
</Dropdown.Item>
</SplitButton>
) : (
<Button
className="edit-button"
variant="primary"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
)}
<Button
className="edit-button"
variant="danger"

View file

@ -1,44 +0,0 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { GalleryCard } from "./GalleryCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
interface IGalleryCardGrid {
galleries: GQL.SlimGalleryDataFragment[];
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
const zoomWidths = [280, 340, 480, 640];
export const GalleryCardGrid: React.FC<IGalleryCardGrid> = ({
galleries,
selectedIds,
zoomIndex,
onSelectChange,
}) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{galleries.map((gallery) => (
<GalleryCard
key={gallery.id}
cardWidth={cardWidth}
gallery={gallery}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(gallery.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(gallery.id, selected, shiftKey)
}
/>
))}
</div>
);
};

View file

@ -13,7 +13,7 @@ import { EditGalleriesDialog } from "./EditGalleriesDialog";
import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog";
import { ExportDialog } from "../Shared/ExportDialog";
import { GalleryListTable } from "./GalleryListTable";
import { GalleryCardGrid } from "./GalleryGridCard";
import { GalleryCardGrid } from "./GalleryCardGrid";
import { View } from "../List/views";
import { PatchComponent } from "src/patch";
import { IItemListOperation } from "../List/FilteredListToolbar";
@ -153,7 +153,15 @@ export const GalleryList: React.FC<IGalleryList> = PatchComponent(
<div className="row">
<div className={`GalleryWall zoom-${filter.zoomIndex}`}>
{result.data.findGalleries.galleries.map((gallery) => (
<GalleryWallCard key={gallery.id} gallery={gallery} />
<GalleryWallCard
key={gallery.id}
gallery={gallery}
selected={selectedIds.has(gallery.id)}
onSelectedChanged={(selected, shiftKey) =>
onSelectChange(gallery.id, selected, shiftKey)
}
selecting={selectedIds.size > 0}
/>
))}
</div>
</div>

View file

@ -7,6 +7,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
interface IProps {
isTouch: boolean;
@ -14,41 +15,44 @@ interface IProps {
header: string;
}
export const GalleryRecommendationRow: React.FC<IProps> = (props) => {
const result = useFindGalleries(props.filter);
const cardCount = result.data?.findGalleries.count;
export const GalleryRecommendationRow: React.FC<IProps> = PatchComponent(
"GalleryRecommendationRow",
(props) => {
const result = useFindGalleries(props.filter);
const cardCount = result.data?.findGalleries.count;
if (!result.loading && !cardCount) {
return null;
}
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className="gallery-recommendations"
header={props.header}
link={
<Link to={`/galleries?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
return (
<RecommendationRow
className="gallery-recommendations"
header={props.header}
link={
<Link to={`/galleries?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="gallery-skeleton skeleton-card"
></div>
))
: result.data?.findGalleries.galleries.map((g) => (
<GalleryCard key={g.id} gallery={g} zoomIndex={1} />
))}
</Slider>
</RecommendationRow>
);
};
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="gallery-skeleton skeleton-card"
></div>
))
: result.data?.findGalleries.galleries.map((g) => (
<GalleryCard key={g.id} gallery={g} zoomIndex={1} />
))}
</Slider>
</RecommendationRow>
);
}
);

View file

@ -1,4 +1,5 @@
import React, { useState } from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
@ -8,6 +9,7 @@ import { useGalleryLightbox } from "src/hooks/Lightbox/hooks";
import { galleryTitle } from "src/core/galleries";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import { GalleryPreviewScrubber } from "./GalleryPreviewScrubber";
import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect";
import cx from "classnames";
const CLASSNAME = "GalleryWallCard";
@ -18,6 +20,9 @@ const CLASSNAME_IMG_CONTAIN = `${CLASSNAME}-img-contain`;
interface IProps {
gallery: GQL.SlimGalleryDataFragment;
selected?: boolean;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
selecting?: boolean;
}
type Orientation = "landscape" | "portrait";
@ -26,7 +31,12 @@ function getOrientation(width: number, height: number): Orientation {
return width > height ? "landscape" : "portrait";
}
const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
const GalleryWallCard: React.FC<IProps> = ({
gallery,
selected,
onSelectedChanged,
selecting,
}) => {
const intl = useIntl();
const [coverOrientation, setCoverOrientation] =
React.useState<Orientation>("landscape");
@ -34,6 +44,12 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
React.useState<Orientation>("landscape");
const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters);
const { dragProps } = useDragMoveSelect({
selecting: selecting || false,
selected: selected || false,
onSelectedChanged: onSelectedChanged,
});
const cover = gallery?.paths.cover;
function onCoverLoad(e: React.SyntheticEvent<HTMLImageElement, Event>) {
@ -58,6 +74,14 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")]
: performerNames;
function handleCardClick(event: React.MouseEvent) {
if (selecting && onSelectedChanged) {
onSelectedChanged(!selected, event.shiftKey);
return;
}
showLightboxStart();
}
async function showLightboxStart() {
if (gallery.image_count === 0) {
return;
@ -69,15 +93,32 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
const imgClassname =
imageOrientation !== coverOrientation ? CLASSNAME_IMG_CONTAIN : "";
let shiftKey = false;
return (
<>
<section
className={`${CLASSNAME} ${CLASSNAME}-${coverOrientation}`}
onClick={showLightboxStart}
onKeyPress={showLightboxStart}
className={`${CLASSNAME} ${CLASSNAME}-${coverOrientation} wall-item`}
onClick={handleCardClick}
onKeyPress={() => showLightboxStart()}
role="button"
tabIndex={0}
{...dragProps}
>
{onSelectedChanged && (
<Form.Control
type="checkbox"
className="wall-item-check mousetrap"
checked={selected}
onChange={() => onSelectedChanged(!selected, shiftKey)}
onClick={(
event: React.MouseEvent<HTMLInputElement, MouseEvent>
) => {
shiftKey = event.shiftKey;
event.stopPropagation();
}}
/>
)}
<RatingSystem value={gallery.rating100} disabled withoutContext />
<img
loading="lazy"

View file

@ -5,6 +5,7 @@ import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { PatchComponent } from "src/patch";
interface IGroupCardGrid {
groups: GQL.ListGroupDataFragment[];
@ -17,34 +18,30 @@ interface IGroupCardGrid {
const zoomWidths = [210, 250, 300, 375];
export const GroupCardGrid: React.FC<IGroupCardGrid> = ({
groups,
selectedIds,
zoomIndex,
onSelectChange,
fromGroupId,
onMove,
}) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
export const GroupCardGrid: React.FC<IGroupCardGrid> = PatchComponent(
"GroupCardGrid",
({ groups, selectedIds, zoomIndex, onSelectChange, fromGroupId, onMove }) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{groups.map((p) => (
<GroupCard
key={p.id}
cardWidth={cardWidth}
group={p}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(p.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(p.id, selected, shiftKey)
}
fromGroupId={fromGroupId}
onMove={onMove}
/>
))}
</div>
);
};
return (
<div className="row justify-content-center" ref={componentRef}>
{groups.map((p) => (
<GroupCard
key={p.id}
cardWidth={cardWidth}
group={p}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(p.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(p.id, selected, shiftKey)
}
fromGroupId={fromGroupId}
onMove={onMove}
/>
))}
</div>
);
}
);

View file

@ -25,12 +25,14 @@ const GroupCreate: React.FC = () => {
const [createGroup] = useGroupCreate();
async function onSave(input: GQL.GroupCreateInput) {
async function onSave(input: GQL.GroupCreateInput, andNew?: boolean) {
const result = await createGroup({
variables: { input },
});
if (result.data?.groupCreate?.id) {
history.push(`/groups/${result.data.groupCreate.id}`);
if (!andNew) {
history.push(`/groups/${result.data.groupCreate.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View file

@ -31,7 +31,7 @@ import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable";
interface IGroupEditPanel {
group: Partial<GQL.GroupDataFragment>;
onSubmit: (group: GQL.GroupCreateInput) => Promise<void>;
onSubmit: (group: GQL.GroupCreateInput, andNew?: boolean) => Promise<void>;
onCancel: () => void;
onDelete: () => void;
setFrontImage: (image?: string | null) => void;
@ -208,10 +208,10 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
}
}
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@ -219,6 +219,11 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
onSave(input, true);
}
async function onScrapeGroupURL(url: string) {
if (!url) return;
setIsLoading(true);
@ -462,6 +467,7 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
isEditing
onToggleEdit={onCancel}
onSave={formik.handleSubmit}
onSaveAndNew={isNew ? onSaveAndNewClick : undefined}
saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
onImageChange={onFrontImageChange}
onImageChangeURL={onFrontImageLoad}

View file

@ -76,7 +76,8 @@ const Toolbar: React.FC<IFilteredListToolbar> = ({
onDelete,
operations,
}) => {
const { getSelected, onSelectAll, onSelectNone } = useListContext();
const { getSelected, onSelectAll, onSelectNone, onInvertSelection } =
useListContext();
const { filter, setFilter } = useFilter();
return (
@ -91,6 +92,7 @@ const Toolbar: React.FC<IFilteredListToolbar> = ({
<ListOperationButtons
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
onInvertSelection={onInvertSelection}
itemsSelected={getSelected().length > 0}
otherOperations={operations}
onEdit={onEdit}

View file

@ -7,6 +7,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
interface IProps {
isTouch: boolean;
@ -14,38 +15,44 @@ interface IProps {
header: string;
}
export const GroupRecommendationRow: React.FC<IProps> = (props: IProps) => {
const result = useFindGroups(props.filter);
const cardCount = result.data?.findGroups.count;
export const GroupRecommendationRow: React.FC<IProps> = PatchComponent(
"GroupRecommendationRow",
(props: IProps) => {
const result = useFindGroups(props.filter);
const cardCount = result.data?.findGroups.count;
if (!result.loading && !cardCount) {
return null;
}
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className="group-recommendations"
header={props.header}
link={
<Link to={`/groups?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
return (
<RecommendationRow
className="group-recommendations"
header={props.header}
link={
<Link to={`/groups?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={`_${i}`} className="group-skeleton skeleton-card"></div>
))
: result.data?.findGroups.groups.map((g) => (
<GroupCard key={g.id} group={g} />
))}
</Slider>
</RecommendationRow>
);
};
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="group-skeleton skeleton-card"
></div>
))
: result.data?.findGroups.groups.map((g) => (
<GroupCard key={g.id} group={g} />
))}
</Slider>
</RecommendationRow>
);
}
);

View file

@ -0,0 +1,47 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { ImageCard } from "./ImageCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { PatchComponent } from "src/patch";
interface IImageCardGrid {
images: GQL.SlimImageDataFragment[];
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
onPreview: (index: number, ev: React.MouseEvent<Element, MouseEvent>) => void;
}
const zoomWidths = [280, 340, 480, 640];
export const ImageCardGrid: React.FC<IImageCardGrid> = PatchComponent(
"ImageCardGrid",
({ images, selectedIds, zoomIndex, onSelectChange, onPreview }) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{images.map((image, index) => (
<ImageCard
key={image.id}
cardWidth={cardWidth}
image={image}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(image.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(image.id, selected, shiftKey)
}
onPreview={
selectedIds.size < 1 ? (ev) => onPreview(index, ev) : undefined
}
/>
))}
</div>
);
}
);

View file

@ -1,49 +0,0 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { ImageCard } from "./ImageCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
interface IImageCardGrid {
images: GQL.SlimImageDataFragment[];
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
onPreview: (index: number, ev: React.MouseEvent<Element, MouseEvent>) => void;
}
const zoomWidths = [280, 340, 480, 640];
export const ImageGridCard: React.FC<IImageCardGrid> = ({
images,
selectedIds,
zoomIndex,
onSelectChange,
onPreview,
}) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{images.map((image, index) => (
<ImageCard
key={image.id}
cardWidth={cardWidth}
image={image}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(image.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(image.id, selected, shiftKey)
}
onPreview={
selectedIds.size < 1 ? (ev) => onPreview(index, ev) : undefined
}
/>
))}
</div>
);
};

View file

@ -22,7 +22,7 @@ import Gallery, { RenderImageProps } from "react-photo-gallery";
import { ExportDialog } from "../Shared/ExportDialog";
import { objectTitle } from "src/core/files";
import { useConfigurationContext } from "src/hooks/Config";
import { ImageGridCard } from "./ImageGridCard";
import { ImageCardGrid } from "./ImageCardGrid";
import { View } from "../List/views";
import { IItemListOperation } from "../List/FilteredListToolbar";
import { FileSize } from "../Shared/FileSize";
@ -35,6 +35,9 @@ interface IImageWallProps {
pageCount: number;
handleImageOpen: (index: number) => void;
zoomIndex: number;
selectedIds?: Set<string>;
onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void;
selecting?: boolean;
}
const zoomWidths = [280, 340, 480, 640];
@ -49,6 +52,9 @@ const ImageWall: React.FC<IImageWallProps> = ({
images,
zoomIndex,
handleImageOpen,
selectedIds,
onSelectChange,
selecting,
}) => {
const { configuration } = useConfigurationContext();
const uiConfig = configuration?.ui;
@ -121,9 +127,26 @@ const ImageWall: React.FC<IImageWallProps> = ({
? props.photo.height
: targetRowHeight(containerRef.current?.offsetWidth ?? 0) *
maxHeightFactor;
return <ImageWallItem {...props} maxHeight={maxHeight} />;
const imageId = props.photo.key;
if (!imageId) {
return null;
}
return (
<ImageWallItem
{...props}
maxHeight={maxHeight}
selected={selectedIds?.has(imageId)}
onSelectedChanged={
onSelectChange
? (selected, shiftKey) =>
onSelectChange(imageId, selected, shiftKey)
: undefined
}
selecting={selecting}
/>
);
},
[targetRowHeight]
[targetRowHeight, selectedIds, onSelectChange, selecting]
);
return (
@ -240,7 +263,7 @@ const ImageListImages: React.FC<IImageListImages> = ({
if (filter.displayMode === DisplayMode.Grid) {
return (
<ImageGridCard
<ImageCardGrid
images={images}
selectedIds={selectedIds}
zoomIndex={filter.zoomIndex}
@ -258,6 +281,9 @@ const ImageListImages: React.FC<IImageListImages> = ({
pageCount={pageCount}
handleImageOpen={handleImageOpen}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
selecting={!!selectedIds && selectedIds.size > 0}
/>
);
}

View file

@ -7,6 +7,7 @@ import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { ImageCard } from "./ImageCard";
import { PatchComponent } from "src/patch";
interface IProps {
isTouch: boolean;
@ -14,38 +15,44 @@ interface IProps {
header: string;
}
export const ImageRecommendationRow: React.FC<IProps> = (props: IProps) => {
const result = useFindImages(props.filter);
const cardCount = result.data?.findImages.count;
export const ImageRecommendationRow: React.FC<IProps> = PatchComponent(
"ImageRecommendationRow",
(props: IProps) => {
const result = useFindImages(props.filter);
const cardCount = result.data?.findImages.count;
if (!result.loading && !cardCount) {
return null;
}
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className="images-recommendations"
header={props.header}
link={
<Link to={`/images?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
return (
<RecommendationRow
className="images-recommendations"
header={props.header}
link={
<Link to={`/images?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
{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>
);
};
<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>
);
}
);

View file

@ -1,32 +1,50 @@
import React from "react";
import { Form } from "react-bootstrap";
import type { RenderImageProps } from "react-photo-gallery";
import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect";
interface IExtraProps {
maxHeight: number;
selected?: boolean;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
selecting?: boolean;
}
export const ImageWallItem: React.FC<RenderImageProps & IExtraProps> = (
props: RenderImageProps & IExtraProps
) => {
const { dragProps } = useDragMoveSelect({
selecting: props.selecting || false,
selected: props.selected || false,
onSelectedChanged: props.onSelectedChanged,
});
const height = Math.min(props.maxHeight, props.photo.height);
const zoomFactor = height / props.photo.height;
const width = props.photo.width * zoomFactor;
type style = Record<string, string | number | undefined>;
var imgStyle: style = {
var divStyle: style = {
margin: props.margin,
display: "block",
position: "relative",
};
if (props.direction === "column") {
imgStyle.position = "absolute";
imgStyle.left = props.left;
imgStyle.top = props.top;
divStyle.position = "absolute";
divStyle.left = props.left;
divStyle.top = props.top;
}
var handleClick = function handleClick(
event: React.MouseEvent<Element, MouseEvent>
) {
if (props.selecting && props.onSelectedChanged) {
props.onSelectedChanged(!props.selected, event.shiftKey);
event.preventDefault();
event.stopPropagation();
return;
}
if (props.onClick) {
props.onClick(event, { index: props.index });
}
@ -35,19 +53,39 @@ export const ImageWallItem: React.FC<RenderImageProps & IExtraProps> = (
const video = props.photo.src.includes("preview");
const ImagePreview = video ? "video" : "img";
let shiftKey = false;
return (
<ImagePreview
loop={video}
muted={video}
playsInline={video}
autoPlay={video}
key={props.photo.key}
style={imgStyle}
src={props.photo.src}
width={width}
height={height}
alt={props.photo.alt}
<div
className="wall-item"
style={divStyle}
onClick={handleClick}
/>
{...dragProps}
>
{props.onSelectedChanged && (
<Form.Control
type="checkbox"
className="wall-item-check mousetrap"
checked={props.selected}
onChange={() => props.onSelectedChanged!(!props.selected, shiftKey)}
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
shiftKey = event.shiftKey;
event.stopPropagation();
}}
/>
)}
<ImagePreview
loop={video}
muted={video}
playsInline={video}
autoPlay={video}
key={props.photo.key}
src={props.photo.src}
width={width}
height={height}
alt={props.photo.alt}
onClick={handleClick}
/>
</div>
);
};

View file

@ -86,6 +86,7 @@
}
&-preview {
align-items: center;
display: flex;
justify-content: center;
margin-bottom: 5px;
@ -94,7 +95,6 @@
&-image {
height: 100%;
object-fit: contain;
object-position: top;
width: 100%;
}

View file

@ -99,13 +99,15 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
filter,
setFilter,
});
const { selectedIds, onSelectAll, onSelectNone } = listSelect;
const { selectedIds, onSelectAll, onSelectNone, onInvertSelection } =
listSelect;
const hasSelection = selectedIds.size > 0;
const renderOperations = operationComponent ?? (
<ListOperationButtons
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
onInvertSelection={onInvertSelection}
otherOperations={operations}
itemsSelected={selectedIds.size > 0}
onEdit={onEdit}

View file

@ -73,7 +73,7 @@ export function useFilteredItemList<
const { result, items, totalCount, pages } = queryResult;
const listSelect = useListSelect(items);
const { onSelectAll, onSelectNone } = listSelect;
const { onSelectAll, onSelectNone, onInvertSelection } = listSelect;
const modalState = useModal();
const { showModal, closeModal } = modalState;
@ -99,6 +99,7 @@ export function useFilteredItemList<
onChangePage: setPage,
onSelectAll,
onSelectNone,
onInvertSelection,
pages,
showEditFilter,
});
@ -164,6 +165,7 @@ export const ItemList = <T extends QueryResult, E extends IHasID, M = unknown>(
onSelectChange,
onSelectAll,
onSelectNone,
onInvertSelection,
} = listSelect;
// scroll to the top of the page when the page changes
@ -212,6 +214,7 @@ export const ItemList = <T extends QueryResult, E extends IHasID, M = unknown>(
onChangePage,
onSelectAll,
onSelectNone,
onInvertSelection,
pages,
showEditFilter,
});

View file

@ -63,6 +63,7 @@ export interface IListFilterOperation {
interface IListOperationButtonsProps {
onSelectAll?: () => void;
onSelectNone?: () => void;
onInvertSelection?: () => void;
onEdit?: () => void;
onDelete?: () => void;
itemsSelected?: boolean;
@ -72,6 +73,7 @@ interface IListOperationButtonsProps {
export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
onSelectAll,
onSelectNone,
onInvertSelection,
onEdit,
onDelete,
itemsSelected,
@ -82,6 +84,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
useEffect(() => {
Mousetrap.bind("s a", () => onSelectAll?.());
Mousetrap.bind("s n", () => onSelectNone?.());
Mousetrap.bind("s i", () => onInvertSelection?.());
Mousetrap.bind("e", () => {
if (itemsSelected) {
@ -98,10 +101,18 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
return () => {
Mousetrap.unbind("s a");
Mousetrap.unbind("s n");
Mousetrap.unbind("s i");
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
};
});
}, [
onSelectAll,
onSelectNone,
onInvertSelection,
itemsSelected,
onEdit,
onDelete,
]);
const buttons = useMemo(() => {
const ret = (otherOperations ?? []).filter((o) => {
@ -185,7 +196,25 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
}
}
const options = [renderSelectAll(), renderSelectNone()].filter((o) => o);
function renderInvertSelection() {
if (onInvertSelection) {
return (
<Dropdown.Item
key="invert-selection"
className="bg-secondary text-white"
onClick={() => onInvertSelection?.()}
>
<FormattedMessage id="actions.invert_selection" />
</Dropdown.Item>
);
}
}
const options = [
renderSelectAll(),
renderSelectNone(),
renderInvertSelection(),
].filter((o) => o);
if (otherOperations) {
otherOperations
@ -219,7 +248,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
{options.length > 0 ? options : undefined}
</OperationDropdown>
);
}, [otherOperations, onSelectAll, onSelectNone]);
}, [otherOperations, onSelectAll, onSelectNone, onInvertSelection]);
// don't render anything if there are no buttons or operations
if (buttons.length === 0 && !moreDropdown) {

View file

@ -63,6 +63,7 @@ const emptyState: IListContextState = {
onSelectChange: () => {},
onSelectAll: () => {},
onSelectNone: () => {},
onInvertSelection: () => {},
items: [],
hasSelection: false,
selectedItems: [],

View file

@ -229,6 +229,7 @@ export function useListKeyboardShortcuts(props: {
pages?: number;
onSelectAll?: () => void;
onSelectNone?: () => void;
onInvertSelection?: () => void;
}) {
const {
currentPage,
@ -237,6 +238,7 @@ export function useListKeyboardShortcuts(props: {
pages = 0,
onSelectAll,
onSelectNone,
onInvertSelection,
} = props;
// set up hotkeys
@ -298,12 +300,14 @@ export function useListKeyboardShortcuts(props: {
useEffect(() => {
Mousetrap.bind("s a", () => onSelectAll?.());
Mousetrap.bind("s n", () => onSelectNone?.());
Mousetrap.bind("s i", () => onInvertSelection?.());
return () => {
Mousetrap.unbind("s a");
Mousetrap.unbind("s n");
Mousetrap.unbind("s i");
};
}, [onSelectAll, onSelectNone]);
}, [onSelectAll, onSelectNone, onInvertSelection]);
}
export function useListSelect<T extends IHasID = IHasID>(items: T[]) {
@ -420,6 +424,14 @@ export function useListSelect<T extends IHasID = IHasID>(items: T[]) {
setLastClickedId(undefined);
}
function onInvertSelection() {
setItemsSelected((prevSelected) => {
const selectedSet = new Set(prevSelected.map((item) => item.id));
return items.filter((item) => !selectedSet.has(item.id));
});
setLastClickedId(undefined);
}
// TODO - this is for backwards compatibility
const getSelected = useCallback(() => itemsSelected, [itemsSelected]);
@ -433,6 +445,7 @@ export function useListSelect<T extends IHasID = IHasID>(items: T[]) {
onSelectChange,
onSelectAll,
onSelectNone,
onInvertSelection,
hasSelection,
};
}

View file

@ -3,6 +3,7 @@ import {
faVenus,
faTransgenderAlt,
faMars,
faNonBinary,
} from "@fortawesome/free-solid-svg-icons";
import * as GQL from "src/core/generated-graphql";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@ -13,21 +14,34 @@ interface IIconProps {
className?: string;
}
function genderIcon(gender: GQL.GenderEnum) {
switch (gender) {
case GQL.GenderEnum.Male:
return faMars;
case GQL.GenderEnum.Female:
return faVenus;
case GQL.GenderEnum.NonBinary:
return faNonBinary;
default:
return faTransgenderAlt;
}
}
const GenderIcon: React.FC<IIconProps> = ({ gender, className }) => {
const intl = useIntl();
if (gender) {
const icon =
gender === GQL.GenderEnum.Male
? faMars
: gender === GQL.GenderEnum.Female
? faVenus
: faTransgenderAlt;
const icon = genderIcon(gender);
// new version of fontawesome doesn't seem to support titles on icons, so adding it
// to a span instead
return (
<FontAwesomeIcon
title={intl.formatMessage({ id: "gender_types." + gender })}
className={className}
icon={icon}
/>
<span title={intl.formatMessage({ id: "gender_types." + gender })}>
<FontAwesomeIcon
data-gender={gender}
className={className}
icon={icon}
/>
</span>
);
}
return null;

View file

@ -5,6 +5,7 @@ import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { PatchComponent } from "src/patch";
interface IPerformerCardGrid {
performers: GQL.PerformerDataFragment[];
@ -16,32 +17,29 @@ interface IPerformerCardGrid {
const zoomWidths = [240, 300, 375, 470];
export const PerformerCardGrid: React.FC<IPerformerCardGrid> = ({
performers,
selectedIds,
zoomIndex,
onSelectChange,
extraCriteria,
}) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
export const PerformerCardGrid: React.FC<IPerformerCardGrid> = PatchComponent(
"PerformerCardGrid",
({ performers, selectedIds, zoomIndex, onSelectChange, extraCriteria }) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{performers.map((p) => (
<PerformerCard
key={p.id}
cardWidth={cardWidth}
performer={p}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(p.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(p.id, selected, shiftKey)
}
extraCriteria={extraCriteria}
/>
))}
</div>
);
};
return (
<div className="row justify-content-center" ref={componentRef}>
{performers.map((p) => (
<PerformerCard
key={p.id}
cardWidth={cardWidth}
performer={p}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(p.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(p.id, selected, shiftKey)
}
extraCriteria={extraCriteria}
/>
))}
</div>
);
}
);

View file

@ -23,12 +23,14 @@ const PerformerCreate: React.FC = () => {
const [createPerformer] = usePerformerCreate();
async function onSave(input: GQL.PerformerCreateInput) {
async function onSave(input: GQL.PerformerCreateInput, andNew?: boolean) {
const result = await createPerformer({
variables: { input },
});
if (result.data?.performerCreate) {
history.push(`/performers/${result.data.performerCreate.id}`);
if (!andNew) {
history.push(`/performers/${result.data.performerCreate.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Button, Form, Dropdown } from "react-bootstrap";
import { Button, Form, Dropdown, SplitButton } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
@ -58,7 +58,10 @@ const isScraper = (
interface IPerformerDetails {
performer: Partial<GQL.PerformerDataFragment>;
isVisible: boolean;
onSubmit: (performer: GQL.PerformerCreateInput) => Promise<void>;
onSubmit: (
performer: GQL.PerformerCreateInput,
andNew?: boolean
) => Promise<void>;
onCancel?: () => void;
setImage: (image?: string | null) => void;
setEncodingImage: (loading: boolean) => void;
@ -345,10 +348,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
ImageUtils.onImageChange(event, onImageLoad);
}
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@ -356,6 +359,15 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const { values } = formik;
const input = {
...schema.cast(values),
custom_fields: customFieldInput(isNew, values.custom_fields),
};
onSave(input, true);
}
// set up hotkeys
useEffect(() => {
if (isVisible) {
@ -603,17 +615,33 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<FormattedMessage id="actions.clear_image" />
</Button>
</div>
<Button
variant="success"
disabled={
(!isNew && !formik.dirty) ||
!isEqual(formik.errors, {}) ||
customFieldsError !== undefined
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
{isNew ? (
<SplitButton
id="save-split-button"
variant="success"
disabled={
!isEqual(formik.errors, {}) || customFieldsError !== undefined
}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => formik.submitForm()}
>
<Dropdown.Item onClick={() => onSaveAndNewClick()}>
<FormattedMessage id="actions.save_and_new" />
</Dropdown.Item>
</SplitButton>
) : (
<Button
variant="success"
disabled={
(!isNew && !formik.dirty) ||
!isEqual(formik.errors, {}) ||
customFieldsError !== undefined
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
)}
</div>
);
}

View file

@ -7,6 +7,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
interface IProps {
isTouch: boolean;
@ -14,41 +15,44 @@ interface IProps {
header: string;
}
export const PerformerRecommendationRow: React.FC<IProps> = (props) => {
const result = useFindPerformers(props.filter);
const cardCount = result.data?.findPerformers.count;
export const PerformerRecommendationRow: React.FC<IProps> = PatchComponent(
"PerformerRecommendationRow",
(props) => {
const result = useFindPerformers(props.filter);
const cardCount = result.data?.findPerformers.count;
if (!result.loading && !cardCount) {
return null;
}
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className="performer-recommendations"
header={props.header}
link={
<Link to={`/performers?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
return (
<RecommendationRow
className="performer-recommendations"
header={props.header}
link={
<Link to={`/performers?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="performer-skeleton skeleton-card"
></div>
))
: result.data?.findPerformers.performers.map((p) => (
<PerformerCard key={p.id} performer={p} />
))}
</Slider>
</RecommendationRow>
);
};
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="performer-skeleton skeleton-card"
></div>
))
: result.data?.findPerformers.performers.map((p) => (
<PerformerCard key={p.id} performer={p} />
))}
</Slider>
</RecommendationRow>
);
}
);

View file

@ -193,17 +193,21 @@
display: flex;
}
.fa-mars {
color: #89cff0;
}
.gender-icon {
&[data-gender="FEMALE"],
&[data-gender="TRANSGENDER_FEMALE"] {
color: #f38cac;
}
.fa-venus {
color: #f38cac;
}
&[data-gender="MALE"],
&[data-gender="TRANSGENDER_MALE"] {
color: #89cff0;
}
.fa-transgender,
.fa-transgender-alt {
color: #c8a2c8;
&[data-gender="NON_BINARY"],
&[data-gender="INTERSEX"] {
color: #c8a2c8;
}
}
.performer-height .height-imperial,

View file

@ -0,0 +1,50 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { SceneQueue } from "src/models/sceneQueue";
import { SceneCard } from "./SceneCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { PatchComponent } from "src/patch";
interface ISceneCardGrid {
scenes: GQL.SlimSceneDataFragment[];
queue?: SceneQueue;
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
fromGroupId?: string;
}
const zoomWidths = [280, 340, 480, 640];
export const SceneCardGrid: React.FC<ISceneCardGrid> = PatchComponent(
"SceneCardGrid",
({ scenes, queue, selectedIds, zoomIndex, onSelectChange, fromGroupId }) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{scenes.map((scene, index) => (
<SceneCard
key={scene.id}
width={cardWidth}
scene={scene}
queue={queue}
index={index}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(scene.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(scene.id, selected, shiftKey)
}
fromGroupId={fromGroupId}
/>
))}
</div>
);
}
);

View file

@ -1,53 +0,0 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { SceneQueue } from "src/models/sceneQueue";
import { SceneCard } from "./SceneCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
interface ISceneCardsGrid {
scenes: GQL.SlimSceneDataFragment[];
queue?: SceneQueue;
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
fromGroupId?: string;
}
const zoomWidths = [280, 340, 480, 640];
export const SceneCardsGrid: React.FC<ISceneCardsGrid> = ({
scenes,
queue,
selectedIds,
zoomIndex,
onSelectChange,
fromGroupId,
}) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{scenes.map((scene, index) => (
<SceneCard
key={scene.id}
width={cardWidth}
scene={scene}
queue={queue}
index={index}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(scene.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(scene.id, selected, shiftKey)
}
fromGroupId={fromGroupId}
/>
))}
</div>
);
};

View file

@ -57,14 +57,16 @@ const SceneCreate: React.FC = () => {
return <LoadingIndicator />;
}
async function onSave(input: GQL.SceneCreateInput) {
async function onSave(input: GQL.SceneCreateInput, andNew?: boolean) {
const fileID = query.get("file_id") ?? undefined;
const result = await mutateCreateScene({
...input,
file_ids: fileID ? [fileID] : undefined,
});
if (result.data?.sceneCreate?.id) {
history.push(`/scenes/${result.data.sceneCreate.id}`);
if (!andNew) {
history.push(`/scenes/${result.data.sceneCreate.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View file

@ -1,6 +1,14 @@
import React, { useEffect, useState, useMemo } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Button, Form, Col, Row, ButtonGroup } from "react-bootstrap";
import {
Button,
Dropdown,
Form,
Col,
Row,
ButtonGroup,
SplitButton,
} from "react-bootstrap";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
@ -51,7 +59,7 @@ interface IProps {
initialCoverImage?: string;
isNew?: boolean;
isVisible: boolean;
onSubmit: (input: GQL.SceneCreateInput) => Promise<void>;
onSubmit: (input: GQL.SceneCreateInput, andNew?: boolean) => Promise<void>;
onDelete?: () => void;
}
@ -268,10 +276,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
formik.setFieldValue("groups", newGroups);
}
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@ -279,6 +287,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
onSave(input, true);
}
const encodingImage = ImageUtils.usePasteImage(onImageLoad);
function onImageLoad(imageData: string) {
@ -289,6 +302,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
ImageUtils.onImageChange(event, onImageLoad);
}
function onResetCover() {
formik.setFieldValue("cover_image", null);
}
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
setIsLoading(true);
try {
@ -737,16 +754,31 @@ export const SceneEditPanel: React.FC<IProps> = ({
<Form noValidate onSubmit={formik.handleSubmit}>
<Row className="form-container edit-buttons-container px-3 pt-3">
<div className="edit-buttons mb-3 pl-0">
<Button
className="edit-button"
variant="primary"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
{isNew ? (
<SplitButton
id="scene-save-split-button"
className="edit-button"
variant="primary"
disabled={!isEqual(formik.errors, {})}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => formik.submitForm()}
>
<Dropdown.Item onClick={() => onSaveAndNewClick()}>
<FormattedMessage id="actions.save_and_new" />
</Dropdown.Item>
</SplitButton>
) : (
<Button
className="edit-button"
variant="primary"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
)}
{onDelete && (
<Button
className="edit-button"
@ -828,6 +860,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
isEditing
onImageChange={onCoverImageChange}
onImageURL={onImageLoad}
onReset={scene.id ? onResetCover : undefined}
/>
</Form.Group>
</Col>

View file

@ -15,7 +15,7 @@ import { EditScenesDialog } from "./EditScenesDialog";
import { DeleteScenesDialog } from "./DeleteScenesDialog";
import { GenerateDialog } from "../Dialogs/GenerateDialog";
import { ExportDialog } from "../Shared/ExportDialog";
import { SceneCardsGrid } from "./SceneCardsGrid";
import { SceneCardGrid } from "./SceneCardGrid";
import { TaggerContext } from "../Tagger/context";
import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog";
import { useConfigurationContext } from "src/hooks/Config";
@ -209,7 +209,7 @@ const SceneList: React.FC<{
if (filter.displayMode === DisplayMode.Grid) {
return (
<SceneCardsGrid
<SceneCardGrid
scenes={scenes}
queue={queue}
zoomIndex={filter.zoomIndex}
@ -235,11 +235,20 @@ const SceneList: React.FC<{
scenes={scenes}
sceneQueue={queue}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.Tagger) {
return <Tagger scenes={scenes} queue={queue} />;
return (
<Tagger
scenes={scenes}
queue={queue}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
return null;
@ -513,6 +522,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
onSelectChange,
onSelectAll,
onSelectNone,
onInvertSelection,
hasSelection,
} = listSelect;
@ -530,6 +540,27 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
setShowSidebar,
});
const onCloseEditDelete = useCloseEditDelete({
closeModal,
onSelectNone,
result,
});
const onEdit = useCallback(() => {
showModal(
<EditScenesDialog selected={selectedItems} onClose={onCloseEditDelete} />
);
}, [showModal, selectedItems, onCloseEditDelete]);
const onDelete = useCallback(() => {
showModal(
<DeleteScenesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}, [showModal, selectedItems, onCloseEditDelete]);
useEffect(() => {
Mousetrap.bind("e", () => {
if (hasSelection) {
@ -547,18 +578,12 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
};
});
}, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]);
useZoomKeybinds({
zoomIndex: filter.zoomIndex,
onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)),
});
const onCloseEditDelete = useCloseEditDelete({
closeModal,
onSelectNone,
result,
});
const metadataByline = useMemo(() => {
if (cachedResult.loading) return null;
@ -627,21 +652,6 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
);
}
function onEdit() {
showModal(
<EditScenesDialog selected={selectedItems} onClose={onCloseEditDelete} />
);
}
function onDelete() {
showModal(
<DeleteScenesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.play" }),
@ -668,6 +678,11 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
onClick: () => onSelectNone(),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.invert_selection" }),
onClick: () => onInvertSelection(),
isDisplayed: () => totalCount > 0,
},
{
text: intl.formatMessage({ id: "actions.play_random" }),
onClick: playRandom,

View file

@ -0,0 +1,46 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { SceneMarkerCard } from "./SceneMarkerCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { PatchComponent } from "src/patch";
interface ISceneMarkerCardGrid {
markers: GQL.SceneMarkerDataFragment[];
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
const zoomWidths = [240, 340, 480, 640];
export const SceneMarkerCardGrid: React.FC<ISceneMarkerCardGrid> =
PatchComponent(
"SceneMarkerCardGrid",
({ markers, selectedIds, zoomIndex, onSelectChange }) => {
const [componentRef, { width: containerWidth }] =
useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{markers.map((marker, index) => (
<SceneMarkerCard
key={marker.id}
cardWidth={cardWidth}
marker={marker}
index={index}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(marker.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(marker.id, selected, shiftKey)
}
/>
))}
</div>
);
}
);

View file

@ -1,45 +0,0 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { SceneMarkerCard } from "./SceneMarkerCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
interface ISceneMarkerCardsGrid {
markers: GQL.SceneMarkerDataFragment[];
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
const zoomWidths = [240, 340, 480, 640];
export const SceneMarkerCardsGrid: React.FC<ISceneMarkerCardsGrid> = ({
markers,
selectedIds,
zoomIndex,
onSelectChange,
}) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{markers.map((marker, index) => (
<SceneMarkerCard
key={marker.id}
cardWidth={cardWidth}
marker={marker}
index={index}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(marker.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(marker.id, selected, shiftKey)
}
/>
))}
</div>
);
};

View file

@ -14,7 +14,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { MarkerWallPanel } from "./SceneMarkerWallPanel";
import { View } from "../List/views";
import { SceneMarkerCardsGrid } from "./SceneMarkerCardsGrid";
import { SceneMarkerCardGrid } from "./SceneMarkerCardGrid";
import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog";
import { EditSceneMarkersDialog } from "./EditSceneMarkersDialog";
import { PatchComponent } from "src/patch";
@ -101,13 +101,15 @@ export const SceneMarkerList: React.FC<ISceneMarkerList> = PatchComponent(
<MarkerWallPanel
markers={result.data.findSceneMarkers.scene_markers}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<SceneMarkerCardsGrid
<SceneMarkerCardGrid
markers={result.data.findSceneMarkers.scene_markers}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}

View file

@ -7,6 +7,7 @@ import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { SceneMarkerCard } from "./SceneMarkerCard";
import { PatchComponent } from "src/patch";
interface IProps {
isTouch: boolean;
@ -14,46 +15,51 @@ interface IProps {
header: string;
}
export const SceneMarkerRecommendationRow: React.FC<IProps> = (props) => {
const result = useFindSceneMarkers(props.filter);
const cardCount = result.data?.findSceneMarkers.count;
export const SceneMarkerRecommendationRow: React.FC<IProps> = PatchComponent(
"SceneMarkerRecommendationRow",
(props) => {
const result = useFindSceneMarkers(props.filter);
const cardCount = result.data?.findSceneMarkers.count;
if (!result.loading && !cardCount) {
return null;
}
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className="scene-marker-recommendations"
header={props.header}
link={
<Link to={`/scenes/markers?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
return (
<RecommendationRow
className="scene-marker-recommendations"
header={props.header}
link={
<Link to={`/scenes/markers?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="scene-marker-skeleton skeleton-card"
></div>
))
: result.data?.findSceneMarkers.scene_markers.map((marker, index) => (
<SceneMarkerCard
key={marker.id}
marker={marker}
index={index}
zoomIndex={1}
/>
))}
</Slider>
</RecommendationRow>
);
};
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="scene-marker-skeleton skeleton-card"
></div>
))
: result.data?.findSceneMarkers.scene_markers.map(
(marker, index) => (
<SceneMarkerCard
key={marker.id}
marker={marker}
index={index}
zoomIndex={1}
/>
)
)}
</Slider>
</RecommendationRow>
);
}
);

View file

@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Form } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import Gallery, {
GalleryI,
@ -10,6 +11,7 @@ import { objectTitle } from "src/core/files";
import { Link, useHistory } from "react-router-dom";
import { TruncatedText } from "../Shared/TruncatedText";
import TextUtils from "src/utils/text";
import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect";
import cx from "classnames";
import NavUtils from "src/utils/navigation";
import { markerTitle } from "src/core/markers";
@ -35,11 +37,20 @@ interface IMarkerPhoto {
interface IExtraProps {
maxHeight: number;
selected?: boolean;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
selecting?: boolean;
}
export const MarkerWallItem: React.FC<
RenderImageProps<IMarkerPhoto> & IExtraProps
> = (props: RenderImageProps<IMarkerPhoto> & IExtraProps) => {
const { dragProps } = useDragMoveSelect({
selecting: props.selecting || false,
selected: props.selected || false,
onSelectedChanged: props.onSelectedChanged,
});
const { configuration } = useConfigurationContext();
const playSound = configuration?.interface.soundOnPreview ?? false;
const showTitle = configuration?.interface.wallShowTitle ?? false;
@ -63,6 +74,12 @@ export const MarkerWallItem: React.FC<
}
var handleClick = function handleClick(event: React.MouseEvent) {
if (props.selecting && props.onSelectedChanged) {
props.onSelectedChanged(!props.selected, event.shiftKey);
event.preventDefault();
event.stopPropagation();
return;
}
if (props.onClick) {
props.onClick(event, { index: props.index });
}
@ -75,16 +92,32 @@ export const MarkerWallItem: React.FC<
const title = wallItemTitle(marker);
const tagNames = marker.tags.map((p) => p.name);
let shiftKey = false;
return (
<div
className={cx("wall-item", { "show-title": showTitle })}
role="button"
onClick={handleClick}
{...dragProps}
style={{
...divStyle,
width,
height,
}}
>
{props.onSelectedChanged && (
<Form.Control
type="checkbox"
className="wall-item-check mousetrap"
checked={props.selected}
onChange={() => props.onSelectedChanged!(!props.selected, shiftKey)}
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
shiftKey = event.shiftKey;
event.stopPropagation();
}}
/>
)}
<ImagePreview
loading="lazy"
loop={video}
@ -124,6 +157,9 @@ export const MarkerWallItem: React.FC<
interface IMarkerWallProps {
markers: GQL.SceneMarkerDataFragment[];
zoomIndex: number;
selectedIds?: Set<string>;
onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void;
selecting?: boolean;
}
// HACK: typescript doesn't allow Gallery to accept a parameter for some reason
@ -163,7 +199,13 @@ const breakpointZoomHeights = [
{ minWidth: 1400, heights: [160, 240, 300, 480] },
];
const MarkerWall: React.FC<IMarkerWallProps> = ({ markers, zoomIndex }) => {
const MarkerWall: React.FC<IMarkerWallProps> = ({
markers,
zoomIndex,
selectedIds,
onSelectChange,
selecting,
}) => {
const history = useHistory();
const containerRef = React.useRef<HTMLDivElement>(null);
@ -233,6 +275,7 @@ const MarkerWall: React.FC<IMarkerWallProps> = ({ markers, zoomIndex }) => {
const renderImage = useCallback(
(props: RenderImageProps<IMarkerPhoto>) => {
const markerId = props.photo.marker.id;
return (
<MarkerWallItem
{...props}
@ -240,10 +283,18 @@ const MarkerWall: React.FC<IMarkerWallProps> = ({ markers, zoomIndex }) => {
targetRowHeight(containerRef.current?.offsetWidth ?? 0) *
maxHeightFactor
}
selected={selectedIds?.has(markerId)}
onSelectedChanged={
onSelectChange
? (selected, shiftKey) =>
onSelectChange(markerId, selected, shiftKey)
: undefined
}
selecting={selecting}
/>
);
},
[targetRowHeight]
[targetRowHeight, selectedIds, onSelectChange, selecting]
);
return (
@ -266,11 +317,24 @@ const MarkerWall: React.FC<IMarkerWallProps> = ({ markers, zoomIndex }) => {
interface IMarkerWallPanelProps {
markers: GQL.SceneMarkerDataFragment[];
zoomIndex: number;
selectedIds?: Set<string>;
onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void;
}
export const MarkerWallPanel: React.FC<IMarkerWallPanelProps> = ({
markers,
zoomIndex,
selectedIds,
onSelectChange,
}) => {
return <MarkerWall markers={markers} zoomIndex={zoomIndex} />;
const selecting = !!selectedIds && selectedIds.size > 0;
return (
<MarkerWall
markers={markers}
zoomIndex={zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
selecting={selecting}
/>
);
};

View file

@ -8,6 +8,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
interface IProps {
isTouch: boolean;
@ -15,48 +16,54 @@ interface IProps {
header: string;
}
export const SceneRecommendationRow: React.FC<IProps> = (props) => {
const result = useFindScenes(props.filter);
const cardCount = result.data?.findScenes.count;
export const SceneRecommendationRow: React.FC<IProps> = PatchComponent(
"SceneRecommendationRow",
(props) => {
const result = useFindScenes(props.filter);
const cardCount = result.data?.findScenes.count;
const queue = useMemo(() => {
return SceneQueue.fromListFilterModel(props.filter);
}, [props.filter]);
const queue = useMemo(() => {
return SceneQueue.fromListFilterModel(props.filter);
}, [props.filter]);
if (!result.loading && !cardCount) {
return null;
}
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className="scene-recommendations"
header={props.header}
link={
<Link to={`/scenes?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
return (
<RecommendationRow
className="scene-recommendations"
header={props.header}
link={
<Link to={`/scenes?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={`_${i}`} className="scene-skeleton skeleton-card"></div>
))
: result.data?.findScenes.scenes.map((scene, index) => (
<SceneCard
key={scene.id}
scene={scene}
queue={queue}
index={index}
zoomIndex={1}
/>
))}
</Slider>
</RecommendationRow>
);
};
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="scene-skeleton skeleton-card"
></div>
))
: result.data?.findScenes.scenes.map((scene, index) => (
<SceneCard
key={scene.id}
scene={scene}
queue={queue}
index={index}
zoomIndex={1}
/>
))}
</Slider>
</RecommendationRow>
);
}
);

View file

@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Form } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { SceneQueue } from "src/models/sceneQueue";
import Gallery, {
@ -12,6 +13,7 @@ import { Link, useHistory } from "react-router-dom";
import { TruncatedText } from "../Shared/TruncatedText";
import TextUtils from "src/utils/text";
import { useIntl } from "react-intl";
import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect";
import cx from "classnames";
interface IScenePhoto {
@ -22,6 +24,9 @@ interface IScenePhoto {
interface IExtraProps {
maxHeight: number;
selected?: boolean;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
selecting?: boolean;
}
export const SceneWallItem: React.FC<
@ -29,6 +34,12 @@ export const SceneWallItem: React.FC<
> = (props: RenderImageProps<IScenePhoto> & IExtraProps) => {
const intl = useIntl();
const { dragProps } = useDragMoveSelect({
selecting: props.selecting || false,
selected: props.selected || false,
onSelectedChanged: props.onSelectedChanged,
});
const { configuration } = useConfigurationContext();
const playSound = configuration?.interface.soundOnPreview ?? false;
const showTitle = configuration?.interface.wallShowTitle ?? false;
@ -52,6 +63,12 @@ export const SceneWallItem: React.FC<
}
var handleClick = function handleClick(event: React.MouseEvent) {
if (props.selecting && props.onSelectedChanged) {
props.onSelectedChanged(!props.selected, event.shiftKey);
event.preventDefault();
event.stopPropagation();
return;
}
if (props.onClick) {
props.onClick(event, { index: props.index });
}
@ -68,16 +85,32 @@ export const SceneWallItem: React.FC<
? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")]
: performerNames;
let shiftKey = false;
return (
<div
className={cx("wall-item", { "show-title": showTitle })}
role="button"
onClick={handleClick}
{...dragProps}
style={{
...divStyle,
width,
height,
}}
>
{props.onSelectedChanged && (
<Form.Control
type="checkbox"
className="wall-item-check mousetrap"
checked={props.selected}
onChange={() => props.onSelectedChanged!(!props.selected, shiftKey)}
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
shiftKey = event.shiftKey;
event.stopPropagation();
}}
/>
)}
<ImagePreview
loading="lazy"
loop={video}
@ -132,6 +165,9 @@ interface ISceneWallProps {
scenes: GQL.SlimSceneDataFragment[];
sceneQueue?: SceneQueue;
zoomIndex: number;
selectedIds?: Set<string>;
onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void;
selecting?: boolean;
}
// HACK: typescript doesn't allow Gallery to accept a parameter for some reason
@ -148,6 +184,9 @@ const SceneWall: React.FC<ISceneWallProps> = ({
scenes,
sceneQueue,
zoomIndex,
selectedIds,
onSelectChange,
selecting,
}) => {
const history = useHistory();
@ -223,6 +262,7 @@ const SceneWall: React.FC<ISceneWallProps> = ({
const renderImage = useCallback(
(props: RenderImageProps<IScenePhoto>) => {
const sceneId = props.photo.scene.id;
return (
<SceneWallItem
{...props}
@ -230,10 +270,18 @@ const SceneWall: React.FC<ISceneWallProps> = ({
targetRowHeight(containerRef.current?.offsetWidth ?? 0) *
maxHeightFactor
}
selected={selectedIds?.has(sceneId)}
onSelectedChanged={
onSelectChange
? (selected, shiftKey) =>
onSelectChange(sceneId, selected, shiftKey)
: undefined
}
selecting={selecting}
/>
);
},
[targetRowHeight]
[targetRowHeight, selectedIds, onSelectChange, selecting]
);
return (
@ -257,14 +305,26 @@ interface ISceneWallPanelProps {
scenes: GQL.SlimSceneDataFragment[];
sceneQueue?: SceneQueue;
zoomIndex: number;
selectedIds?: Set<string>;
onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void;
}
export const SceneWallPanel: React.FC<ISceneWallPanelProps> = ({
scenes,
sceneQueue,
zoomIndex,
selectedIds,
onSelectChange,
}) => {
const selecting = !!selectedIds && selectedIds.size > 0;
return (
<SceneWall scenes={scenes} sceneQueue={sceneQueue} zoomIndex={zoomIndex} />
<SceneWall
scenes={scenes}
sceneQueue={sceneQueue}
zoomIndex={zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
selecting={selecting}
/>
);
};

View file

@ -1,4 +1,4 @@
import { Button, Modal } from "react-bootstrap";
import { Button, Dropdown, Modal, SplitButton } from "react-bootstrap";
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { ImageInput } from "./ImageInput";
@ -10,6 +10,7 @@ interface IProps {
isEditing: boolean;
onToggleEdit: () => void;
onSave: () => void;
onSaveAndNew?: () => void;
saveDisabled?: boolean;
onDelete: () => void;
onAutoTag?: () => void;
@ -48,6 +49,23 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
function renderSaveButton() {
if (!props.isEditing) return;
if (props.isNew && props.onSaveAndNew) {
return (
<SplitButton
id="save-split-button"
variant="success"
className="save"
disabled={props.saveDisabled}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => props.onSave()}
>
<Dropdown.Item onClick={() => props.onSaveAndNew!()}>
<FormattedMessage id="actions.save_and_new" />
</Dropdown.Item>
</SplitButton>
);
}
return (
<Button
variant="success"

View file

@ -18,6 +18,7 @@ interface IImageInput {
text?: string;
onImageChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onImageURL?: (url: string) => void;
onReset?: () => void;
acceptSVG?: boolean;
}
@ -27,7 +28,14 @@ function acceptExtensions(acceptSVG: boolean = false) {
export const ImageInput: React.FC<IImageInput> = PatchComponent(
"ImageInput",
({ isEditing, text, onImageChange, onImageURL, acceptSVG = false }) => {
({
isEditing,
text,
onImageChange,
onImageURL,
onReset,
acceptSVG = false,
}) => {
const [isShowDialog, setIsShowDialog] = useState(false);
const [url, setURL] = useState("");
const intl = useIntl();
@ -137,6 +145,11 @@ export const ImageInput: React.FC<IImageInput> = PatchComponent(
{text ?? intl.formatMessage({ id: "actions.set_image" })}
</Button>
</OverlayTrigger>
{onReset && (
<Button variant="danger" className="mr-2" onClick={onReset}>
{intl.formatMessage({ id: "actions.clear_image" })}
</Button>
)}
</>
);
}

View file

@ -62,10 +62,23 @@
padding: 0;
row-gap: 0.5rem;
.btn {
> .btn {
margin-right: 0.5rem;
white-space: nowrap;
}
> .btn-group {
margin-right: 0.5rem;
.btn {
margin-right: 0;
}
// Show caret on split button dropdown toggle
.dropdown-toggle-split::after {
content: "";
}
}
}
.col-md-8 .details-edit div:nth-last-child(2),
@ -290,6 +303,10 @@ button.collapse-button {
opacity: 0;
width: 1.2rem;
&:checked {
opacity: 0.75;
}
@media (hover: none), (pointer: coarse) {
// always show card controls when hovering not supported
opacity: 0.25;
@ -303,10 +320,6 @@ button.collapse-button {
.card-check {
padding-left: 15px;
&:checked {
opacity: 0.75;
}
@media (hover: none), (pointer: coarse) {
// and make it bigger when hovering not supported
width: 1.5rem;
@ -320,6 +333,34 @@ button.collapse-button {
}
}
.search-item-check,
.wall-item-check {
height: 1.2rem;
width: 1.2rem;
}
// Wall item checkbox styles
.wall-item-check {
left: 0.5rem;
opacity: 0;
position: absolute;
top: 0.5rem;
z-index: 10;
&:checked {
opacity: 0.75;
}
@media (hover: none) {
opacity: 0.25;
}
}
.wall-item:hover .wall-item-check {
opacity: 0.75;
transition: opacity 0.5s;
}
.TruncatedText {
-webkit-box-orient: vertical;
display: -webkit-box;

View file

@ -5,6 +5,7 @@ import {
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { StudioCard } from "./StudioCard";
import { PatchComponent } from "src/patch";
interface IStudioCardGrid {
studios: GQL.StudioDataFragment[];
@ -16,32 +17,29 @@ interface IStudioCardGrid {
const zoomWidths = [280, 340, 420, 560];
export const StudioCardGrid: React.FC<IStudioCardGrid> = ({
studios,
fromParent,
selectedIds,
zoomIndex,
onSelectChange,
}) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
export const StudioCardGrid: React.FC<IStudioCardGrid> = PatchComponent(
"StudioCardGrid",
({ studios, fromParent, selectedIds, zoomIndex, onSelectChange }) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{studios.map((studio) => (
<StudioCard
key={studio.id}
cardWidth={cardWidth}
studio={studio}
zoomIndex={zoomIndex}
hideParent={fromParent}
selecting={selectedIds.size > 0}
selected={selectedIds.has(studio.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(studio.id, selected, shiftKey)
}
/>
))}
</div>
);
};
return (
<div className="row justify-content-center" ref={componentRef}>
{studios.map((studio) => (
<StudioCard
key={studio.id}
cardWidth={cardWidth}
studio={studio}
zoomIndex={zoomIndex}
hideParent={fromParent}
selecting={selectedIds.size > 0}
selected={selectedIds.has(studio.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(studio.id, selected, shiftKey)
}
/>
))}
</div>
);
}
);

View file

@ -26,12 +26,14 @@ const StudioCreate: React.FC = () => {
const [createStudio] = useStudioCreate();
async function onSave(input: GQL.StudioCreateInput) {
async function onSave(input: GQL.StudioCreateInput, andNew?: boolean) {
const result = await createStudio({
variables: { input },
});
if (result.data?.studioCreate?.id) {
history.push(`/studios/${result.data.studioCreate.id}`);
if (!andNew) {
history.push(`/studios/${result.data.studioCreate.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View file

@ -28,7 +28,7 @@ import { StudioScrapeDialog } from "./StudioScrapeDialog";
interface IStudioEditPanel {
studio: Partial<GQL.StudioDataFragment>;
onSubmit: (studio: GQL.StudioCreateInput) => Promise<void>;
onSubmit: (studio: GQL.StudioCreateInput, andNew?: boolean) => Promise<void>;
onCancel: () => void;
setImage: (image?: string | null) => void;
setEncodingImage: (loading: boolean) => void;
@ -138,10 +138,10 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
};
});
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@ -149,6 +149,11 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
onSave(input, true);
}
function onImageLoad(imageData: string | null) {
formik.setFieldValue("image", imageData);
}
@ -432,9 +437,9 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
)}
<hr />
{renderInputField("ignore_auto_tag", "checkbox")}
{renderButtons("mt-3")}
</Form>
{renderButtons("mt-3")}
</>
);
};

View file

@ -7,6 +7,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
interface IProps {
isTouch: boolean;
@ -14,41 +15,44 @@ interface IProps {
header: string;
}
export const StudioRecommendationRow: React.FC<IProps> = (props) => {
const result = useFindStudios(props.filter);
const cardCount = result.data?.findStudios.count;
export const StudioRecommendationRow: React.FC<IProps> = PatchComponent(
"StudioRecommendationRow",
(props) => {
const result = useFindStudios(props.filter);
const cardCount = result.data?.findStudios.count;
if (!result.loading && !cardCount) {
return null;
}
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className="studio-recommendations"
header={props.header}
link={
<Link to={`/studios?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
return (
<RecommendationRow
className="studio-recommendations"
header={props.header}
link={
<Link to={`/studios?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="studio-skeleton skeleton-card"
></div>
))
: result.data?.findStudios.studios.map((s) => (
<StudioCard key={s.id} studio={s} hideParent={true} />
))}
</Slider>
</RecommendationRow>
);
};
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="studio-skeleton skeleton-card"
></div>
))
: result.data?.findStudios.studios.map((s) => (
<StudioCard key={s.id} studio={s} hideParent={true} />
))}
</Slider>
</RecommendationRow>
);
}
);

View file

@ -22,7 +22,17 @@ const Scene: React.FC<{
queue?: SceneQueue;
index: number;
showLightboxImage: (imagePath: string) => void;
}> = ({ scene, searchResult, queue, index, showLightboxImage }) => {
selected?: boolean;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
}> = ({
scene,
searchResult,
queue,
index,
showLightboxImage,
selected,
onSelectedChanged,
}) => {
const intl = useIntl();
const { currentSource, doSceneQuery, doSceneFragmentScrape, loading } =
useContext(TaggerStateContext);
@ -71,6 +81,8 @@ const Scene: React.FC<{
showLightboxImage={showLightboxImage}
queue={queue}
index={index}
selected={selected}
onSelectedChanged={onSelectedChanged}
>
{searchResult && searchResult.results?.length ? (
<SceneSearchResults scenes={searchResult.results} target={scene} />
@ -82,9 +94,16 @@ const Scene: React.FC<{
interface ITaggerProps {
scenes: GQL.SlimSceneDataFragment[];
queue?: SceneQueue;
selectedIds: Set<string>;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
export const Tagger: React.FC<ITaggerProps> = ({
scenes,
queue,
selectedIds,
onSelectChange,
}) => {
const {
sources,
setCurrentSource,
@ -103,6 +122,8 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
const intl = useIntl();
const hasSelection = selectedIds.size > 0;
function handleSourceSelect(e: React.ChangeEvent<HTMLSelectElement>) {
setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value));
}
@ -211,7 +232,12 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
return;
}
if (scenes.length === 0) {
// Use selected scenes if any, otherwise all scenes
const scenesToScrape = hasSelection
? scenes.filter((s) => selectedIds.has(s.id))
: scenes;
if (scenesToScrape.length === 0) {
return;
}
@ -232,15 +258,20 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
);
}
// Change button text based on selection state
const buttonTextId = hasSelection
? "component_tagger.verb_scrape_selected"
: "component_tagger.verb_scrape_all";
return (
<div className="ml-1">
<OperationButton
disabled={loading}
operation={async () => {
await doMultiSceneFragmentScrape(scenes.map((s) => s.id));
await doMultiSceneFragmentScrape(scenesToScrape.map((s) => s.id));
}}
>
{intl.formatMessage({ id: "component_tagger.verb_scrape_all" })}
{intl.formatMessage({ id: buttonTextId })}
</OperationButton>
{multiError && (
<>
@ -276,6 +307,10 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
index={i}
showLightboxImage={showLightboxImage}
queue={queue}
selected={selectedIds.has(s.id)}
onSelectedChanged={(selected, shiftKey) =>
onSelectChange(s.id, selected, shiftKey)
}
/>
))}
</div>

View file

@ -116,6 +116,8 @@ interface ITaggerScene {
showLightboxImage: (imagePath: string) => void;
queue?: SceneQueue;
index?: number;
selected?: boolean;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
}
export const TaggerScene: React.FC<PropsWithChildren<ITaggerScene>> = ({
@ -129,6 +131,8 @@ export const TaggerScene: React.FC<PropsWithChildren<ITaggerScene>> = ({
showLightboxImage,
queue,
index,
selected,
onSelectedChanged,
}) => {
const { config } = useContext(TaggerStateContext);
const [queryString, setQueryString] = useState<string>("");
@ -235,10 +239,28 @@ export const TaggerScene: React.FC<PropsWithChildren<ITaggerScene>> = ({
history.push(link);
}
let shiftKey = false;
return (
<div key={scene.id} className="mt-3 search-item">
<div className="row">
<div className="col col-lg-6 overflow-hidden align-items-center d-flex flex-column flex-sm-row">
{onSelectedChanged && (
<div className="col-auto d-flex align-items-start pt-2 pr-2">
<Form.Control
type="checkbox"
className="search-item-check mousetrap"
checked={selected}
onChange={() => onSelectedChanged(!selected, shiftKey)}
onClick={(
event: React.MouseEvent<HTMLInputElement, MouseEvent>
) => {
shiftKey = event.shiftKey;
event.stopPropagation();
}}
/>
</div>
)}
<div className="col-12 col-lg overflow-hidden align-items-center d-flex flex-column flex-sm-row">
<div className="scene-card mr-3">
<Link to={url}>
<ScenePreview
@ -256,7 +278,7 @@ export const TaggerScene: React.FC<PropsWithChildren<ITaggerScene>> = ({
<TruncatedText text={objectTitle(scene)} lineCount={2} />
</Link>
</div>
<div className="col-md-6 my-1">
<div className="col-12 col-lg my-1">
<div>
{renderQueryForm()}
{scrapeSceneFragment ? (

View file

@ -56,6 +56,10 @@
}
}
.search-item-check {
cursor: pointer;
}
.search-result {
background-color: rgba(61, 80, 92, 0.3);
padding: 1rem 0;

View file

@ -5,6 +5,7 @@ import {
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { TagCard } from "./TagCard";
import { PatchComponent } from "src/patch";
interface ITagCardGrid {
tags: (GQL.TagDataFragment | GQL.TagListDataFragment)[];
@ -15,30 +16,28 @@ interface ITagCardGrid {
const zoomWidths = [280, 340, 480, 640];
export const TagCardGrid: React.FC<ITagCardGrid> = ({
tags,
selectedIds,
zoomIndex,
onSelectChange,
}) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
export const TagCardGrid: React.FC<ITagCardGrid> = PatchComponent(
"TagCardGrid",
({ tags, selectedIds, zoomIndex, onSelectChange }) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{tags.map((tag) => (
<TagCard
key={tag.id}
cardWidth={cardWidth}
tag={tag}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(tag.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(tag.id, selected, shiftKey)
}
/>
))}
</div>
);
};
return (
<div className="row justify-content-center" ref={componentRef}>
{tags.map((tag) => (
<TagCard
key={tag.id}
cardWidth={cardWidth}
tag={tag}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(tag.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(tag.id, selected, shiftKey)
}
/>
))}
</div>
);
}
);

View file

@ -25,7 +25,7 @@ const TagCreate: React.FC = () => {
const [createTag] = useTagCreate();
async function onSave(input: GQL.TagCreateInput) {
async function onSave(input: GQL.TagCreateInput, andNew?: boolean) {
const oldRelations = {
parents: [],
children: [],
@ -39,7 +39,9 @@ const TagCreate: React.FC = () => {
parents: created.parents,
children: created.children,
});
history.push(`/tags/${created.id}`);
if (!andNew) {
history.push(`/tags/${created.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View file

@ -23,7 +23,7 @@ import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal";
interface ITagEditPanel {
tag: Partial<GQL.TagDataFragment>;
onSubmit: (tag: GQL.TagCreateInput) => Promise<void>;
onSubmit: (tag: GQL.TagCreateInput, andNew?: boolean) => Promise<void>;
onCancel: () => void;
onDelete: () => void;
setImage: (image?: string | null) => void;
@ -122,10 +122,10 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
};
});
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@ -133,6 +133,11 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
onSave(input, true);
}
const encodingImage = ImageUtils.usePasteImage(onImageLoad);
useEffect(() => {
@ -272,6 +277,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
isEditing
onToggleEdit={onCancel}
onSave={formik.handleSubmit}
onSaveAndNew={isNew ? onSaveAndNewClick : undefined}
saveDisabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}

View file

@ -7,6 +7,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
interface IProps {
isTouch: boolean;
@ -14,38 +15,41 @@ interface IProps {
header: string;
}
export const TagRecommendationRow: React.FC<IProps> = (props) => {
const result = useFindTags(props.filter);
const cardCount = result.data?.findTags.count;
export const TagRecommendationRow: React.FC<IProps> = PatchComponent(
"TagRecommendationRow",
(props) => {
const result = useFindTags(props.filter);
const cardCount = result.data?.findTags.count;
if (!result.loading && !cardCount) {
return null;
}
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className="tag-recommendations"
header={props.header}
link={
<Link to={`/tags?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
return (
<RecommendationRow
className="tag-recommendations"
header={props.header}
link={
<Link to={`/tags?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={`_${i}`} className="tag-skeleton skeleton-card"></div>
))
: result.data?.findTags.tags.map((p) => (
<TagCard key={p.id} tag={p} zoomIndex={0} />
))}
</Slider>
</RecommendationRow>
);
};
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={`_${i}`} className="tag-skeleton skeleton-card"></div>
))
: result.data?.findTags.tags.map((p) => (
<TagCard key={p.id} tag={p} zoomIndex={0} />
))}
</Slider>
</RecommendationRow>
);
}
);

View file

@ -41,6 +41,7 @@
| `Ctrl + End` | Go to last page of results |
| `s a` | Select all on page |
| `s n` | Unselect all |
| `s i` | Invert selection |
| `e` | Edit selected |
| `d d` | Delete selected |

View file

@ -687,6 +687,11 @@ div.dropdown-menu {
.edit-button {
margin-right: 10px;
// Show caret on split button dropdown toggle
&.btn-group .dropdown-toggle-split::after {
content: "";
}
}
.wrap-tags {

View file

@ -105,6 +105,7 @@
"reshuffle": "Reshuffle",
"running": "running",
"save": "Save",
"save_and_new": "Save & New",
"save_delete_settings": "Use these options by default when deleting",
"save_filter": "Save filter",
"scan": "Scan",
@ -117,6 +118,7 @@
"select_entity": "Select {entityType}",
"select_folders": "Select folders",
"select_none": "Select None",
"invert_selection": "Invert Selection",
"selective_auto_tag": "Selective Auto Tag",
"selective_clean": "Selective Clean",
"selective_scan": "Selective Scan",
@ -230,6 +232,7 @@
"verb_match_tag": "Match Tag",
"verb_matched": "Matched",
"verb_scrape_all": "Scrape All",
"verb_scrape_selected": "Scrape Selected",
"verb_submit_fp": "Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}",
"verb_toggle_config": "{toggle} {configuration}",
"verb_toggle_unmatched": "{toggle} unmatched scenes"

View file

@ -617,7 +617,9 @@ declare namespace PluginApi {
const FontAwesomeBrands: typeof import("@fortawesome/free-brands-svg-icons");
const Intl: typeof import("react-intl");
const Mousetrap: typeof import("mousetrap");
const ReactFontAwesome: typeof import("@fortawesome/react-fontawesome");
const ReactSelect: typeof import("react-select");
const ReactSlick: typeof import("@ant-design/react-slick");
// @ts-expect-error
import { MousetrapStatic } from "mousetrap";
@ -671,23 +673,29 @@ declare namespace PluginApi {
"GalleryCard.Image": React.FC<any>;
"GalleryCard.Overlays": React.FC<any>;
"GalleryCard.Popovers": React.FC<any>;
GalleryCardGrid: React.FC<any>;
GalleryAddPanel: React.FC<any>;
GalleryIDSelect: React.FC<any>;
GalleryImagesPanel: React.FC<any>;
GalleryList: React.FC<any>;
GalleryRecommendationRow: React.FC<any>;
GallerySelect: React.FC<any>;
GridCard: React.FC<any>;
GroupCard: React.FC<any>;
GroupCardGrid: React.FC<any>;
GroupIDSelect: React.FC<any>;
GroupList: React.FC<any>;
GroupRecommendationRow: React.FC<any>;
GroupSelect: React.FC<any>;
GroupSubGroupsPanel: React.FC<any>;
HeaderImage: React.FC<any>;
HoverPopover: React.FC<any>;
Icon: React.FC<any>;
ImageCard: React.FC<any>;
ImageCardGrid: React.FC<any>;
ImageInput: React.FC<any>;
ImageList: React.FC<any>;
ImageRecommendationRow: React.FC<any>;
LightboxLink: React.FC<any>;
LoadingIndicator: React.FC<any>;
"MainNavBar.MenuItems": React.FC<any>;
@ -696,6 +704,7 @@ declare namespace PluginApi {
NumberSetting: React.FC<any>;
PerformerAppearsWithPanel: React.FC<any>;
PerformerCard: React.FC<any>;
PerformerCardGrid: React.FC<any>;
"PerformerCard.Details": React.FC<any>;
"PerformerCard.Image": React.FC<any>;
"PerformerCard.Overlays": React.FC<any>;
@ -710,12 +719,14 @@ declare namespace PluginApi {
PerformerImagesPanel: React.FC<any>;
PerformerList: React.FC<any>;
PerformerPage: React.FC<any>;
PerformerRecommendationRow: React.FC<any>;
PerformerScenesPanel: React.FC<any>;
PerformerSelect: React.FC<any>;
PluginSettings: React.FC<any>;
RatingNumber: React.FC<any>;
RatingStars: React.FC<any>;
RatingSystem: React.FC<any>;
RecommendationRow: React.FC<any>;
SceneFileInfoPanel: React.FC<any>;
SceneIDSelect: React.FC<any>;
ScenePage: React.FC<any>;
@ -727,13 +738,17 @@ declare namespace PluginApi {
"SceneCard.Image": React.FC<any>;
"SceneCard.Overlays": React.FC<any>;
"SceneCard.Popovers": React.FC<any>;
SceneCardGrid: React.FC<any>;
SceneList: React.FC<any>;
SceneListOperations: React.FC<any>;
SceneMarkerCard: React.FC<any>;
"SceneMarkerCard.Details": React.FC<any>;
"SceneMarkerCard.Image": React.FC<any>;
"SceneMarkerCard.Popovers": React.FC<any>;
SceneMarkerCardGrid: React.FC<any>;
SceneMarkerList: React.FC<any>;
SceneMarkerRecommendationRow: React.FC<any>;
SceneRecommendationRow: React.FC<any>;
SelectSetting: React.FC<any>;
Setting: React.FC<any>;
SettingGroup: React.FC<any>;
@ -741,9 +756,11 @@ declare namespace PluginApi {
StringListSetting: React.FC<any>;
StringSetting: React.FC<any>;
StudioCard: React.FC<any>;
StudioCardGrid: React.FC<any>;
StudioDetailsPanel: React.FC<any>;
StudioIDSelect: React.FC<any>;
StudioList: React.FC<any>;
StudioRecommendationRow: React.FC<any>;
StudioSelect: React.FC<any>;
SweatDrops: React.FC<any>;
TabTitleCounter: React.FC<any>;
@ -753,8 +770,10 @@ declare namespace PluginApi {
"TagCard.Overlays": React.FC<any>;
"TagCard.Popovers": React.FC<any>;
"TagCard.Title": React.FC<any>;
TagCardGrid: React.FC<any>;
TagLink: React.FC<any>;
TagList: React.FC<any>;
TagRecommendationRow: React.FC<any>;
TagSelect: React.FC<any>;
TruncatedText: React.FC<any>;
};

View file

@ -12,7 +12,9 @@ import * as Intl from "react-intl";
import * as FontAwesomeSolid from "@fortawesome/free-solid-svg-icons";
import * as FontAwesomeRegular from "@fortawesome/free-regular-svg-icons";
import * as FontAwesomeBrands from "@fortawesome/free-brands-svg-icons";
import * as ReactFontAwesome from "@fortawesome/react-fontawesome";
import * as ReactSelect from "react-select";
import * as ReactSlick from "@ant-design/react-slick";
import { useSpriteInfo } from "./hooks/sprite";
import { useToast } from "./hooks/Toast";
import Event from "./hooks/event";
@ -78,7 +80,9 @@ export const PluginApi = {
FontAwesomeBrands,
Mousetrap,
MousetrapPause,
ReactFontAwesome,
ReactSelect,
ReactSlick,
},
register: {
// register a route to be added to the main router