mirror of
https://github.com/stashapp/stash.git
synced 2026-04-24 07:53:31 +02:00
Merge branch 'develop' into feature/studio-image-from-stash-id
This commit is contained in:
commit
58ba5b9862
85 changed files with 1798 additions and 937 deletions
11
go.mod
11
go.mod
|
|
@ -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
24
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
||||
×tampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil},
|
||||
×tampCriterionHandler{tagFilter.UpdatedAt, "tags.updated_at", nil},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
)
|
||||
);
|
||||
|
|
|
|||
43
ui/v2.5/src/components/Galleries/GalleryCardGrid.tsx
Normal file
43
ui/v2.5/src/components/Galleries/GalleryCardGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
47
ui/v2.5/src/components/Images/ImageCardGrid.tsx
Normal file
47
ui/v2.5/src/components/Images/ImageCardGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ const emptyState: IListContextState = {
|
|||
onSelectChange: () => {},
|
||||
onSelectAll: () => {},
|
||||
onSelectNone: () => {},
|
||||
onInvertSelection: () => {},
|
||||
items: [],
|
||||
hasSelection: false,
|
||||
selectedItems: [],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
50
ui/v2.5/src/components/Scenes/SceneCardGrid.tsx
Normal file
50
ui/v2.5/src/components/Scenes/SceneCardGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
46
ui/v2.5/src/components/Scenes/SceneMarkerCardGrid.tsx
Normal file
46
ui/v2.5/src/components/Scenes/SceneMarkerCardGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.search-item-check {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
background-color: rgba(61, 80, 92, 0.3);
|
||||
padding: 1rem 0;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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, {})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
19
ui/v2.5/src/pluginApi.d.ts
vendored
19
ui/v2.5/src/pluginApi.d.ts
vendored
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue