mirror of
https://github.com/stashapp/stash.git
synced 2026-06-11 07:41:08 -05:00
Compare commits
34 Commits
v0.30.1
...
docs-patch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c97a140f9 | ||
|
|
70ad014ac4 | ||
|
|
5b3785f164 | ||
|
|
ed3a239366 | ||
|
|
2a5b59a96a | ||
|
|
d7d7530c78 | ||
|
|
211f06963e | ||
|
|
0fa132cf60 | ||
|
|
77d0008c6d | ||
|
|
b4969add27 | ||
|
|
6049b21d22 | ||
|
|
deada580e5 | ||
|
|
95b1bce917 | ||
|
|
579fc66275 | ||
|
|
c9fa3b76d9 | ||
|
|
cf3489efdc | ||
|
|
9b709ef614 | ||
|
|
c0260781a5 | ||
|
|
fa80454891 | ||
|
|
1e6bf74385 | ||
|
|
3b5e1db2aa | ||
|
|
6eed5390e1 | ||
|
|
81e8ccb5a9 | ||
|
|
45dc892a54 | ||
|
|
dc7ebadb16 | ||
|
|
956af44a29 | ||
|
|
09ba41b2bb | ||
|
|
91e1ec520f | ||
|
|
56822dbdc5 | ||
|
|
39d3e63cbf | ||
|
|
66ceceeaf1 | ||
|
|
65e82a0cf6 | ||
|
|
d962247016 | ||
|
|
08b87431c3 |
15
go.mod
15
go.mod
@@ -7,10 +7,10 @@ require (
|
||||
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552
|
||||
github.com/Yamashou/gqlgenc v0.32.1
|
||||
github.com/anacrolix/dms v1.2.2
|
||||
github.com/antchfx/htmlquery v1.3.0
|
||||
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
|
||||
@@ -69,20 +69,21 @@ require (
|
||||
|
||||
require (
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/antchfx/xpath v1.2.3 // indirect
|
||||
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
|
||||
|
||||
78
go.sum
78
go.sum
@@ -85,10 +85,10 @@ github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/antchfx/htmlquery v1.3.0 h1:5I5yNFOVI+egyia5F2s/5Do2nFWxJz41Tr3DyfKD25E=
|
||||
github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8=
|
||||
github.com/antchfx/xpath v1.2.3 h1:CCZWOzv5bAqjVv0offZ2LVgVYFbeldKQVuLNbViZdes=
|
||||
github.com/antchfx/xpath v1.2.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
|
||||
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
|
||||
github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
|
||||
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
@@ -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=
|
||||
@@ -286,6 +286,7 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -379,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=
|
||||
@@ -432,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=
|
||||
@@ -664,6 +661,10 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@@ -707,6 +708,10 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -757,7 +762,12 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
@@ -789,6 +799,11 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -869,14 +884,25 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -889,7 +915,12 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -956,6 +987,9 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||
golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -373,6 +373,7 @@ type Mutation {
|
||||
performerDestroy(input: PerformerDestroyInput!): Boolean!
|
||||
performersDestroy(ids: [ID!]!): Boolean!
|
||||
bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!]
|
||||
performerMerge(input: PerformerMergeInput!): Performer!
|
||||
|
||||
studioCreate(input: StudioCreateInput!): Studio
|
||||
studioUpdate(input: StudioUpdateInput!): Studio
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -185,3 +185,10 @@ type FindPerformersResultType {
|
||||
count: Int!
|
||||
performers: [Performer!]!
|
||||
}
|
||||
|
||||
input PerformerMergeInput {
|
||||
source: [ID!]!
|
||||
destination: ID!
|
||||
# values defined here will override values in the destination
|
||||
values: PerformerUpdateInput
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/performer"
|
||||
"github.com/stashapp/stash/pkg/plugin/hook"
|
||||
"github.com/stashapp/stash/pkg/sliceutil"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
@@ -136,7 +139,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
||||
return r.getPerformer(ctx, newPerformer.ID)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator) error {
|
||||
func validateNoLegacyURLs(translator changesetTranslator) error {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if translator.hasField("url") {
|
||||
return fmt.Errorf("url field must not be included if urls is included")
|
||||
@@ -151,7 +154,7 @@ func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURL, legacyTwitter, legacyInstagram models.OptionalString, updatedPerformer *models.PerformerPartial) error {
|
||||
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURLs legacyPerformerURLs, updatedPerformer *models.PerformerPartial) error {
|
||||
qb := r.repository.Performer
|
||||
|
||||
// we need to be careful with URL/Twitter/Instagram
|
||||
@@ -170,23 +173,23 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
||||
existingURLs := p.URLs.List()
|
||||
|
||||
// performer partial URLs should be empty
|
||||
if legacyURL.Set {
|
||||
if legacyURLs.URL.Set {
|
||||
replaced := false
|
||||
for i, url := range existingURLs {
|
||||
if !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) {
|
||||
existingURLs[i] = legacyURL.Value
|
||||
existingURLs[i] = legacyURLs.URL.Value
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced {
|
||||
existingURLs = append(existingURLs, legacyURL.Value)
|
||||
existingURLs = append(existingURLs, legacyURLs.URL.Value)
|
||||
}
|
||||
}
|
||||
|
||||
if legacyTwitter.Set {
|
||||
value := utils.URLFromHandle(legacyTwitter.Value, twitterURL)
|
||||
if legacyURLs.Twitter.Set {
|
||||
value := utils.URLFromHandle(legacyURLs.Twitter.Value, twitterURL)
|
||||
found := false
|
||||
// find and replace the first twitter URL
|
||||
for i, url := range existingURLs {
|
||||
@@ -201,9 +204,9 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
||||
existingURLs = append(existingURLs, value)
|
||||
}
|
||||
}
|
||||
if legacyInstagram.Set {
|
||||
if legacyURLs.Instagram.Set {
|
||||
found := false
|
||||
value := utils.URLFromHandle(legacyInstagram.Value, instagramURL)
|
||||
value := utils.URLFromHandle(legacyURLs.Instagram.Value, instagramURL)
|
||||
// find and replace the first instagram URL
|
||||
for i, url := range existingURLs {
|
||||
if performer.IsInstagramURL(url) {
|
||||
@@ -226,16 +229,25 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
|
||||
performerID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
type legacyPerformerURLs struct {
|
||||
URL models.OptionalString
|
||||
Twitter models.OptionalString
|
||||
Instagram models.OptionalString
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
func (u *legacyPerformerURLs) AnySet() bool {
|
||||
return u.URL.Set || u.Twitter.Set || u.Instagram.Set
|
||||
}
|
||||
|
||||
func legacyPerformerURLsFromInput(input models.PerformerUpdateInput, translator changesetTranslator) legacyPerformerURLs {
|
||||
return legacyPerformerURLs{
|
||||
URL: translator.optionalString(input.URL, "url"),
|
||||
Twitter: translator.optionalString(input.Twitter, "twitter"),
|
||||
Instagram: translator.optionalString(input.Instagram, "instagram"),
|
||||
}
|
||||
}
|
||||
|
||||
func performerPartialFromInput(input models.PerformerUpdateInput, translator changesetTranslator) (*models.PerformerPartial, error) {
|
||||
// Populate performer from the input
|
||||
updatedPerformer := models.NewPerformerPartial()
|
||||
|
||||
@@ -260,19 +272,17 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
||||
updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
|
||||
updatedPerformer.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
|
||||
|
||||
var err error
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedPerformer.URLs = translator.updateStrings(input.Urls, "urls")
|
||||
}
|
||||
|
||||
legacyURL := translator.optionalString(input.URL, "url")
|
||||
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
|
||||
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
|
||||
|
||||
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting birthdate: %w", err)
|
||||
@@ -299,6 +309,26 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
||||
|
||||
updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields)
|
||||
|
||||
return &updatedPerformer, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
|
||||
performerID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
|
||||
updatedPerformer, err := performerPartialFromInput(input, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
legacyURLs := legacyPerformerURLsFromInput(input, translator)
|
||||
|
||||
var imageData []byte
|
||||
imageIncluded := translator.hasField("image")
|
||||
if input.Image != nil {
|
||||
@@ -312,17 +342,17 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Performer
|
||||
|
||||
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil {
|
||||
if legacyURLs.AnySet() {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, updatedPerformer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil {
|
||||
if err := performer.ValidateUpdate(ctx, performerID, *updatedPerformer, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = qb.UpdatePartial(ctx, performerID, updatedPerformer)
|
||||
_, err = qb.UpdatePartial(ctx, performerID, *updatedPerformer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -379,16 +409,18 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedPerformer.URLs = translator.updateStringsBulk(input.Urls, "urls")
|
||||
}
|
||||
|
||||
legacyURL := translator.optionalString(input.URL, "url")
|
||||
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
|
||||
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
|
||||
legacyURLs := legacyPerformerURLs{
|
||||
URL: translator.optionalString(input.URL, "url"),
|
||||
Twitter: translator.optionalString(input.Twitter, "twitter"),
|
||||
Instagram: translator.optionalString(input.Instagram, "instagram"),
|
||||
}
|
||||
|
||||
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
|
||||
if err != nil {
|
||||
@@ -425,8 +457,8 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
||||
qb := r.repository.Performer
|
||||
|
||||
for _, performerID := range performerIDs {
|
||||
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil {
|
||||
if legacyURLs.AnySet() {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, &updatedPerformer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -506,3 +538,87 @@ func (r *mutationResolver) PerformersDestroy(ctx context.Context, performerIDs [
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMergeInput) (*models.Performer, error) {
|
||||
srcIDs, err := stringslice.StringSliceToIntSlice(input.Source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting source ids: %w", err)
|
||||
}
|
||||
|
||||
// ensure source ids are unique
|
||||
srcIDs = sliceutil.AppendUniques(nil, srcIDs)
|
||||
|
||||
destID, err := strconv.Atoi(input.Destination)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting destination id: %w", err)
|
||||
}
|
||||
|
||||
// ensure destination is not in source list
|
||||
if slices.Contains(srcIDs, destID) {
|
||||
return nil, errors.New("destination performer cannot be in source list")
|
||||
}
|
||||
|
||||
var values *models.PerformerPartial
|
||||
var imageData []byte
|
||||
|
||||
if input.Values != nil {
|
||||
translator := changesetTranslator{
|
||||
inputMap: getNamedUpdateInputMap(ctx, "input.values"),
|
||||
}
|
||||
|
||||
values, err = performerPartialFromInput(*input.Values, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacyURLs := legacyPerformerURLsFromInput(*input.Values, translator)
|
||||
if legacyURLs.AnySet() {
|
||||
return nil, errors.New("Merging legacy performer URLs is not supported")
|
||||
}
|
||||
|
||||
if input.Values.Image != nil {
|
||||
var err error
|
||||
imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("processing cover image: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
v := models.NewPerformerPartial()
|
||||
values = &v
|
||||
}
|
||||
|
||||
var dest *models.Performer
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Performer
|
||||
|
||||
dest, err = qb.Find(ctx, destID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding destination performer ID %d: %w", destID, err)
|
||||
}
|
||||
|
||||
// ensure source performers exist
|
||||
if _, err := qb.FindMany(ctx, srcIDs); err != nil {
|
||||
return fmt.Errorf("finding source performers: %w", err)
|
||||
}
|
||||
|
||||
if _, err := qb.UpdatePartial(ctx, destID, *values); err != nil {
|
||||
return fmt.Errorf("updating performer: %w", err)
|
||||
}
|
||||
|
||||
if err := qb.Merge(ctx, srcIDs, destID); err != nil {
|
||||
return fmt.Errorf("merging performers: %w", err)
|
||||
}
|
||||
|
||||
if len(imageData) > 0 {
|
||||
if err := qb.UpdateImage(ctx, destID, imageData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -134,7 +134,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url not included in the input
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -255,6 +256,9 @@ func Initialize() (*Server, error) {
|
||||
staticUI = statigz.FileServer(ui.UIBox.(fs.ReadDirFS))
|
||||
}
|
||||
|
||||
// handle favicon override
|
||||
r.HandleFunc("/favicon.ico", handleFavicon(staticUI))
|
||||
|
||||
// Serve the web app
|
||||
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
ext := path.Ext(r.URL.Path)
|
||||
@@ -295,6 +299,31 @@ func Initialize() (*Server, error) {
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func handleFavicon(staticUI *statigz.Server) func(w http.ResponseWriter, r *http.Request) {
|
||||
mgr := manager.GetInstance()
|
||||
cfg := mgr.Config
|
||||
|
||||
// check if favicon.ico exists in the config directory
|
||||
// if so, use that
|
||||
// otherwise, use the embedded one
|
||||
iconPath := filepath.Join(cfg.GetConfigPath(), "favicon.ico")
|
||||
exists, _ := fsutil.FileExists(iconPath)
|
||||
|
||||
if exists {
|
||||
logger.Debugf("Using custom favicon at %s", iconPath)
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
|
||||
if exists {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
} else {
|
||||
staticUI.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the server. It listens on the configured address and port.
|
||||
// It calls ListenAndServeTLS if TLS is configured, otherwise it calls ListenAndServe.
|
||||
// Calls to Start are blocked until the server is shutdown.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
@@ -58,12 +59,12 @@ func startSystray(exit chan int, faviconProvider FaviconProvider) {
|
||||
func systrayInitialize(exit chan<- int, faviconProvider FaviconProvider) {
|
||||
favicon := faviconProvider.GetFavicon()
|
||||
systray.SetTemplateIcon(favicon, favicon)
|
||||
systray.SetTooltip("🟢 Stash is Running.")
|
||||
c := config.GetInstance()
|
||||
systray.SetTooltip(fmt.Sprintf("🟢 Stash is Running on port %d.", c.GetPort()))
|
||||
|
||||
openStashButton := systray.AddMenuItem("Open Stash", "Open a browser window to Stash")
|
||||
var menuItems []string
|
||||
systray.AddSeparator()
|
||||
c := config.GetInstance()
|
||||
if !c.IsNewSystem() {
|
||||
menuItems = c.GetMenuItems()
|
||||
for _, item := range menuItems {
|
||||
|
||||
333
internal/dlna/activity.go
Normal file
333
internal/dlna/activity.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/txn"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultSessionTimeout is the time after which a session is considered complete
|
||||
// if no new requests are received.
|
||||
// This is set high (5 minutes) because DLNA clients buffer aggressively and may not
|
||||
// send any HTTP requests for extended periods while the user is still watching.
|
||||
DefaultSessionTimeout = 5 * time.Minute
|
||||
|
||||
// monitorInterval is how often we check for expired sessions.
|
||||
monitorInterval = 10 * time.Second
|
||||
)
|
||||
|
||||
// ActivityConfig provides configuration options for DLNA activity tracking.
|
||||
type ActivityConfig interface {
|
||||
// GetDLNAActivityTrackingEnabled returns true if activity tracking should be enabled.
|
||||
// If not implemented, defaults to true.
|
||||
GetDLNAActivityTrackingEnabled() bool
|
||||
|
||||
// GetMinimumPlayPercent returns the minimum percentage of a video that must be
|
||||
// watched before incrementing the play count. Uses UI setting if available.
|
||||
GetMinimumPlayPercent() int
|
||||
}
|
||||
|
||||
// SceneActivityWriter provides methods for saving scene activity.
|
||||
type SceneActivityWriter interface {
|
||||
SaveActivity(ctx context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error)
|
||||
AddViews(ctx context.Context, sceneID int, dates []time.Time) ([]time.Time, error)
|
||||
}
|
||||
|
||||
// streamSession represents an active DLNA streaming session.
|
||||
type streamSession struct {
|
||||
SceneID int
|
||||
ClientIP string
|
||||
StartTime time.Time
|
||||
LastActivity time.Time
|
||||
VideoDuration float64
|
||||
PlayCountAdded bool
|
||||
}
|
||||
|
||||
// sessionKey generates a unique key for a session based on client IP and scene ID.
|
||||
func sessionKey(clientIP string, sceneID int) string {
|
||||
return fmt.Sprintf("%s:%d", clientIP, sceneID)
|
||||
}
|
||||
|
||||
// percentWatched calculates the estimated percentage of video watched.
|
||||
// Uses a time-based approach since DLNA clients buffer aggressively and byte
|
||||
// positions don't correlate with actual playback position.
|
||||
//
|
||||
// The key insight: you cannot have watched more of the video than time has elapsed.
|
||||
// If the video is 30 minutes and only 1 minute has passed, maximum watched is ~3.3%.
|
||||
func (s *streamSession) percentWatched() float64 {
|
||||
if s.VideoDuration <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate elapsed time from session start to last activity
|
||||
elapsed := s.LastActivity.Sub(s.StartTime).Seconds()
|
||||
if elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Maximum possible percent is based on elapsed time
|
||||
// You can't watch more of the video than time has passed
|
||||
timeBasedPercent := (elapsed / s.VideoDuration) * 100
|
||||
|
||||
// Cap at 100%
|
||||
if timeBasedPercent > 100 {
|
||||
return 100
|
||||
}
|
||||
|
||||
return timeBasedPercent
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Returns the time in seconds, or 0 if the video is nearly complete (>=98%).
|
||||
func (s *streamSession) estimatedResumeTime() float64 {
|
||||
if s.VideoDuration <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate elapsed time from session start
|
||||
elapsed := s.LastActivity.Sub(s.StartTime).Seconds()
|
||||
if elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// If elapsed time exceeds 98% of video duration, reset resume time (matches frontend behavior)
|
||||
if elapsed >= s.VideoDuration*0.98 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Resume time is approximately where the user was watching
|
||||
// Capped by video duration
|
||||
if elapsed > s.VideoDuration {
|
||||
elapsed = s.VideoDuration
|
||||
}
|
||||
|
||||
return elapsed
|
||||
}
|
||||
|
||||
// ActivityTracker tracks DLNA streaming activity and saves it to the database.
|
||||
type ActivityTracker struct {
|
||||
txnManager txn.Manager
|
||||
sceneWriter SceneActivityWriter
|
||||
config ActivityConfig
|
||||
sessionTimeout time.Duration
|
||||
|
||||
sessions map[string]*streamSession
|
||||
mutex sync.RWMutex
|
||||
|
||||
ctx context.Context
|
||||
cancelFunc context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewActivityTracker creates a new ActivityTracker.
|
||||
func NewActivityTracker(
|
||||
txnManager txn.Manager,
|
||||
sceneWriter SceneActivityWriter,
|
||||
config ActivityConfig,
|
||||
) *ActivityTracker {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: txnManager,
|
||||
sceneWriter: sceneWriter,
|
||||
config: config,
|
||||
sessionTimeout: DefaultSessionTimeout,
|
||||
sessions: make(map[string]*streamSession),
|
||||
ctx: ctx,
|
||||
cancelFunc: cancel,
|
||||
}
|
||||
|
||||
// Start the session monitor goroutine
|
||||
tracker.wg.Add(1)
|
||||
go tracker.monitorSessions()
|
||||
|
||||
return tracker
|
||||
}
|
||||
|
||||
// Stop stops the activity tracker and processes any remaining sessions.
|
||||
func (t *ActivityTracker) Stop() {
|
||||
t.cancelFunc()
|
||||
t.wg.Wait()
|
||||
|
||||
// Process any remaining sessions
|
||||
t.mutex.Lock()
|
||||
sessions := make([]*streamSession, 0, len(t.sessions))
|
||||
for _, session := range t.sessions {
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
t.sessions = make(map[string]*streamSession)
|
||||
t.mutex.Unlock()
|
||||
|
||||
for _, session := range sessions {
|
||||
t.processCompletedSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
// RecordRequest records a streaming request for activity tracking.
|
||||
// Each request updates the session's LastActivity time, which is used for
|
||||
// time-based tracking of watch progress.
|
||||
func (t *ActivityTracker) RecordRequest(sceneID int, clientIP string, videoDuration float64) {
|
||||
if !t.isEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
key := sessionKey(clientIP, sceneID)
|
||||
now := time.Now()
|
||||
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
session, exists := t.sessions[key]
|
||||
if !exists {
|
||||
session = &streamSession{
|
||||
SceneID: sceneID,
|
||||
ClientIP: clientIP,
|
||||
StartTime: now,
|
||||
VideoDuration: videoDuration,
|
||||
}
|
||||
t.sessions[key] = session
|
||||
logger.Debugf("[DLNA Activity] New session started: scene=%d, client=%s", sceneID, clientIP)
|
||||
}
|
||||
|
||||
session.LastActivity = now
|
||||
}
|
||||
|
||||
// monitorSessions periodically checks for expired sessions and processes them.
|
||||
func (t *ActivityTracker) monitorSessions() {
|
||||
defer t.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(monitorInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-t.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
t.processExpiredSessions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processExpiredSessions finds and processes sessions that have timed out.
|
||||
func (t *ActivityTracker) processExpiredSessions() {
|
||||
now := time.Now()
|
||||
var expiredSessions []*streamSession
|
||||
|
||||
t.mutex.Lock()
|
||||
for key, session := range t.sessions {
|
||||
timeSinceStart := now.Sub(session.StartTime)
|
||||
timeSinceActivity := now.Sub(session.LastActivity)
|
||||
|
||||
// Must have no HTTP activity for the full timeout period
|
||||
if timeSinceActivity <= t.sessionTimeout {
|
||||
continue
|
||||
}
|
||||
|
||||
// DLNA clients buffer aggressively - they fetch most/all of the video quickly,
|
||||
// then play from cache with NO further HTTP requests.
|
||||
//
|
||||
// Two scenarios:
|
||||
// 1. User watched the whole video: timeSinceStart >= videoDuration
|
||||
// -> Set LastActivity to when timeout began (they finished watching)
|
||||
// 2. User stopped early: timeSinceStart < videoDuration
|
||||
// -> Keep LastActivity as-is (best estimate of when they stopped)
|
||||
|
||||
videoDuration := time.Duration(session.VideoDuration) * time.Second
|
||||
if timeSinceStart >= videoDuration && videoDuration > 0 {
|
||||
// User likely watched the whole video, then it timed out
|
||||
// Estimate they watched until the timeout period started
|
||||
session.LastActivity = now.Add(-t.sessionTimeout)
|
||||
}
|
||||
// else: User stopped early - LastActivity is already our best estimate
|
||||
|
||||
expiredSessions = append(expiredSessions, session)
|
||||
delete(t.sessions, key)
|
||||
}
|
||||
t.mutex.Unlock()
|
||||
|
||||
for _, session := range expiredSessions {
|
||||
t.processCompletedSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
// processCompletedSession saves activity data for a completed streaming session.
|
||||
func (t *ActivityTracker) processCompletedSession(session *streamSession) {
|
||||
percentWatched := session.percentWatched()
|
||||
resumeTime := session.estimatedResumeTime()
|
||||
|
||||
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)
|
||||
if percentWatched < 1 {
|
||||
logger.Debugf("[DLNA Activity] Session too short, skipping save")
|
||||
return
|
||||
}
|
||||
|
||||
// Skip DB operations if txnManager is nil (for testing)
|
||||
if t.txnManager == nil {
|
||||
logger.Debugf("[DLNA Activity] No transaction manager, skipping DB save")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine what needs to be saved
|
||||
shouldSaveResume := resumeTime > 0
|
||||
shouldAddView := !session.PlayCountAdded && percentWatched >= float64(t.getMinimumPlayPercent())
|
||||
|
||||
// Nothing to save
|
||||
if !shouldSaveResume && !shouldAddView {
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// isEnabled returns true if activity tracking is enabled.
|
||||
func (t *ActivityTracker) isEnabled() bool {
|
||||
if t.config == nil {
|
||||
return true // Default to enabled
|
||||
}
|
||||
return t.config.GetDLNAActivityTrackingEnabled()
|
||||
}
|
||||
|
||||
// getMinimumPlayPercent returns the minimum play percentage for incrementing play count.
|
||||
func (t *ActivityTracker) getMinimumPlayPercent() int {
|
||||
if t.config == nil {
|
||||
return 0 // Default: any play increments count (matches frontend default)
|
||||
}
|
||||
return t.config.GetMinimumPlayPercent()
|
||||
}
|
||||
420
internal/dlna/activity_test.go
Normal file
420
internal/dlna/activity_test.go
Normal file
@@ -0,0 +1,420 @@
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// mockSceneWriter is a mock implementation of SceneActivityWriter
|
||||
type mockSceneWriter struct {
|
||||
mu sync.Mutex
|
||||
saveActivityCalls []saveActivityCall
|
||||
addViewsCalls []addViewsCall
|
||||
}
|
||||
|
||||
type saveActivityCall struct {
|
||||
sceneID int
|
||||
resumeTime *float64
|
||||
playDuration *float64
|
||||
}
|
||||
|
||||
type addViewsCall struct {
|
||||
sceneID int
|
||||
dates []time.Time
|
||||
}
|
||||
|
||||
func (m *mockSceneWriter) SaveActivity(_ context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) {
|
||||
m.mu.Lock()
|
||||
m.saveActivityCalls = append(m.saveActivityCalls, saveActivityCall{
|
||||
sceneID: sceneID,
|
||||
resumeTime: resumeTime,
|
||||
playDuration: playDuration,
|
||||
})
|
||||
m.mu.Unlock()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *mockSceneWriter) AddViews(_ context.Context, sceneID int, dates []time.Time) ([]time.Time, error) {
|
||||
m.mu.Lock()
|
||||
m.addViewsCalls = append(m.addViewsCalls, addViewsCall{
|
||||
sceneID: sceneID,
|
||||
dates: dates,
|
||||
})
|
||||
m.mu.Unlock()
|
||||
return dates, nil
|
||||
}
|
||||
|
||||
// mockConfig is a mock implementation of ActivityConfig
|
||||
type mockConfig struct {
|
||||
enabled bool
|
||||
minPlayPercent int
|
||||
}
|
||||
|
||||
func (c *mockConfig) GetDLNAActivityTrackingEnabled() bool {
|
||||
return c.enabled
|
||||
}
|
||||
|
||||
func (c *mockConfig) GetMinimumPlayPercent() int {
|
||||
return c.minPlayPercent
|
||||
}
|
||||
|
||||
func TestStreamSession_PercentWatched(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startTime time.Time
|
||||
lastActivity time.Time
|
||||
videoDuration float64
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "no video duration",
|
||||
startTime: now.Add(-60 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 0,
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "half watched",
|
||||
startTime: now.Add(-60 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 1 minute = 50%
|
||||
expected: 50.0,
|
||||
},
|
||||
{
|
||||
name: "fully watched",
|
||||
startTime: now.Add(-120 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 2 minutes = 100%
|
||||
expected: 100.0,
|
||||
},
|
||||
{
|
||||
name: "quarter watched",
|
||||
startTime: now.Add(-30 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 30 seconds = 25%
|
||||
expected: 25.0,
|
||||
},
|
||||
{
|
||||
name: "elapsed exceeds duration - capped at 100%",
|
||||
startTime: now.Add(-180 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, but 3 minutes elapsed = capped at 100%
|
||||
expected: 100.0,
|
||||
},
|
||||
{
|
||||
name: "no elapsed time",
|
||||
startTime: now,
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0,
|
||||
expected: 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.percentWatched()
|
||||
assert.InDelta(t, tt.expected, result, 0.01)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSession_EstimatedResumeTime(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startTime time.Time
|
||||
lastActivity time.Time
|
||||
videoDuration float64
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "no elapsed time",
|
||||
startTime: now,
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0,
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "half way through",
|
||||
startTime: now.Add(-60 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 1 minute = resume at 60s
|
||||
expected: 60.0,
|
||||
},
|
||||
{
|
||||
name: "quarter way through",
|
||||
startTime: now.Add(-30 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 2 minutes, watched for 30 seconds = resume at 30s
|
||||
expected: 30.0,
|
||||
},
|
||||
{
|
||||
name: "98% complete - should reset to 0",
|
||||
startTime: now.Add(-118 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 98.3% elapsed, should reset
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "100% complete - should reset to 0",
|
||||
startTime: now.Add(-120 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0,
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "elapsed exceeds duration - capped and reset to 0",
|
||||
startTime: now.Add(-180 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 120.0, // 150% elapsed, capped at 100%, reset to 0
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "no video duration",
|
||||
startTime: now.Add(-60 * time.Second),
|
||||
lastActivity: now,
|
||||
videoDuration: 0,
|
||||
expected: 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.estimatedResumeTime()
|
||||
assert.InDelta(t, tt.expected, result, 1.0) // Allow 1 second tolerance
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionKey(t *testing.T) {
|
||||
key := sessionKey("192.168.1.100", 42)
|
||||
assert.Equal(t, "192.168.1.100:42", key)
|
||||
}
|
||||
|
||||
func TestActivityTracker_RecordRequest(t *testing.T) {
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 50}
|
||||
|
||||
// Create tracker without starting the goroutine (for unit testing)
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil, // Don't need DB for this test
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: DefaultSessionTimeout,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Record first request - should create new session
|
||||
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
||||
|
||||
tracker.mutex.RLock()
|
||||
session := tracker.sessions["192.168.1.100:42"]
|
||||
tracker.mutex.RUnlock()
|
||||
|
||||
assert.NotNil(t, session)
|
||||
assert.Equal(t, 42, session.SceneID)
|
||||
assert.Equal(t, "192.168.1.100", session.ClientIP)
|
||||
assert.Equal(t, 120.0, session.VideoDuration)
|
||||
assert.False(t, session.StartTime.IsZero())
|
||||
assert.False(t, session.LastActivity.IsZero())
|
||||
|
||||
// Record second request - should update LastActivity
|
||||
firstActivity := session.LastActivity
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
||||
|
||||
tracker.mutex.RLock()
|
||||
session = tracker.sessions["192.168.1.100:42"]
|
||||
tracker.mutex.RUnlock()
|
||||
|
||||
assert.True(t, session.LastActivity.After(firstActivity))
|
||||
}
|
||||
|
||||
func TestActivityTracker_DisabledTracking(t *testing.T) {
|
||||
config := &mockConfig{enabled: false, minPlayPercent: 50}
|
||||
|
||||
// Create tracker without starting the goroutine (for unit testing)
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil,
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: DefaultSessionTimeout,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Record request - should be ignored when tracking is disabled
|
||||
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
||||
|
||||
tracker.mutex.RLock()
|
||||
sessionCount := len(tracker.sessions)
|
||||
tracker.mutex.RUnlock()
|
||||
|
||||
assert.Equal(t, 0, sessionCount)
|
||||
}
|
||||
|
||||
func TestActivityTracker_SessionExpiration(t *testing.T) {
|
||||
// For this test, we'll test the session expiration logic directly
|
||||
// without the full transaction manager integration
|
||||
|
||||
sceneWriter := &mockSceneWriter{}
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 10}
|
||||
|
||||
// Create a tracker with nil txnManager - we'll test processCompletedSession separately
|
||||
// Here we just verify the session management logic
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil, // Skip DB calls for this test
|
||||
sceneWriter: sceneWriter,
|
||||
config: config,
|
||||
sessionTimeout: 100 * time.Millisecond,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Manually add a session
|
||||
// Use a short video duration (1 second) so the test can verify expiration quickly.
|
||||
now := time.Now()
|
||||
tracker.sessions["192.168.1.100:42"] = &streamSession{
|
||||
SceneID: 42,
|
||||
ClientIP: "192.168.1.100",
|
||||
StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago
|
||||
LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout)
|
||||
VideoDuration: 1.0, // Short video so timeSinceStart > videoDuration
|
||||
}
|
||||
|
||||
// Verify session exists
|
||||
assert.Len(t, tracker.sessions, 1)
|
||||
|
||||
// Process expired sessions - this will try to save activity but txnManager is nil
|
||||
// so it will skip the DB calls but still remove the session
|
||||
tracker.processExpiredSessions()
|
||||
|
||||
// Verify session was removed (even though DB calls were skipped)
|
||||
assert.Len(t, tracker.sessions, 0)
|
||||
}
|
||||
|
||||
func TestActivityTracker_SessionExpiration_StoppedEarly(t *testing.T) {
|
||||
// Test that sessions expire when user stops watching early (before video ends)
|
||||
// This was a bug where sessions wouldn't expire until video duration passed
|
||||
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 10}
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil,
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: 100 * time.Millisecond,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// User started watching a 30-minute video but stopped after 5 seconds
|
||||
now := time.Now()
|
||||
tracker.sessions["192.168.1.100:42"] = &streamSession{
|
||||
SceneID: 42,
|
||||
ClientIP: "192.168.1.100",
|
||||
StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago
|
||||
LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout)
|
||||
VideoDuration: 1800.0, // 30 minute video - much longer than elapsed time
|
||||
}
|
||||
|
||||
assert.Len(t, tracker.sessions, 1)
|
||||
|
||||
// Session should expire because timeSinceActivity > timeout
|
||||
// Even though the video is 30 minutes and only 5 seconds have passed
|
||||
tracker.processExpiredSessions()
|
||||
|
||||
// Verify session was expired
|
||||
assert.Len(t, tracker.sessions, 0, "Session should expire when user stops early, not wait for video duration")
|
||||
}
|
||||
|
||||
func TestActivityTracker_MinimumPlayPercentThreshold(t *testing.T) {
|
||||
// Test the threshold logic without full transaction integration
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 75} // High threshold
|
||||
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil,
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: 50 * time.Millisecond,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Test that getMinimumPlayPercent returns the configured value
|
||||
assert.Equal(t, 75, tracker.getMinimumPlayPercent())
|
||||
|
||||
// Create a session with 30% watched (36 seconds of a 120 second video)
|
||||
now := time.Now()
|
||||
session := &streamSession{
|
||||
SceneID: 42,
|
||||
StartTime: now.Add(-36 * time.Second),
|
||||
LastActivity: now,
|
||||
VideoDuration: 120.0,
|
||||
}
|
||||
|
||||
// 30% is below 75% threshold
|
||||
percentWatched := session.percentWatched()
|
||||
assert.InDelta(t, 30.0, percentWatched, 0.1)
|
||||
assert.False(t, percentWatched >= float64(tracker.getMinimumPlayPercent()))
|
||||
}
|
||||
|
||||
func TestActivityTracker_MultipleSessions(t *testing.T) {
|
||||
config := &mockConfig{enabled: true, minPlayPercent: 50}
|
||||
|
||||
// Create tracker without starting the goroutine (for unit testing)
|
||||
tracker := &ActivityTracker{
|
||||
txnManager: nil,
|
||||
sceneWriter: nil,
|
||||
config: config,
|
||||
sessionTimeout: DefaultSessionTimeout,
|
||||
sessions: make(map[string]*streamSession),
|
||||
}
|
||||
|
||||
// Different clients watching same scene
|
||||
tracker.RecordRequest(42, "192.168.1.100", 120.0)
|
||||
tracker.RecordRequest(42, "192.168.1.101", 120.0)
|
||||
|
||||
// Same client watching different scenes
|
||||
tracker.RecordRequest(43, "192.168.1.100", 180.0)
|
||||
|
||||
tracker.mutex.RLock()
|
||||
assert.Len(t, tracker.sessions, 3)
|
||||
tracker.mutex.RUnlock()
|
||||
}
|
||||
|
||||
func TestActivityTracker_ShortSessionIgnored(t *testing.T) {
|
||||
// Test that short sessions are ignored
|
||||
// Create a session with only ~0.8% watched (1 second of a 120 second video)
|
||||
now := time.Now()
|
||||
session := &streamSession{
|
||||
SceneID: 42,
|
||||
ClientIP: "192.168.1.100",
|
||||
StartTime: now.Add(-1 * time.Second), // Only 1 second
|
||||
LastActivity: now,
|
||||
VideoDuration: 120.0, // 2 minutes
|
||||
}
|
||||
|
||||
// Verify percent watched is below threshold (1s / 120s = 0.83%)
|
||||
assert.InDelta(t, 0.83, session.percentWatched(), 0.1)
|
||||
|
||||
// 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()
|
||||
shouldSkip := percentWatched < 1 && elapsed < 5
|
||||
assert.True(t, shouldSkip, "Short session should be skipped")
|
||||
}
|
||||
@@ -278,6 +278,7 @@ type Server struct {
|
||||
repository Repository
|
||||
sceneServer sceneServer
|
||||
ipWhitelistManager *ipWhitelistManager
|
||||
activityTracker *ActivityTracker
|
||||
VideoSortOrder string
|
||||
|
||||
subscribeLock sync.Mutex
|
||||
@@ -596,6 +597,7 @@ func (me *Server) initMux(mux *http.ServeMux) {
|
||||
mux.HandleFunc(resPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
sceneId := r.URL.Query().Get("scene")
|
||||
var scene *models.Scene
|
||||
var videoDuration float64
|
||||
repo := me.repository
|
||||
err := repo.WithReadTxn(r.Context(), func(ctx context.Context) error {
|
||||
sceneIdInt, err := strconv.Atoi(sceneId)
|
||||
@@ -603,6 +605,15 @@ func (me *Server) initMux(mux *http.ServeMux) {
|
||||
return nil
|
||||
}
|
||||
scene, _ = repo.SceneFinder.Find(ctx, sceneIdInt)
|
||||
if scene != nil {
|
||||
// Load primary file to get duration for activity tracking
|
||||
if err := scene.LoadPrimaryFile(ctx, repo.FileGetter); err != nil {
|
||||
logger.Debugf("failed to load primary file for scene %d: %v", sceneIdInt, err)
|
||||
}
|
||||
if f := scene.Files.Primary(); f != nil {
|
||||
videoDuration = f.Duration
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -615,6 +626,14 @@ func (me *Server) initMux(mux *http.ServeMux) {
|
||||
|
||||
w.Header().Set("transferMode.dlna.org", "Streaming")
|
||||
w.Header().Set("contentFeatures.dlna.org", "DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01500000000000000000000000000000")
|
||||
|
||||
// Track activity - uses time-based tracking, updated on each request
|
||||
if me.activityTracker != nil {
|
||||
sceneIdInt, _ := strconv.Atoi(sceneId)
|
||||
clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
me.activityTracker.RecordRequest(sceneIdInt, clientIP, videoDuration)
|
||||
}
|
||||
|
||||
me.sceneServer.StreamSceneDirect(scene, w, r)
|
||||
})
|
||||
mux.HandleFunc(rootDescPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -77,13 +77,29 @@ type Config interface {
|
||||
GetDLNADefaultIPWhitelist() []string
|
||||
GetVideoSortOrder() string
|
||||
GetDLNAPortAsString() string
|
||||
GetDLNAActivityTrackingEnabled() bool
|
||||
}
|
||||
|
||||
// activityConfig wraps Config to implement ActivityConfig.
|
||||
type activityConfig struct {
|
||||
config Config
|
||||
minPlayPercent int // cached from UI config
|
||||
}
|
||||
|
||||
func (c *activityConfig) GetDLNAActivityTrackingEnabled() bool {
|
||||
return c.config.GetDLNAActivityTrackingEnabled()
|
||||
}
|
||||
|
||||
func (c *activityConfig) GetMinimumPlayPercent() int {
|
||||
return c.minPlayPercent
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
config Config
|
||||
sceneServer sceneServer
|
||||
ipWhitelistMgr *ipWhitelistManager
|
||||
repository Repository
|
||||
config Config
|
||||
sceneServer sceneServer
|
||||
ipWhitelistMgr *ipWhitelistManager
|
||||
activityTracker *ActivityTracker
|
||||
|
||||
server *Server
|
||||
running bool
|
||||
@@ -155,6 +171,7 @@ func (s *Service) init() error {
|
||||
repository: s.repository,
|
||||
sceneServer: s.sceneServer,
|
||||
ipWhitelistManager: s.ipWhitelistMgr,
|
||||
activityTracker: s.activityTracker,
|
||||
Interfaces: interfaces,
|
||||
HTTPConn: func() net.Listener {
|
||||
conn, err := net.Listen("tcp", dmsConfig.Http)
|
||||
@@ -215,7 +232,14 @@ func (s *Service) init() error {
|
||||
// }
|
||||
|
||||
// NewService initialises and returns a new DLNA service.
|
||||
func NewService(repo Repository, cfg Config, sceneServer sceneServer) *Service {
|
||||
// The sceneWriter parameter should implement SceneActivityWriter (typically models.SceneReaderWriter).
|
||||
// The minPlayPercent parameter is the minimum percentage of video that must be played to increment play count.
|
||||
func NewService(repo Repository, cfg Config, sceneServer sceneServer, sceneWriter SceneActivityWriter, minPlayPercent int) *Service {
|
||||
activityCfg := &activityConfig{
|
||||
config: cfg,
|
||||
minPlayPercent: minPlayPercent,
|
||||
}
|
||||
|
||||
ret := &Service{
|
||||
repository: repo,
|
||||
sceneServer: sceneServer,
|
||||
@@ -223,7 +247,8 @@ func NewService(repo Repository, cfg Config, sceneServer sceneServer) *Service {
|
||||
ipWhitelistMgr: &ipWhitelistManager{
|
||||
config: cfg,
|
||||
},
|
||||
mutex: sync.Mutex{},
|
||||
activityTracker: NewActivityTracker(repo.TxnManager, sceneWriter, activityCfg),
|
||||
mutex: sync.Mutex{},
|
||||
}
|
||||
|
||||
return ret
|
||||
@@ -283,6 +308,12 @@ func (s *Service) Stop(duration *time.Duration) {
|
||||
|
||||
if s.running {
|
||||
logger.Info("Stopping DLNA")
|
||||
|
||||
// Stop activity tracker first to process any pending sessions
|
||||
if s.activityTracker != nil {
|
||||
s.activityTracker.Stop()
|
||||
}
|
||||
|
||||
err := s.server.Close()
|
||||
if err != nil {
|
||||
logger.Error(err)
|
||||
|
||||
@@ -1323,6 +1323,26 @@ func (i *Config) GetUIConfiguration() map[string]interface{} {
|
||||
return i.forKey(UI).Cut(UI).Raw()
|
||||
}
|
||||
|
||||
// GetMinimumPlayPercent returns the minimum percentage of a video that must be
|
||||
// watched before incrementing the play count. Returns 0 if not configured.
|
||||
func (i *Config) GetMinimumPlayPercent() int {
|
||||
uiConfig := i.GetUIConfiguration()
|
||||
if uiConfig == nil {
|
||||
return 0
|
||||
}
|
||||
if val, ok := uiConfig["minimumPlayPercent"]; ok {
|
||||
switch v := val.(type) {
|
||||
case int:
|
||||
return v
|
||||
case float64:
|
||||
return int(v)
|
||||
case int64:
|
||||
return int(v)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (i *Config) SetUIConfiguration(v map[string]interface{}) {
|
||||
i.Lock()
|
||||
defer i.Unlock()
|
||||
@@ -1615,6 +1635,22 @@ func (i *Config) GetDLNAPortAsString() string {
|
||||
return ":" + strconv.Itoa(i.GetDLNAPort())
|
||||
}
|
||||
|
||||
// GetDLNAActivityTrackingEnabled returns true if DLNA activity tracking is enabled.
|
||||
// This uses the same "trackActivity" UI setting that controls frontend play history tracking.
|
||||
// When enabled, scenes played via DLNA will have their play count and duration tracked.
|
||||
func (i *Config) GetDLNAActivityTrackingEnabled() bool {
|
||||
uiConfig := i.GetUIConfiguration()
|
||||
if uiConfig == nil {
|
||||
return true // Default to enabled
|
||||
}
|
||||
if val, ok := uiConfig["trackActivity"]; ok {
|
||||
if v, ok := val.(bool); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return true // Default to enabled
|
||||
}
|
||||
|
||||
// GetVideoSortOrder returns the sort order to display videos. If
|
||||
// empty, videos will be sorted by titles.
|
||||
func (i *Config) GetVideoSortOrder() string {
|
||||
|
||||
@@ -78,7 +78,7 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) {
|
||||
}
|
||||
|
||||
dlnaRepository := dlna.NewRepository(repo)
|
||||
dlnaService := dlna.NewService(dlnaRepository, cfg, sceneServer)
|
||||
dlnaService := dlna.NewService(dlnaRepository, cfg, sceneServer, repo.Scene, cfg.GetMinimumPlayPercent())
|
||||
|
||||
mgr := &Manager{
|
||||
Config: cfg,
|
||||
|
||||
@@ -411,12 +411,13 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
|
||||
}
|
||||
}
|
||||
|
||||
if j.input.Markers {
|
||||
if j.input.Markers || j.input.MarkerImagePreviews || j.input.MarkerScreenshots {
|
||||
task := &GenerateMarkersTask{
|
||||
repository: r,
|
||||
Scene: scene,
|
||||
Overwrite: j.overwrite,
|
||||
fileNamingAlgorithm: j.fileNamingAlgo,
|
||||
VideoPreview: j.input.Markers,
|
||||
ImagePreview: j.input.MarkerImagePreviews,
|
||||
Screenshot: j.input.MarkerScreenshots,
|
||||
|
||||
@@ -488,6 +489,9 @@ func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.Scene
|
||||
Marker: marker,
|
||||
Overwrite: j.overwrite,
|
||||
fileNamingAlgorithm: j.fileNamingAlgo,
|
||||
VideoPreview: j.input.Markers,
|
||||
ImagePreview: j.input.MarkerImagePreviews,
|
||||
Screenshot: j.input.MarkerScreenshots,
|
||||
generator: g,
|
||||
}
|
||||
j.totals.markers++
|
||||
|
||||
@@ -18,6 +18,7 @@ type GenerateMarkersTask struct {
|
||||
Overwrite bool
|
||||
fileNamingAlgorithm models.HashAlgorithm
|
||||
|
||||
VideoPreview bool
|
||||
ImagePreview bool
|
||||
Screenshot bool
|
||||
|
||||
@@ -115,9 +116,11 @@ func (t *GenerateMarkersTask) generateMarker(videoFile *models.VideoFile, scene
|
||||
|
||||
g := t.generator
|
||||
|
||||
if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil {
|
||||
logger.Errorf("[generator] failed to generate marker video: %v", err)
|
||||
logErrorOutput(err)
|
||||
if t.VideoPreview {
|
||||
if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil {
|
||||
logger.Errorf("[generator] failed to generate marker video: %v", err)
|
||||
logErrorOutput(err)
|
||||
}
|
||||
}
|
||||
|
||||
if t.ImagePreview {
|
||||
@@ -164,7 +167,7 @@ func (t *GenerateMarkersTask) markerExists(sceneChecksum string, seconds int) bo
|
||||
return false
|
||||
}
|
||||
|
||||
videoExists := t.videoExists(sceneChecksum, seconds)
|
||||
videoExists := !t.VideoPreview || t.videoExists(sceneChecksum, seconds)
|
||||
imageExists := !t.ImagePreview || t.imageExists(sceneChecksum, seconds)
|
||||
screenshotExists := !t.Screenshot || t.screenshotExists(sceneChecksum, seconds)
|
||||
|
||||
|
||||
@@ -363,8 +363,11 @@ func (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw
|
||||
args = args.Append("scale_qsv=format=nv12")
|
||||
}
|
||||
case VideoCodecRK264:
|
||||
// For Rockchip, no extra mapping here. If there is no scale filter,
|
||||
// leave frames in DRM_PRIME for the encoder.
|
||||
// Full-hw decode on 10-bit sources often produces DRM_PRIME with sw_pix_fmt=nv15.
|
||||
// h264_rkmpp does NOT accept nv15, so we must force a conversion to nv12
|
||||
if fullhw {
|
||||
args = args.Append("scale_rkrga=w=iw:h=ih:format=nv12")
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
@@ -399,7 +402,7 @@ func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []in
|
||||
// by downloading the scaled frame to system RAM and re-uploading it.
|
||||
// The filter chain below uses a zero-copy approach, passing the hardware-scaled
|
||||
// frame directly to the encoder. This is more efficient but may be less stable.
|
||||
template = "scale_rkrga=$value"
|
||||
template = "scale_rkrga=$value:format=nv12"
|
||||
default:
|
||||
return VideoFilter(sargs)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -178,7 +179,16 @@ func (s *scanJob) execute(ctx context.Context) {
|
||||
wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
wg.Done()
|
||||
|
||||
// handle panics in goroutine
|
||||
if p := recover(); p != nil {
|
||||
logger.Errorf("panic while queuing files for scan: %v", p)
|
||||
logger.Errorf(string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := s.queueFiles(ctx, paths); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
@@ -204,6 +214,15 @@ func (s *scanJob) execute(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (s *scanJob) queueFiles(ctx context.Context, paths []string) error {
|
||||
defer func() {
|
||||
close(s.fileQueue)
|
||||
|
||||
if s.ProgressReports != nil {
|
||||
s.ProgressReports.AddTotal(s.count)
|
||||
s.ProgressReports.Definite()
|
||||
}
|
||||
}()
|
||||
|
||||
var err error
|
||||
s.ProgressReports.ExecuteTask("Walking directory tree", func() {
|
||||
for _, p := range paths {
|
||||
@@ -214,13 +233,6 @@ func (s *scanJob) queueFiles(ctx context.Context, paths []string) error {
|
||||
}
|
||||
})
|
||||
|
||||
close(s.fileQueue)
|
||||
|
||||
if s.ProgressReports != nil {
|
||||
s.ProgressReports.AddTotal(s.count)
|
||||
s.ProgressReports.Definite()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -883,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) {
|
||||
|
||||
@@ -473,6 +473,20 @@ func (_m *PerformerReaderWriter) HasImage(ctx context.Context, performerID int)
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Merge provides a mock function with given fields: ctx, source, destination
|
||||
func (_m *PerformerReaderWriter) Merge(ctx context.Context, source []int, destination int) error {
|
||||
ret := _m.Called(ctx, source, destination)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []int, int) error); ok {
|
||||
r0 = rf(ctx, source, destination)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Query provides a mock function with given fields: ctx, performerFilter, findFilter
|
||||
func (_m *PerformerReaderWriter) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) {
|
||||
ret := _m.Called(ctx, performerFilter, findFilter)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -92,6 +92,8 @@ type PerformerWriter interface {
|
||||
PerformerCreator
|
||||
PerformerUpdater
|
||||
PerformerDestroyer
|
||||
|
||||
Merge(ctx context.Context, source []int, destination int) error
|
||||
}
|
||||
|
||||
// PerformerReaderWriter provides all performer methods.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,6 +68,12 @@ func processImageField(ctx context.Context, imageField *string, client *http.Cli
|
||||
return nil
|
||||
}
|
||||
|
||||
// don't try to get the image if it doesn't appear to be a URL
|
||||
// this allows scrapers to return base64 data URIs directly
|
||||
if !strings.HasPrefix(*imageField, "http") {
|
||||
return nil
|
||||
}
|
||||
|
||||
img, err := getImage(ctx, *imageField, client, globalConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1012,6 +1012,11 @@ func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder)
|
||||
return
|
||||
}
|
||||
|
||||
// ideally, this handler should just convert to stashIDsCriterionHandler
|
||||
// but there are some differences in how the existing handler works compared
|
||||
// to the new code, specifically because this code uses the stringCriterionHandler.
|
||||
// To minimise potential regressions, we'll keep the existing logic for now.
|
||||
|
||||
stashIDRepo := h.stashIDRepository
|
||||
t := stashIDRepo.tableName
|
||||
if h.stashIDTableAs != "" {
|
||||
@@ -1036,6 +1041,53 @@ func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder)
|
||||
}, t+".stash_id")(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 != "" {
|
||||
t = h.stashIDTableAs
|
||||
}
|
||||
|
||||
joinClause := fmt.Sprintf("%s.%s = %s", t, stashIDRepo.idColumn, h.parentIDCol)
|
||||
if h.c.Endpoint != nil && *h.c.Endpoint != "" {
|
||||
joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint)
|
||||
}
|
||||
|
||||
f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause)
|
||||
|
||||
switch h.c.Modifier {
|
||||
case models.CriterionModifierIsNull:
|
||||
f.addWhere(fmt.Sprintf("%s.stash_id IS NULL", t))
|
||||
case models.CriterionModifierNotNull:
|
||||
f.addWhere(fmt.Sprintf("%s.stash_id IS NOT NULL", t))
|
||||
case models.CriterionModifierEquals:
|
||||
var clauses []sqlClause
|
||||
for _, id := range h.c.StashIDs {
|
||||
clauses = append(clauses, makeClause(fmt.Sprintf("%s.stash_id = ?", t), id))
|
||||
}
|
||||
f.whereClauses = append(f.whereClauses, orClauses(clauses...))
|
||||
case models.CriterionModifierNotEquals:
|
||||
var clauses []sqlClause
|
||||
for _, id := range h.c.StashIDs {
|
||||
clauses = append(clauses, makeClause(fmt.Sprintf("%s.stash_id != ?", t), id))
|
||||
}
|
||||
f.whereClauses = append(f.whereClauses, andClauses(clauses...))
|
||||
default:
|
||||
f.setError(fmt.Errorf("invalid modifier %s for stash IDs criterion", h.c.Modifier))
|
||||
}
|
||||
}
|
||||
|
||||
type relatedFilterHandler struct {
|
||||
relatedIDCol string
|
||||
relatedRepo repository
|
||||
|
||||
@@ -942,6 +942,7 @@ var imageSortOptions = sortOptions{
|
||||
"performer_count",
|
||||
"random",
|
||||
"rating",
|
||||
"resolution",
|
||||
"tag_count",
|
||||
"title",
|
||||
"updated_at",
|
||||
@@ -1001,6 +1002,14 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod
|
||||
case "mod_time", "filesize":
|
||||
addFilesJoin()
|
||||
sortClause = getSort(sort, direction, "files")
|
||||
case "resolution":
|
||||
addFilesJoin()
|
||||
q.addJoins(join{
|
||||
sort: true,
|
||||
table: imageFileTable,
|
||||
onClause: "images_files.file_id = image_files.file_id",
|
||||
})
|
||||
sortClause = " ORDER BY MIN(image_files.width, image_files.height) " + direction
|
||||
case "title":
|
||||
addFilesJoin()
|
||||
addFolderJoin()
|
||||
|
||||
@@ -893,3 +893,58 @@ func (qb *PerformerStore) FindByStashIDStatus(ctx context.Context, hasStashID bo
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *PerformerStore) Merge(ctx context.Context, source []int, destination int) error {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
inBinding := getInBinding(len(source))
|
||||
|
||||
args := []interface{}{destination}
|
||||
srcArgs := make([]interface{}, len(source))
|
||||
for i, id := range source {
|
||||
if id == destination {
|
||||
return errors.New("cannot merge where source == destination")
|
||||
}
|
||||
srcArgs[i] = id
|
||||
}
|
||||
|
||||
args = append(args, srcArgs...)
|
||||
|
||||
performerTables := map[string]string{
|
||||
performersScenesTable: sceneIDColumn,
|
||||
performersGalleriesTable: galleryIDColumn,
|
||||
performersImagesTable: imageIDColumn,
|
||||
performersTagsTable: tagIDColumn,
|
||||
}
|
||||
|
||||
args = append(args, destination)
|
||||
|
||||
// for each table, update source performer ids to destination performer id, ignoring duplicates
|
||||
for table, idColumn := range performerTables {
|
||||
_, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+`
|
||||
SET performer_id = ?
|
||||
WHERE performer_id IN `+inBinding+`
|
||||
AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idColumn+` AND o.performer_id = ?)`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete source performer ids from the table where they couldn't be set
|
||||
if _, err := dbWrapper.Exec(ctx, `DELETE FROM `+table+` WHERE performer_id IN `+inBinding, srcArgs...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range source {
|
||||
err := qb.Destroy(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -2524,6 +2580,146 @@ func TestPerformerStore_FindByStashIDStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformerMerge(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
srcIdxs []int
|
||||
destIdx int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "merge into self",
|
||||
srcIdxs: []int{performerIdx1WithDupName},
|
||||
destIdx: performerIdx1WithDupName,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "merge multiple",
|
||||
srcIdxs: []int{
|
||||
performerIdx2WithScene,
|
||||
performerIdxWithTwoScenes,
|
||||
performerIdx1WithImage,
|
||||
performerIdxWithTwoImages,
|
||||
performerIdxWithGallery,
|
||||
performerIdxWithTwoGalleries,
|
||||
performerIdxWithTag,
|
||||
performerIdxWithTwoTags,
|
||||
},
|
||||
destIdx: tagIdxWithPerformer,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
qb := db.Performer
|
||||
|
||||
for _, tt := range tests {
|
||||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
// load src tag ids to compare after merge
|
||||
performerTagIds := make(map[int][]int)
|
||||
for _, srcIdx := range tt.srcIdxs {
|
||||
srcPerformer, err := qb.Find(ctx, performerIDs[srcIdx])
|
||||
if err != nil {
|
||||
t.Errorf("Error finding performer: %s", err.Error())
|
||||
}
|
||||
if err := srcPerformer.LoadTagIDs(ctx, qb); err != nil {
|
||||
t.Errorf("Error loading performer tag IDs: %s", err.Error())
|
||||
}
|
||||
srcTagIDs := srcPerformer.TagIDs.List()
|
||||
performerTagIds[srcIdx] = srcTagIDs
|
||||
}
|
||||
|
||||
err := qb.Merge(ctx, indexesToIDs(tagIDs, tt.srcIdxs), tagIDs[tt.destIdx])
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("PerformerStore.Merge() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// ensure source performers are destroyed
|
||||
for _, srcIdx := range tt.srcIdxs {
|
||||
p, err := qb.Find(ctx, performerIDs[srcIdx])
|
||||
|
||||
// not found returns nil performer and nil error
|
||||
if err != nil {
|
||||
t.Errorf("Error finding performer: %s", err.Error())
|
||||
continue
|
||||
}
|
||||
assert.Nil(p)
|
||||
}
|
||||
|
||||
// ensure items point to new performer
|
||||
for _, srcIdx := range tt.srcIdxs {
|
||||
sceneIdxs := scenePerformers.reverseLookup(srcIdx)
|
||||
for _, sceneIdx := range sceneIdxs {
|
||||
s, err := db.Scene.Find(ctx, sceneIDs[sceneIdx])
|
||||
if err != nil {
|
||||
t.Errorf("Error finding scene: %s", err.Error())
|
||||
}
|
||||
if err := s.LoadPerformerIDs(ctx, db.Scene); err != nil {
|
||||
t.Errorf("Error loading scene performer IDs: %s", err.Error())
|
||||
}
|
||||
scenePerformerIDs := s.PerformerIDs.List()
|
||||
|
||||
assert.Contains(scenePerformerIDs, performerIDs[tt.destIdx])
|
||||
assert.NotContains(scenePerformerIDs, performerIDs[srcIdx])
|
||||
}
|
||||
|
||||
imageIdxs := imagePerformers.reverseLookup(srcIdx)
|
||||
for _, imageIdx := range imageIdxs {
|
||||
i, err := db.Image.Find(ctx, imageIDs[imageIdx])
|
||||
if err != nil {
|
||||
t.Errorf("Error finding image: %s", err.Error())
|
||||
}
|
||||
if err := i.LoadPerformerIDs(ctx, db.Image); err != nil {
|
||||
t.Errorf("Error loading image performer IDs: %s", err.Error())
|
||||
}
|
||||
imagePerformerIDs := i.PerformerIDs.List()
|
||||
|
||||
assert.Contains(imagePerformerIDs, performerIDs[tt.destIdx])
|
||||
assert.NotContains(imagePerformerIDs, performerIDs[srcIdx])
|
||||
}
|
||||
|
||||
galleryIdxs := galleryPerformers.reverseLookup(srcIdx)
|
||||
for _, galleryIdx := range galleryIdxs {
|
||||
g, err := db.Gallery.Find(ctx, galleryIDs[galleryIdx])
|
||||
if err != nil {
|
||||
t.Errorf("Error finding gallery: %s", err.Error())
|
||||
}
|
||||
if err := g.LoadPerformerIDs(ctx, db.Gallery); err != nil {
|
||||
t.Errorf("Error loading gallery performer IDs: %s", err.Error())
|
||||
}
|
||||
galleryPerformerIDs := g.PerformerIDs.List()
|
||||
|
||||
assert.Contains(galleryPerformerIDs, performerIDs[tt.destIdx])
|
||||
assert.NotContains(galleryPerformerIDs, performerIDs[srcIdx])
|
||||
}
|
||||
}
|
||||
|
||||
// ensure tags were merged
|
||||
destPerformer, err := qb.Find(ctx, performerIDs[tt.destIdx])
|
||||
if err != nil {
|
||||
t.Errorf("Error finding performer: %s", err.Error())
|
||||
}
|
||||
if err := destPerformer.LoadTagIDs(ctx, qb); err != nil {
|
||||
t.Errorf("Error loading performer tag IDs: %s", err.Error())
|
||||
}
|
||||
destTagIDs := destPerformer.TagIDs.List()
|
||||
|
||||
for _, srcIdx := range tt.srcIdxs {
|
||||
for _, tagID := range performerTagIds[srcIdx] {
|
||||
assert.Contains(destTagIDs, tagID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Update
|
||||
// TODO Destroy
|
||||
// TODO Find
|
||||
|
||||
@@ -1138,6 +1138,7 @@ var sceneSortOptions = sortOptions{
|
||||
"perceptual_similarity",
|
||||
"random",
|
||||
"rating",
|
||||
"resolution",
|
||||
"studio",
|
||||
"tag_count",
|
||||
"title",
|
||||
@@ -1236,6 +1237,9 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF
|
||||
sort = "frame_rate"
|
||||
addVideoFileTable()
|
||||
query.sortAndPagination += getSort(sort, direction, videoFileTable)
|
||||
case "resolution":
|
||||
addVideoFileTable()
|
||||
query.sortAndPagination += fmt.Sprintf(" ORDER BY MIN(%s.width, %s.height) %s", videoFileTable, videoFileTable, getSortDirection(direction))
|
||||
case "filesize":
|
||||
addFileTable()
|
||||
query.sortAndPagination += getSort(sort, direction, fileTable)
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,8 @@ func getCountSort(primaryTable, joinTable, primaryFK, direction string) string {
|
||||
return fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM %s AS sort WHERE sort.%s = %s.id) %s", joinTable, primaryFK, primaryTable, getSortDirection(direction))
|
||||
}
|
||||
|
||||
// getStringSearchClause returns a sqlClause for searching strings in the provided columns.
|
||||
// It is used for includes and excludes string criteria.
|
||||
func getStringSearchClause(columns []string, q string, not bool) sqlClause {
|
||||
var likeClauses []string
|
||||
var args []interface{}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -859,6 +859,8 @@ func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) er
|
||||
}
|
||||
|
||||
args = append(args, destination)
|
||||
|
||||
// for each table, update source tag ids to destination tag id, ignoring duplicates
|
||||
for table, idColumn := range tagTables {
|
||||
_, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+`
|
||||
SET tag_id = ?
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# Full fragment for detail views - includes recursive counts
|
||||
fragment GroupData on Group {
|
||||
id
|
||||
name
|
||||
@@ -39,3 +40,44 @@ fragment GroupData on Group {
|
||||
title
|
||||
}
|
||||
}
|
||||
|
||||
# Lightweight fragment for list views - excludes expensive recursive counts
|
||||
# The _all fields (depth: -1) cause 10+ second queries on large databases
|
||||
fragment ListGroupData on Group {
|
||||
id
|
||||
name
|
||||
aliases
|
||||
duration
|
||||
date
|
||||
rating100
|
||||
director
|
||||
|
||||
studio {
|
||||
...SlimStudioData
|
||||
}
|
||||
|
||||
tags {
|
||||
...SlimTagData
|
||||
}
|
||||
|
||||
containing_groups {
|
||||
group {
|
||||
...SlimGroupData
|
||||
}
|
||||
description
|
||||
}
|
||||
|
||||
synopsis
|
||||
urls
|
||||
front_image_path
|
||||
back_image_path
|
||||
scene_count
|
||||
performer_count
|
||||
sub_group_count
|
||||
o_counter
|
||||
|
||||
scenes {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,11 @@ fragment TagListData on Tag {
|
||||
aliases
|
||||
ignore_auto_tag
|
||||
favorite
|
||||
stash_ids {
|
||||
endpoint
|
||||
stash_id
|
||||
updated_at
|
||||
}
|
||||
image_path
|
||||
# Direct counts only - no recursive depth queries
|
||||
scene_count
|
||||
|
||||
@@ -23,3 +23,9 @@ mutation PerformerDestroy($id: ID!) {
|
||||
mutation PerformersDestroy($ids: [ID!]!) {
|
||||
performersDestroy(ids: $ids)
|
||||
}
|
||||
|
||||
mutation PerformerMerge($input: PerformerMergeInput!) {
|
||||
performerMerge(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ query FindGroups($filter: FindFilterType, $group_filter: GroupFilterType) {
|
||||
findGroups(filter: $filter, group_filter: $group_filter) {
|
||||
count
|
||||
groups {
|
||||
...GroupData
|
||||
...ListGroupData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
63
ui/v2.5/pnpm-lock.yaml
generated
63
ui/v2.5/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -307,7 +307,8 @@ export const App: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const titleProps = makeTitleProps();
|
||||
const title = config.data?.configuration.ui.title || "Stash";
|
||||
const titleProps = makeTitleProps(title);
|
||||
|
||||
if (!messages) {
|
||||
return null;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -23,12 +23,12 @@ import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet";
|
||||
import { IRelatedGroupEntry } from "./GroupDetails/RelatedGroupTable";
|
||||
|
||||
interface IListOperationProps {
|
||||
selected: GQL.GroupDataFragment[];
|
||||
selected: GQL.ListGroupDataFragment[];
|
||||
onClose: (applied: boolean) => void;
|
||||
}
|
||||
|
||||
export function getAggregateContainingGroups(
|
||||
state: Pick<GQL.GroupDataFragment, "containing_groups">[]
|
||||
state: Pick<GQL.ListGroupDataFragment, "containing_groups">[]
|
||||
) {
|
||||
const sortedLists: IRelatedGroupEntry[][] = state.map((o) =>
|
||||
o.containing_groups
|
||||
@@ -144,7 +144,7 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
|
||||
let updateDirector: string | undefined;
|
||||
let first = true;
|
||||
|
||||
state.forEach((group: GQL.GroupDataFragment) => {
|
||||
state.forEach((group: GQL.ListGroupDataFragment) => {
|
||||
const groupTagIDs = (group.tags ?? []).map((p) => p.id).sort();
|
||||
const groupContainingGroupIDs = (group.containing_groups ?? []).sort(
|
||||
(a, b) => a.group.id.localeCompare(b.group.id)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Button, ButtonGroup } from "react-bootstrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { GridCard } from "../Shared/GridCard/GridCard";
|
||||
import { HoverPopover } from "../Shared/HoverPopover";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
@@ -36,7 +37,7 @@ const Description: React.FC<{
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
group: GQL.GroupDataFragment;
|
||||
group: GQL.ListGroupDataFragment;
|
||||
cardWidth?: number;
|
||||
sceneNumber?: number;
|
||||
selecting?: boolean;
|
||||
@@ -47,137 +48,140 @@ interface IProps {
|
||||
onMove?: (srcIds: string[], targetId: string, after: boolean) => void;
|
||||
}
|
||||
|
||||
export const GroupCard: React.FC<IProps> = ({
|
||||
group,
|
||||
sceneNumber,
|
||||
cardWidth,
|
||||
selecting,
|
||||
selected,
|
||||
zoomIndex,
|
||||
onSelectedChanged,
|
||||
fromGroupId,
|
||||
onMove,
|
||||
}) => {
|
||||
const groupDescription = useMemo(() => {
|
||||
if (!fromGroupId) {
|
||||
return undefined;
|
||||
}
|
||||
export const GroupCard: React.FC<IProps> = PatchComponent(
|
||||
"GroupCard",
|
||||
({
|
||||
group,
|
||||
sceneNumber,
|
||||
cardWidth,
|
||||
selecting,
|
||||
selected,
|
||||
zoomIndex,
|
||||
onSelectedChanged,
|
||||
fromGroupId,
|
||||
onMove,
|
||||
}) => {
|
||||
const groupDescription = useMemo(() => {
|
||||
if (!fromGroupId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const containingGroup = group.containing_groups.find(
|
||||
(cg) => cg.group.id === fromGroupId
|
||||
);
|
||||
const containingGroup = group.containing_groups.find(
|
||||
(cg) => cg.group.id === fromGroupId
|
||||
);
|
||||
|
||||
return containingGroup?.description ?? undefined;
|
||||
}, [fromGroupId, group.containing_groups]);
|
||||
return containingGroup?.description ?? undefined;
|
||||
}, [fromGroupId, group.containing_groups]);
|
||||
|
||||
function maybeRenderScenesPopoverButton() {
|
||||
if (group.scenes.length === 0) return;
|
||||
function maybeRenderScenesPopoverButton() {
|
||||
if (group.scenes.length === 0) return;
|
||||
|
||||
const popoverContent = group.scenes.map((scene) => (
|
||||
<SceneLink key={scene.id} scene={scene} />
|
||||
));
|
||||
const popoverContent = group.scenes.map((scene) => (
|
||||
<SceneLink key={scene.id} scene={scene} />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
className="scene-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faPlayCircle} />
|
||||
<span>{group.scenes.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderTagPopoverButton() {
|
||||
if (group.tags.length <= 0) return;
|
||||
|
||||
const popoverContent = group.tags.map((tag) => (
|
||||
<TagLink key={tag.id} linkType="group" tag={tag} />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover placement="bottom" content={popoverContent}>
|
||||
<Button className="minimal tag-count">
|
||||
<Icon icon={faTag} />
|
||||
<span>{group.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOCounter() {
|
||||
if (!group.o_counter) return;
|
||||
|
||||
return <OCounterButton value={group.o_counter} />;
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (
|
||||
sceneNumber ||
|
||||
groupDescription ||
|
||||
group.scenes.length > 0 ||
|
||||
group.tags.length > 0 ||
|
||||
group.containing_groups.length > 0 ||
|
||||
group.sub_group_count > 0
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<Description
|
||||
sceneNumber={sceneNumber}
|
||||
description={groupDescription}
|
||||
/>
|
||||
<hr />
|
||||
<ButtonGroup className="card-popovers">
|
||||
{maybeRenderScenesPopoverButton()}
|
||||
{maybeRenderTagPopoverButton()}
|
||||
{(group.sub_group_count > 0 ||
|
||||
group.containing_groups.length > 0) && (
|
||||
<RelatedGroupPopoverButton group={group} />
|
||||
)}
|
||||
{maybeRenderOCounter()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
<HoverPopover
|
||||
className="scene-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faPlayCircle} />
|
||||
<span>{group.scenes.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<GridCard
|
||||
className={`group-card zoom-${zoomIndex}`}
|
||||
objectId={group.id}
|
||||
onMove={onMove}
|
||||
url={`/groups/${group.id}`}
|
||||
width={cardWidth}
|
||||
title={group.name}
|
||||
linkClassName="group-card-header"
|
||||
image={
|
||||
<>
|
||||
<img
|
||||
loading="lazy"
|
||||
className="group-card-image"
|
||||
alt={group.name ?? ""}
|
||||
src={group.front_image_path ?? ""}
|
||||
/>
|
||||
<RatingBanner rating={group.rating100} />
|
||||
</>
|
||||
function maybeRenderTagPopoverButton() {
|
||||
if (group.tags.length <= 0) return;
|
||||
|
||||
const popoverContent = group.tags.map((tag) => (
|
||||
<TagLink key={tag.id} linkType="group" tag={tag} />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover placement="bottom" content={popoverContent}>
|
||||
<Button className="minimal tag-count">
|
||||
<Icon icon={faTag} />
|
||||
<span>{group.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOCounter() {
|
||||
if (!group.o_counter) return;
|
||||
|
||||
return <OCounterButton value={group.o_counter} />;
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (
|
||||
sceneNumber ||
|
||||
groupDescription ||
|
||||
group.scenes.length > 0 ||
|
||||
group.tags.length > 0 ||
|
||||
group.containing_groups.length > 0 ||
|
||||
group.sub_group_count > 0
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<Description
|
||||
sceneNumber={sceneNumber}
|
||||
description={groupDescription}
|
||||
/>
|
||||
<hr />
|
||||
<ButtonGroup className="card-popovers">
|
||||
{maybeRenderScenesPopoverButton()}
|
||||
{maybeRenderTagPopoverButton()}
|
||||
{(group.sub_group_count > 0 ||
|
||||
group.containing_groups.length > 0) && (
|
||||
<RelatedGroupPopoverButton group={group} />
|
||||
)}
|
||||
{maybeRenderOCounter()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
details={
|
||||
<div className="group-card__details">
|
||||
<span className="group-card__date">{group.date}</span>
|
||||
<TruncatedText
|
||||
className="group-card__description"
|
||||
text={group.synopsis}
|
||||
lineCount={3}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
selected={selected}
|
||||
selecting={selecting}
|
||||
onSelectedChanged={onSelectedChanged}
|
||||
popovers={maybeRenderPopoverButtonGroup()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<GridCard
|
||||
className={`group-card zoom-${zoomIndex}`}
|
||||
objectId={group.id}
|
||||
onMove={onMove}
|
||||
url={`/groups/${group.id}`}
|
||||
width={cardWidth}
|
||||
title={group.name}
|
||||
linkClassName="group-card-header"
|
||||
image={
|
||||
<>
|
||||
<img
|
||||
loading="lazy"
|
||||
className="group-card-image"
|
||||
alt={group.name ?? ""}
|
||||
src={group.front_image_path ?? ""}
|
||||
/>
|
||||
<RatingBanner rating={group.rating100} />
|
||||
</>
|
||||
}
|
||||
details={
|
||||
<div className="group-card__details">
|
||||
<span className="group-card__date">{group.date}</span>
|
||||
<TruncatedText
|
||||
className="group-card__description"
|
||||
text={group.synopsis}
|
||||
lineCount={3}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
selected={selected}
|
||||
selecting={selecting}
|
||||
onSelectedChanged={onSelectedChanged}
|
||||
popovers={maybeRenderPopoverButtonGroup()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
useCardWidth,
|
||||
useContainerDimensions,
|
||||
} from "../Shared/GridCard/GridCard";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
interface IGroupCardGrid {
|
||||
groups: GQL.GroupDataFragment[];
|
||||
groups: GQL.ListGroupDataFragment[];
|
||||
selectedIds: Set<string>;
|
||||
zoomIndex: number;
|
||||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
|
||||
@@ -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}
|
||||
|
||||
@@ -199,7 +199,7 @@ export const GroupList: React.FC<IGroupList> = PatchComponent(
|
||||
}
|
||||
|
||||
function renderEditDialog(
|
||||
selectedGroups: GQL.GroupDataFragment[],
|
||||
selectedGroups: GQL.ListGroupDataFragment[],
|
||||
onClose: (applied: boolean) => void
|
||||
) {
|
||||
return <EditGroupsDialog selected={selectedGroups} onClose={onClose} />;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { GroupTag } from "./GroupTag";
|
||||
|
||||
interface IProps {
|
||||
group: Pick<
|
||||
GQL.GroupDataFragment,
|
||||
GQL.ListGroupDataFragment,
|
||||
"id" | "name" | "containing_groups" | "sub_group_count"
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
faTag,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { imageTitle } from "src/core/files";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { TruncatedText } from "../Shared/TruncatedText";
|
||||
import { StudioOverlay } from "../Shared/GridCard/StudioOverlay";
|
||||
import { OCounterButton } from "../Shared/CountButton";
|
||||
@@ -29,168 +30,171 @@ interface IImageCardProps {
|
||||
onPreview?: (ev: MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const ImageCard: React.FC<IImageCardProps> = (
|
||||
props: IImageCardProps
|
||||
) => {
|
||||
const file = useMemo(
|
||||
() =>
|
||||
props.image.visual_files.length > 0
|
||||
? props.image.visual_files[0]
|
||||
: undefined,
|
||||
[props.image]
|
||||
);
|
||||
|
||||
function maybeRenderTagPopoverButton() {
|
||||
if (props.image.tags.length <= 0) return;
|
||||
|
||||
const popoverContent = props.image.tags.map((tag) => (
|
||||
<TagLink key={tag.id} tag={tag} linkType="image" />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
className="tag-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faTag} />
|
||||
<span>{props.image.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
export const ImageCard: React.FC<IImageCardProps> = PatchComponent(
|
||||
"ImageCard",
|
||||
(props: IImageCardProps) => {
|
||||
const file = useMemo(
|
||||
() =>
|
||||
props.image.visual_files.length > 0
|
||||
? props.image.visual_files[0]
|
||||
: undefined,
|
||||
[props.image]
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderPerformerPopoverButton() {
|
||||
if (props.image.performers.length <= 0) return;
|
||||
function maybeRenderTagPopoverButton() {
|
||||
if (props.image.tags.length <= 0) return;
|
||||
|
||||
const popoverContent = props.image.tags.map((tag) => (
|
||||
<TagLink key={tag.id} tag={tag} linkType="image" />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
className="tag-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faTag} />
|
||||
<span>{props.image.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderPerformerPopoverButton() {
|
||||
if (props.image.performers.length <= 0) return;
|
||||
|
||||
return (
|
||||
<PerformerPopoverButton
|
||||
performers={props.image.performers}
|
||||
linkType="image"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOCounter() {
|
||||
if (props.image.o_counter) {
|
||||
return <OCounterButton value={props.image.o_counter} />;
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderGallery() {
|
||||
if (props.image.galleries.length <= 0) return;
|
||||
|
||||
const popoverContent = props.image.galleries.map((gallery) => (
|
||||
<GalleryLink key={gallery.id} gallery={gallery} />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
className="gallery-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faImages} />
|
||||
<span>{props.image.galleries.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOrganized() {
|
||||
if (props.image.organized) {
|
||||
return (
|
||||
<div className="organized">
|
||||
<Button className="minimal">
|
||||
<Icon icon={faBox} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (
|
||||
props.image.tags.length > 0 ||
|
||||
props.image.performers.length > 0 ||
|
||||
props.image.o_counter ||
|
||||
props.image.galleries.length > 0 ||
|
||||
props.image.organized
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<ButtonGroup className="card-popovers">
|
||||
{maybeRenderTagPopoverButton()}
|
||||
{maybeRenderPerformerPopoverButton()}
|
||||
{maybeRenderOCounter()}
|
||||
{maybeRenderGallery()}
|
||||
{maybeRenderOrganized()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isPortrait() {
|
||||
const width = file?.width ? file.width : 0;
|
||||
const height = file?.height ? file.height : 0;
|
||||
return height > width;
|
||||
}
|
||||
|
||||
const source =
|
||||
props.image.paths.preview != ""
|
||||
? props.image.paths.preview ?? ""
|
||||
: props.image.paths.thumbnail ?? "";
|
||||
const video = source.includes("preview");
|
||||
const ImagePreview = video ? "video" : "img";
|
||||
|
||||
return (
|
||||
<PerformerPopoverButton
|
||||
performers={props.image.performers}
|
||||
linkType="image"
|
||||
<GridCard
|
||||
className={`image-card zoom-${props.zoomIndex}`}
|
||||
url={`/images/${props.image.id}`}
|
||||
width={props.cardWidth}
|
||||
title={imageTitle(props.image)}
|
||||
linkClassName="image-card-link"
|
||||
image={
|
||||
<>
|
||||
<div
|
||||
className={cx("image-card-preview", { portrait: isPortrait() })}
|
||||
>
|
||||
<ImagePreview
|
||||
loop={video}
|
||||
autoPlay={video}
|
||||
playsInline={video}
|
||||
className="image-card-preview-image"
|
||||
alt={props.image.title ?? ""}
|
||||
src={source}
|
||||
/>
|
||||
{props.onPreview ? (
|
||||
<div className="preview-button">
|
||||
<Button onClick={props.onPreview}>
|
||||
<Icon icon={faSearch} />
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
<RatingBanner rating={props.image.rating100} />
|
||||
</>
|
||||
}
|
||||
details={
|
||||
<div className="image-card__details">
|
||||
<span className="image-card__date">{props.image.date}</span>
|
||||
<TruncatedText
|
||||
className="image-card__description"
|
||||
text={props.image.details}
|
||||
lineCount={3}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
overlays={<StudioOverlay studio={props.image.studio} />}
|
||||
popovers={maybeRenderPopoverButtonGroup()}
|
||||
selected={props.selected}
|
||||
selecting={props.selecting}
|
||||
onSelectedChanged={props.onSelectedChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOCounter() {
|
||||
if (props.image.o_counter) {
|
||||
return <OCounterButton value={props.image.o_counter} />;
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderGallery() {
|
||||
if (props.image.galleries.length <= 0) return;
|
||||
|
||||
const popoverContent = props.image.galleries.map((gallery) => (
|
||||
<GalleryLink key={gallery.id} gallery={gallery} />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
className="gallery-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faImages} />
|
||||
<span>{props.image.galleries.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOrganized() {
|
||||
if (props.image.organized) {
|
||||
return (
|
||||
<div className="organized">
|
||||
<Button className="minimal">
|
||||
<Icon icon={faBox} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (
|
||||
props.image.tags.length > 0 ||
|
||||
props.image.performers.length > 0 ||
|
||||
props.image.o_counter ||
|
||||
props.image.galleries.length > 0 ||
|
||||
props.image.organized
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<ButtonGroup className="card-popovers">
|
||||
{maybeRenderTagPopoverButton()}
|
||||
{maybeRenderPerformerPopoverButton()}
|
||||
{maybeRenderOCounter()}
|
||||
{maybeRenderGallery()}
|
||||
{maybeRenderOrganized()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isPortrait() {
|
||||
const width = file?.width ? file.width : 0;
|
||||
const height = file?.height ? file.height : 0;
|
||||
return height > width;
|
||||
}
|
||||
|
||||
const source =
|
||||
props.image.paths.preview != ""
|
||||
? props.image.paths.preview ?? ""
|
||||
: props.image.paths.thumbnail ?? "";
|
||||
const video = source.includes("preview");
|
||||
const ImagePreview = video ? "video" : "img";
|
||||
|
||||
return (
|
||||
<GridCard
|
||||
className={`image-card zoom-${props.zoomIndex}`}
|
||||
url={`/images/${props.image.id}`}
|
||||
width={props.cardWidth}
|
||||
title={imageTitle(props.image)}
|
||||
linkClassName="image-card-link"
|
||||
image={
|
||||
<>
|
||||
<div className={cx("image-card-preview", { portrait: isPortrait() })}>
|
||||
<ImagePreview
|
||||
loop={video}
|
||||
autoPlay={video}
|
||||
playsInline={video}
|
||||
className="image-card-preview-image"
|
||||
alt={props.image.title ?? ""}
|
||||
src={source}
|
||||
/>
|
||||
{props.onPreview ? (
|
||||
<div className="preview-button">
|
||||
<Button onClick={props.onPreview}>
|
||||
<Icon icon={faSearch} />
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
<RatingBanner rating={props.image.rating100} />
|
||||
</>
|
||||
}
|
||||
details={
|
||||
<div className="image-card__details">
|
||||
<span className="image-card__date">{props.image.date}</span>
|
||||
<TruncatedText
|
||||
className="image-card__description"
|
||||
text={props.image.details}
|
||||
lineCount={3}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
overlays={<StudioOverlay studio={props.image.studio} />}
|
||||
popovers={maybeRenderPopoverButtonGroup()}
|
||||
selected={props.selected}
|
||||
selecting={props.selecting}
|
||||
onSelectedChanged={props.onSelectedChanged}
|
||||
/>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Tabs, Tab, Col, Row } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { Button, Tabs, Tab, Col, Row } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
import cx from "classnames";
|
||||
@@ -28,6 +28,7 @@ import { PerformerGroupsPanel } from "./PerformerGroupsPanel";
|
||||
import { PerformerImagesPanel } from "./PerformerImagesPanel";
|
||||
import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel";
|
||||
import { PerformerEditPanel } from "./PerformerEditPanel";
|
||||
import { PerformerMergeModal } from "../PerformerMergeDialog";
|
||||
import { PerformerSubmitButton } from "./PerformerSubmitButton";
|
||||
import { useRatingKeybinds } from "src/hooks/keybinds";
|
||||
import { DetailImage } from "src/components/Shared/DetailImage";
|
||||
@@ -250,6 +251,7 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [isMerging, setIsMerging] = useState<boolean>(false);
|
||||
const [image, setImage] = useState<string | null>();
|
||||
const [encodingImage, setEncodingImage] = useState<boolean>(false);
|
||||
const loadStickyHeader = useLoadStickyHeader();
|
||||
@@ -285,6 +287,33 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
|
||||
}
|
||||
}
|
||||
|
||||
function renderMergeButton() {
|
||||
return (
|
||||
<Button variant="secondary" onClick={() => setIsMerging(true)}>
|
||||
<FormattedMessage id="actions.merge" />
|
||||
...
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMergeDialog() {
|
||||
if (!performer.id) return;
|
||||
return (
|
||||
<PerformerMergeModal
|
||||
show={isMerging}
|
||||
onClose={(mergedId) => {
|
||||
setIsMerging(false);
|
||||
if (mergedId !== undefined && mergedId !== performer.id) {
|
||||
// By default, the merge destination is the current performer, but
|
||||
// the user can change it, in which case we need to redirect.
|
||||
history.replace(`/performers/${mergedId}`);
|
||||
}
|
||||
}}
|
||||
performers={[performer]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
useRatingKeybinds(
|
||||
true,
|
||||
configuration?.ui.ratingSystemOptions?.type,
|
||||
@@ -469,9 +498,12 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
|
||||
onImageChange={() => {}}
|
||||
classNames="mb-2"
|
||||
customButtons={
|
||||
<div>
|
||||
<PerformerSubmitButton performer={performer} />
|
||||
</div>
|
||||
<>
|
||||
{renderMergeButton()}
|
||||
<div>
|
||||
<PerformerSubmitButton performer={performer} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
></DetailsEditNavbar>
|
||||
</Row>
|
||||
@@ -499,6 +531,7 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderMergeDialog()}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -681,6 +709,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
||||
onStashIDSelected(item);
|
||||
setIsStashIDSearchOpen(false);
|
||||
}}
|
||||
initialQuery={performer.name ?? ""}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ function renderScrapedGender(
|
||||
);
|
||||
}
|
||||
|
||||
function renderScrapedGenderRow(
|
||||
export function renderScrapedGenderRow(
|
||||
title: string,
|
||||
result: ScrapeResult<string>,
|
||||
onChange: (value: ScrapeResult<string>) => void
|
||||
@@ -104,7 +104,7 @@ function renderScrapedCircumcised(
|
||||
);
|
||||
}
|
||||
|
||||
function renderScrapedCircumcisedRow(
|
||||
export function renderScrapedCircumcisedRow(
|
||||
title: string,
|
||||
result: ScrapeResult<string>,
|
||||
onChange: (value: ScrapeResult<string>) => void
|
||||
|
||||
@@ -21,6 +21,7 @@ import { EditPerformersDialog } from "./EditPerformersDialog";
|
||||
import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { PerformerCardGrid } from "./PerformerCardGrid";
|
||||
import { PerformerMergeModal } from "./PerformerMergeDialog";
|
||||
import { View } from "../List/views";
|
||||
import { IItemListOperation } from "../List/FilteredListToolbar";
|
||||
import { PatchComponent } from "src/patch";
|
||||
@@ -169,6 +170,9 @@ export const PerformerList: React.FC<IPerformerList> = PatchComponent(
|
||||
({ filterHook, view, alterQuery, extraCriteria, extraOperations = [] }) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const [mergePerformers, setMergePerformers] = useState<
|
||||
GQL.SelectPerformerDataFragment[] | undefined
|
||||
>(undefined);
|
||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||
const [isExportAll, setIsExportAll] = useState(false);
|
||||
|
||||
@@ -180,6 +184,11 @@ export const PerformerList: React.FC<IPerformerList> = PatchComponent(
|
||||
text: intl.formatMessage({ id: "actions.open_random" }),
|
||||
onClick: openRandom,
|
||||
},
|
||||
{
|
||||
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
|
||||
onClick: merge,
|
||||
isDisplayed: showWhenSelected,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.export" }),
|
||||
onClick: onExport,
|
||||
@@ -222,6 +231,18 @@ export const PerformerList: React.FC<IPerformerList> = PatchComponent(
|
||||
}
|
||||
}
|
||||
|
||||
async function merge(
|
||||
result: GQL.FindPerformersQueryResult,
|
||||
filter: ListFilterModel,
|
||||
selectedIds: Set<string>
|
||||
) {
|
||||
const selected =
|
||||
result.data?.findPerformers.performers.filter((p) =>
|
||||
selectedIds.has(p.id)
|
||||
) ?? [];
|
||||
setMergePerformers(selected);
|
||||
}
|
||||
|
||||
async function onExport() {
|
||||
setIsExportAll(false);
|
||||
setIsExportDialogOpen(true);
|
||||
@@ -238,6 +259,23 @@ export const PerformerList: React.FC<IPerformerList> = PatchComponent(
|
||||
selectedIds: Set<string>,
|
||||
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
|
||||
) {
|
||||
function renderMergeDialog() {
|
||||
if (mergePerformers) {
|
||||
return (
|
||||
<PerformerMergeModal
|
||||
performers={mergePerformers}
|
||||
onClose={(mergedId?: string) => {
|
||||
setMergePerformers(undefined);
|
||||
if (mergedId) {
|
||||
history.push(`/performers/${mergedId}`);
|
||||
}
|
||||
}}
|
||||
show
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderPerformerExportDialog() {
|
||||
if (isExportDialogOpen) {
|
||||
return (
|
||||
@@ -290,6 +328,7 @@ export const PerformerList: React.FC<IPerformerList> = PatchComponent(
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderMergeDialog()}
|
||||
{maybeRenderPerformerExportDialog()}
|
||||
{renderPerformers()}
|
||||
</>
|
||||
|
||||
876
ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx
Normal file
876
ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx
Normal file
@@ -0,0 +1,876 @@
|
||||
import { Form, Col, Row, Button } from "react-bootstrap";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
import {
|
||||
circumcisedToString,
|
||||
stringToCircumcised,
|
||||
} from "src/utils/circumcised";
|
||||
import * as FormUtils from "src/utils/form";
|
||||
import { genderToString, stringToGender } from "src/utils/gender";
|
||||
import ImageUtils from "src/utils/image";
|
||||
import {
|
||||
mutatePerformerMerge,
|
||||
queryFindPerformersByID,
|
||||
} from "src/core/StashService";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog";
|
||||
import {
|
||||
ScrapedImageRow,
|
||||
ScrapedInputGroupRow,
|
||||
ScrapedStringListRow,
|
||||
ScrapedTextAreaRow,
|
||||
} from "../Shared/ScrapeDialog/ScrapeDialogRow";
|
||||
import { ModalComponent } from "../Shared/Modal";
|
||||
import { sortStoredIdObjects } from "src/utils/data";
|
||||
import {
|
||||
ObjectListScrapeResult,
|
||||
ScrapeResult,
|
||||
ZeroableScrapeResult,
|
||||
hasScrapedValues,
|
||||
} from "../Shared/ScrapeDialog/scrapeResult";
|
||||
import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow";
|
||||
import {
|
||||
renderScrapedGenderRow,
|
||||
renderScrapedCircumcisedRow,
|
||||
} from "./PerformerDetails/PerformerScrapeDialog";
|
||||
import { PerformerSelect } from "./PerformerSelect";
|
||||
import { uniq } from "lodash-es";
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
type CustomFieldScrapeResults = Map<string, ZeroableScrapeResult<any>>;
|
||||
|
||||
// There are a bunch of similar functions in PerformerScrapeDialog, but since we don't support
|
||||
// scraping custom fields, this one is only needed here. The `renderScraped` naming is kept the same
|
||||
// for consistency.
|
||||
function renderScrapedCustomFieldRows(
|
||||
results: CustomFieldScrapeResults,
|
||||
onChange: (newCustomFields: CustomFieldScrapeResults) => void
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{Array.from(results.entries()).map(([field, result]) => {
|
||||
const fieldName = `custom_${field}`;
|
||||
return (
|
||||
<ScrapedInputGroupRow
|
||||
className="custom-field"
|
||||
title={field}
|
||||
field={fieldName}
|
||||
key={fieldName}
|
||||
result={result}
|
||||
onChange={(newResult) => {
|
||||
const newResults = new Map(results);
|
||||
newResults.set(field, newResult);
|
||||
onChange(newResults);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MergeOptions = {
|
||||
values: GQL.PerformerUpdateInput;
|
||||
};
|
||||
|
||||
interface IPerformerMergeDetailsProps {
|
||||
sources: GQL.PerformerDataFragment[];
|
||||
dest: GQL.PerformerDataFragment;
|
||||
onClose: (options?: MergeOptions) => void;
|
||||
}
|
||||
|
||||
const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
|
||||
sources,
|
||||
dest,
|
||||
onClose,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [name, setName] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.name)
|
||||
);
|
||||
const [disambiguation, setDisambiguation] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.disambiguation)
|
||||
);
|
||||
const [aliases, setAliases] = useState<ScrapeResult<string[]>>(
|
||||
new ScrapeResult<string[]>(dest.alias_list)
|
||||
);
|
||||
const [birthdate, setBirthdate] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.birthdate)
|
||||
);
|
||||
const [deathDate, setDeathDate] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.death_date)
|
||||
);
|
||||
const [ethnicity, setEthnicity] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.ethnicity)
|
||||
);
|
||||
const [country, setCountry] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.country)
|
||||
);
|
||||
const [hairColor, setHairColor] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.hair_color)
|
||||
);
|
||||
const [eyeColor, setEyeColor] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.eye_color)
|
||||
);
|
||||
const [height, setHeight] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.height_cm?.toString())
|
||||
);
|
||||
const [weight, setWeight] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.weight?.toString())
|
||||
);
|
||||
const [penisLength, setPenisLength] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.penis_length?.toString())
|
||||
);
|
||||
const [measurements, setMeasurements] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.measurements)
|
||||
);
|
||||
const [fakeTits, setFakeTits] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.fake_tits)
|
||||
);
|
||||
const [careerLength, setCareerLength] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.career_length)
|
||||
);
|
||||
const [tattoos, setTattoos] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.tattoos)
|
||||
);
|
||||
const [piercings, setPiercings] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.piercings)
|
||||
);
|
||||
const [urls, setURLs] = useState<ScrapeResult<string[]>>(
|
||||
new ScrapeResult<string[]>(dest.urls)
|
||||
);
|
||||
const [gender, setGender] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(genderToString(dest.gender))
|
||||
);
|
||||
const [circumcised, setCircumcised] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(circumcisedToString(dest.circumcised))
|
||||
);
|
||||
const [details, setDetails] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.details)
|
||||
);
|
||||
const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>(
|
||||
new ObjectListScrapeResult<GQL.ScrapedTag>(
|
||||
sortStoredIdObjects(dest.tags.map(idToStoredID))
|
||||
)
|
||||
);
|
||||
|
||||
const [image, setImage] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(dest.image_path)
|
||||
);
|
||||
|
||||
const [customFields, setCustomFields] = useState<CustomFieldScrapeResults>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
function idToStoredID(o: { id: string; name: string }) {
|
||||
return {
|
||||
stored_id: o.id,
|
||||
name: o.name,
|
||||
};
|
||||
}
|
||||
|
||||
// calculate the values for everything
|
||||
// uses the first set value for single value fields, and combines all
|
||||
useEffect(() => {
|
||||
async function loadImages() {
|
||||
const src = sources.find((s) => s.image_path);
|
||||
if (!dest.image_path || !src) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const destData = await ImageUtils.imageToDataURL(dest.image_path);
|
||||
const srcData = await ImageUtils.imageToDataURL(src.image_path!);
|
||||
|
||||
// keep destination image by default
|
||||
const useNewValue = false;
|
||||
setImage(new ScrapeResult(destData, srcData, useNewValue));
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setName(
|
||||
new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name)
|
||||
);
|
||||
setDisambiguation(
|
||||
new ScrapeResult(
|
||||
dest.disambiguation,
|
||||
sources.find((s) => s.disambiguation)?.disambiguation,
|
||||
!dest.disambiguation
|
||||
)
|
||||
);
|
||||
|
||||
// default alias list should be the existing aliases, plus the names of all sources,
|
||||
// plus all source aliases, deduplicated
|
||||
const allAliases = uniq(
|
||||
dest.alias_list.concat(
|
||||
sources.map((s) => s.name),
|
||||
sources.flatMap((s) => s.alias_list)
|
||||
)
|
||||
);
|
||||
|
||||
setAliases(
|
||||
new ScrapeResult(dest.alias_list, allAliases, !!allAliases.length)
|
||||
);
|
||||
setBirthdate(
|
||||
new ScrapeResult(
|
||||
dest.birthdate,
|
||||
sources.find((s) => s.birthdate)?.birthdate,
|
||||
!dest.birthdate
|
||||
)
|
||||
);
|
||||
setDeathDate(
|
||||
new ScrapeResult(
|
||||
dest.death_date,
|
||||
sources.find((s) => s.death_date)?.death_date,
|
||||
!dest.death_date
|
||||
)
|
||||
);
|
||||
setEthnicity(
|
||||
new ScrapeResult(
|
||||
dest.ethnicity,
|
||||
sources.find((s) => s.ethnicity)?.ethnicity,
|
||||
!dest.ethnicity
|
||||
)
|
||||
);
|
||||
setCountry(
|
||||
new ScrapeResult(
|
||||
dest.country,
|
||||
sources.find((s) => s.country)?.country,
|
||||
!dest.country
|
||||
)
|
||||
);
|
||||
setHairColor(
|
||||
new ScrapeResult(
|
||||
dest.hair_color,
|
||||
sources.find((s) => s.hair_color)?.hair_color,
|
||||
!dest.hair_color
|
||||
)
|
||||
);
|
||||
setEyeColor(
|
||||
new ScrapeResult(
|
||||
dest.eye_color,
|
||||
sources.find((s) => s.eye_color)?.eye_color,
|
||||
!dest.eye_color
|
||||
)
|
||||
);
|
||||
setHeight(
|
||||
new ScrapeResult(
|
||||
dest.height_cm?.toString(),
|
||||
sources.find((s) => s.height_cm)?.height_cm?.toString(),
|
||||
!dest.height_cm
|
||||
)
|
||||
);
|
||||
setWeight(
|
||||
new ScrapeResult(
|
||||
dest.weight?.toString(),
|
||||
sources.find((s) => s.weight)?.weight?.toString(),
|
||||
!dest.weight
|
||||
)
|
||||
);
|
||||
|
||||
setPenisLength(
|
||||
new ScrapeResult(
|
||||
dest.penis_length?.toString(),
|
||||
sources.find((s) => s.penis_length)?.penis_length?.toString(),
|
||||
!dest.penis_length
|
||||
)
|
||||
);
|
||||
setMeasurements(
|
||||
new ScrapeResult(
|
||||
dest.measurements,
|
||||
sources.find((s) => s.measurements)?.measurements,
|
||||
!dest.measurements
|
||||
)
|
||||
);
|
||||
setFakeTits(
|
||||
new ScrapeResult(
|
||||
dest.fake_tits,
|
||||
sources.find((s) => s.fake_tits)?.fake_tits,
|
||||
!dest.fake_tits
|
||||
)
|
||||
);
|
||||
setCareerLength(
|
||||
new ScrapeResult(
|
||||
dest.career_length,
|
||||
sources.find((s) => s.career_length)?.career_length,
|
||||
!dest.career_length
|
||||
)
|
||||
);
|
||||
setTattoos(
|
||||
new ScrapeResult(
|
||||
dest.tattoos,
|
||||
sources.find((s) => s.tattoos)?.tattoos,
|
||||
!dest.tattoos
|
||||
)
|
||||
);
|
||||
setPiercings(
|
||||
new ScrapeResult(
|
||||
dest.piercings,
|
||||
sources.find((s) => s.piercings)?.piercings,
|
||||
!dest.piercings
|
||||
)
|
||||
);
|
||||
setURLs(
|
||||
new ScrapeResult(
|
||||
dest.urls,
|
||||
sources.find((s) => s.urls)?.urls,
|
||||
!dest.urls?.length
|
||||
)
|
||||
);
|
||||
setGender(
|
||||
new ScrapeResult(
|
||||
genderToString(dest.gender),
|
||||
sources.find((s) => s.gender)?.gender
|
||||
? genderToString(sources.find((s) => s.gender)?.gender)
|
||||
: undefined,
|
||||
!dest.gender
|
||||
)
|
||||
);
|
||||
setCircumcised(
|
||||
new ScrapeResult(
|
||||
circumcisedToString(dest.circumcised),
|
||||
sources.find((s) => s.circumcised)?.circumcised
|
||||
? circumcisedToString(sources.find((s) => s.circumcised)?.circumcised)
|
||||
: undefined,
|
||||
!dest.circumcised
|
||||
)
|
||||
);
|
||||
setDetails(
|
||||
new ScrapeResult(
|
||||
dest.details,
|
||||
sources.find((s) => s.details)?.details,
|
||||
!dest.details
|
||||
)
|
||||
);
|
||||
setImage(
|
||||
new ScrapeResult(
|
||||
dest.image_path,
|
||||
sources.find((s) => s.image_path)?.image_path,
|
||||
!dest.image_path
|
||||
)
|
||||
);
|
||||
|
||||
const customFieldNames = new Set<string>(Object.keys(dest.custom_fields));
|
||||
|
||||
for (const s of sources) {
|
||||
for (const n of Object.keys(s.custom_fields)) {
|
||||
customFieldNames.add(n);
|
||||
}
|
||||
}
|
||||
|
||||
setCustomFields(
|
||||
new Map(
|
||||
Array.from(customFieldNames)
|
||||
.sort()
|
||||
.map((field) => {
|
||||
return [
|
||||
field,
|
||||
new ScrapeResult(
|
||||
dest.custom_fields?.[field],
|
||||
sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[
|
||||
field
|
||||
],
|
||||
dest.custom_fields?.[field] === undefined
|
||||
),
|
||||
];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
loadImages();
|
||||
}, [sources, dest]);
|
||||
|
||||
const hasCustomFieldValues = useMemo(() => {
|
||||
return hasScrapedValues(Array.from(customFields.values()));
|
||||
}, [customFields]);
|
||||
|
||||
// ensure this is updated if fields are changed
|
||||
const hasValues = useMemo(() => {
|
||||
return (
|
||||
hasCustomFieldValues ||
|
||||
hasScrapedValues([
|
||||
name,
|
||||
disambiguation,
|
||||
aliases,
|
||||
birthdate,
|
||||
deathDate,
|
||||
ethnicity,
|
||||
country,
|
||||
hairColor,
|
||||
eyeColor,
|
||||
height,
|
||||
weight,
|
||||
penisLength,
|
||||
measurements,
|
||||
fakeTits,
|
||||
careerLength,
|
||||
tattoos,
|
||||
piercings,
|
||||
urls,
|
||||
gender,
|
||||
circumcised,
|
||||
details,
|
||||
tags,
|
||||
image,
|
||||
])
|
||||
);
|
||||
}, [
|
||||
name,
|
||||
disambiguation,
|
||||
aliases,
|
||||
birthdate,
|
||||
deathDate,
|
||||
ethnicity,
|
||||
country,
|
||||
hairColor,
|
||||
eyeColor,
|
||||
height,
|
||||
weight,
|
||||
penisLength,
|
||||
measurements,
|
||||
fakeTits,
|
||||
careerLength,
|
||||
tattoos,
|
||||
piercings,
|
||||
urls,
|
||||
gender,
|
||||
circumcised,
|
||||
details,
|
||||
tags,
|
||||
image,
|
||||
hasCustomFieldValues,
|
||||
]);
|
||||
|
||||
function renderScrapeRows() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasValues) {
|
||||
return (
|
||||
<div>
|
||||
<FormattedMessage id="dialogs.merge.empty_results" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrapedInputGroupRow
|
||||
field="name"
|
||||
title={intl.formatMessage({ id: "name" })}
|
||||
result={name}
|
||||
onChange={(value) => setName(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="disambiguation"
|
||||
title={intl.formatMessage({ id: "disambiguation" })}
|
||||
result={disambiguation}
|
||||
onChange={(value) => setDisambiguation(value)}
|
||||
/>
|
||||
<ScrapedStringListRow
|
||||
field="aliases"
|
||||
title={intl.formatMessage({ id: "aliases" })}
|
||||
result={aliases}
|
||||
onChange={(value) => setAliases(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="birthdate"
|
||||
title={intl.formatMessage({ id: "birthdate" })}
|
||||
result={birthdate}
|
||||
onChange={(value) => setBirthdate(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="death_date"
|
||||
title={intl.formatMessage({ id: "death_date" })}
|
||||
result={deathDate}
|
||||
onChange={(value) => setDeathDate(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="ethnicity"
|
||||
title={intl.formatMessage({ id: "ethnicity" })}
|
||||
result={ethnicity}
|
||||
onChange={(value) => setEthnicity(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="country"
|
||||
title={intl.formatMessage({ id: "country" })}
|
||||
result={country}
|
||||
onChange={(value) => setCountry(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="hair_color"
|
||||
title={intl.formatMessage({ id: "hair_color" })}
|
||||
result={hairColor}
|
||||
onChange={(value) => setHairColor(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="eye_color"
|
||||
title={intl.formatMessage({ id: "eye_color" })}
|
||||
result={eyeColor}
|
||||
onChange={(value) => setEyeColor(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="height"
|
||||
title={intl.formatMessage({ id: "height" })}
|
||||
result={height}
|
||||
onChange={(value) => setHeight(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="weight"
|
||||
title={intl.formatMessage({ id: "weight" })}
|
||||
result={weight}
|
||||
onChange={(value) => setWeight(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="penis_length"
|
||||
title={intl.formatMessage({ id: "penis_length" })}
|
||||
result={penisLength}
|
||||
onChange={(value) => setPenisLength(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="measurements"
|
||||
title={intl.formatMessage({ id: "measurements" })}
|
||||
result={measurements}
|
||||
onChange={(value) => setMeasurements(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="fake_tits"
|
||||
title={intl.formatMessage({ id: "fake_tits" })}
|
||||
result={fakeTits}
|
||||
onChange={(value) => setFakeTits(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
field="career_length"
|
||||
title={intl.formatMessage({ id: "career_length" })}
|
||||
result={careerLength}
|
||||
onChange={(value) => setCareerLength(value)}
|
||||
/>
|
||||
<ScrapedTextAreaRow
|
||||
field="tattoos"
|
||||
title={intl.formatMessage({ id: "tattoos" })}
|
||||
result={tattoos}
|
||||
onChange={(value) => setTattoos(value)}
|
||||
/>
|
||||
<ScrapedTextAreaRow
|
||||
field="piercings"
|
||||
title={intl.formatMessage({ id: "piercings" })}
|
||||
result={piercings}
|
||||
onChange={(value) => setPiercings(value)}
|
||||
/>
|
||||
<ScrapedStringListRow
|
||||
field="urls"
|
||||
title={intl.formatMessage({ id: "urls" })}
|
||||
result={urls}
|
||||
onChange={(value) => setURLs(value)}
|
||||
/>
|
||||
{renderScrapedGenderRow(
|
||||
intl.formatMessage({ id: "gender" }),
|
||||
gender,
|
||||
(value) => setGender(value)
|
||||
)}
|
||||
{renderScrapedCircumcisedRow(
|
||||
intl.formatMessage({ id: "circumcised" }),
|
||||
circumcised,
|
||||
(value) => setCircumcised(value)
|
||||
)}
|
||||
<ScrapedTagsRow
|
||||
field="tags"
|
||||
title={intl.formatMessage({ id: "tags" })}
|
||||
result={tags}
|
||||
onChange={(value) => setTags(value)}
|
||||
/>
|
||||
<ScrapedTextAreaRow
|
||||
field="details"
|
||||
title={intl.formatMessage({ id: "details" })}
|
||||
result={details}
|
||||
onChange={(value) => setDetails(value)}
|
||||
/>
|
||||
<ScrapedImageRow
|
||||
field="image"
|
||||
title={intl.formatMessage({ id: "performer_image" })}
|
||||
className="performer-image"
|
||||
result={image}
|
||||
onChange={(value) => setImage(value)}
|
||||
/>
|
||||
{hasCustomFieldValues &&
|
||||
renderScrapedCustomFieldRows(customFields, (newCustomFields) =>
|
||||
setCustomFields(newCustomFields)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function createValues(): MergeOptions {
|
||||
// only set the cover image if it's different from the existing cover image
|
||||
const coverImage = image.useNewValue ? image.getNewValue() : undefined;
|
||||
|
||||
return {
|
||||
values: {
|
||||
id: dest.id,
|
||||
name: name.getNewValue(),
|
||||
disambiguation: disambiguation.getNewValue(),
|
||||
alias_list: aliases
|
||||
.getNewValue()
|
||||
?.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0),
|
||||
birthdate: birthdate.getNewValue(),
|
||||
death_date: deathDate.getNewValue(),
|
||||
ethnicity: ethnicity.getNewValue(),
|
||||
country: country.getNewValue(),
|
||||
hair_color: hairColor.getNewValue(),
|
||||
eye_color: eyeColor.getNewValue(),
|
||||
height_cm: height.getNewValue()
|
||||
? parseFloat(height.getNewValue()!)
|
||||
: undefined,
|
||||
weight: weight.getNewValue()
|
||||
? parseFloat(weight.getNewValue()!)
|
||||
: undefined,
|
||||
penis_length: penisLength.getNewValue()
|
||||
? parseFloat(penisLength.getNewValue()!)
|
||||
: undefined,
|
||||
measurements: measurements.getNewValue(),
|
||||
fake_tits: fakeTits.getNewValue(),
|
||||
career_length: careerLength.getNewValue(),
|
||||
tattoos: tattoos.getNewValue(),
|
||||
piercings: piercings.getNewValue(),
|
||||
urls: urls.getNewValue(),
|
||||
gender: stringToGender(gender.getNewValue()),
|
||||
circumcised: stringToCircumcised(circumcised.getNewValue()),
|
||||
tag_ids: tags.getNewValue()?.map((t) => t.stored_id!),
|
||||
details: details.getNewValue(),
|
||||
image: coverImage,
|
||||
custom_fields: {
|
||||
partial: Object.fromEntries(
|
||||
Array.from(customFields.entries()).flatMap(([field, v]) =>
|
||||
v.useNewValue ? [[field, v.getNewValue()]] : []
|
||||
)
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dialogTitle = intl.formatMessage({
|
||||
id: "actions.merge",
|
||||
});
|
||||
|
||||
const destinationLabel = !hasValues
|
||||
? ""
|
||||
: intl.formatMessage({ id: "dialogs.merge.destination" });
|
||||
const sourceLabel = !hasValues
|
||||
? ""
|
||||
: intl.formatMessage({ id: "dialogs.merge.source" });
|
||||
|
||||
return (
|
||||
<ScrapeDialog
|
||||
className="performer-merge-dialog"
|
||||
title={dialogTitle}
|
||||
existingLabel={destinationLabel}
|
||||
scrapedLabel={sourceLabel}
|
||||
onClose={(apply) => {
|
||||
if (!apply) {
|
||||
onClose();
|
||||
} else {
|
||||
onClose(createValues());
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderScrapeRows()}
|
||||
</ScrapeDialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface IPerformerMergeModalProps {
|
||||
show: boolean;
|
||||
onClose: (mergedId?: string) => void;
|
||||
performers: GQL.SelectPerformerDataFragment[];
|
||||
}
|
||||
|
||||
export const PerformerMergeModal: React.FC<IPerformerMergeModalProps> = ({
|
||||
show,
|
||||
onClose,
|
||||
performers,
|
||||
}) => {
|
||||
const [sourcePerformers, setSourcePerformers] = useState<
|
||||
GQL.SelectPerformerDataFragment[]
|
||||
>([]);
|
||||
const [destPerformer, setDestPerformer] = useState<
|
||||
GQL.SelectPerformerDataFragment[]
|
||||
>([]);
|
||||
|
||||
const [loadedSources, setLoadedSources] = useState<
|
||||
GQL.PerformerDataFragment[]
|
||||
>([]);
|
||||
const [loadedDest, setLoadedDest] = useState<GQL.PerformerDataFragment>();
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
const [secondStep, setSecondStep] = useState(false);
|
||||
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
|
||||
const title = intl.formatMessage({
|
||||
id: "actions.merge",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (performers.length > 0) {
|
||||
// set the first performer as the destination, others as source
|
||||
setDestPerformer([performers[0]]);
|
||||
|
||||
if (performers.length > 1) {
|
||||
setSourcePerformers(performers.slice(1));
|
||||
}
|
||||
}
|
||||
}, [performers]);
|
||||
|
||||
async function loadPerformers() {
|
||||
const performerIDs = sourcePerformers.map((s) => parseInt(s.id));
|
||||
performerIDs.push(parseInt(destPerformer[0].id));
|
||||
const query = await queryFindPerformersByID(performerIDs);
|
||||
const { performers: loadedPerformers } = query.data.findPerformers;
|
||||
|
||||
setLoadedDest(loadedPerformers.find((s) => s.id === destPerformer[0].id));
|
||||
setLoadedSources(
|
||||
loadedPerformers.filter((s) => s.id !== destPerformer[0].id)
|
||||
);
|
||||
setSecondStep(true);
|
||||
}
|
||||
|
||||
async function onMerge(options: MergeOptions) {
|
||||
const { values } = options;
|
||||
try {
|
||||
setRunning(true);
|
||||
const result = await mutatePerformerMerge(
|
||||
destPerformer[0].id,
|
||||
sourcePerformers.map((s) => s.id),
|
||||
values
|
||||
);
|
||||
if (result.data?.performerMerge) {
|
||||
Toast.success(intl.formatMessage({ id: "toast.merged_performers" }));
|
||||
onClose(destPerformer[0].id);
|
||||
}
|
||||
onClose();
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function canMerge() {
|
||||
return sourcePerformers.length > 0 && destPerformer.length !== 0;
|
||||
}
|
||||
|
||||
function switchPerformers() {
|
||||
if (sourcePerformers.length && destPerformer.length) {
|
||||
const newDest = sourcePerformers[0];
|
||||
setSourcePerformers([...sourcePerformers.slice(1), destPerformer[0]]);
|
||||
setDestPerformer([newDest]);
|
||||
}
|
||||
}
|
||||
|
||||
if (secondStep && destPerformer.length > 0) {
|
||||
return (
|
||||
<PerformerMergeDetails
|
||||
sources={loadedSources}
|
||||
dest={loadedDest!}
|
||||
onClose={(values) => {
|
||||
setSecondStep(false);
|
||||
if (values) {
|
||||
onMerge(values);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalComponent
|
||||
dialogClassName="performer-merge-dialog"
|
||||
show={show}
|
||||
header={title}
|
||||
icon={faSignInAlt}
|
||||
accept={{
|
||||
text: intl.formatMessage({ id: "actions.next_action" }),
|
||||
onClick: () => loadPerformers(),
|
||||
}}
|
||||
disabled={!canMerge()}
|
||||
cancel={{
|
||||
variant: "secondary",
|
||||
onClick: () => onClose(),
|
||||
}}
|
||||
isRunning={running}
|
||||
>
|
||||
<div className="form-container row px-3">
|
||||
<div className="col-12 col-lg-6 col-xl-12">
|
||||
<Form.Group controlId="source" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "dialogs.merge.source" }),
|
||||
labelProps: {
|
||||
column: true,
|
||||
sm: 3,
|
||||
xl: 12,
|
||||
},
|
||||
})}
|
||||
<Col sm={9} xl={12}>
|
||||
<PerformerSelect
|
||||
isMulti
|
||||
onSelect={(items) => setSourcePerformers(items)}
|
||||
values={sourcePerformers}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group
|
||||
controlId="switch"
|
||||
as={Row}
|
||||
className="justify-content-center"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => switchPerformers()}
|
||||
disabled={!sourcePerformers.length || !destPerformer.length}
|
||||
title={intl.formatMessage({ id: "actions.swap" })}
|
||||
>
|
||||
<Icon className="fa-fw" icon={faExchangeAlt} />
|
||||
</Button>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="destination" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({
|
||||
id: "dialogs.merge.destination",
|
||||
}),
|
||||
labelProps: {
|
||||
column: true,
|
||||
sm: 3,
|
||||
xl: 12,
|
||||
},
|
||||
})}
|
||||
<Col sm={9} xl={12}>
|
||||
<PerformerSelect
|
||||
onSelect={(items) => setDestPerformer(items)}
|
||||
values={destPerformer}
|
||||
menuPortalTarget={document.body}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
</div>
|
||||
</div>
|
||||
</ModalComponent>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
@@ -302,3 +306,11 @@
|
||||
overflow-y: auto;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.performer-merge-dialog .custom-field {
|
||||
// ensure we don't catch the destination/source labels
|
||||
& > .form-label,
|
||||
.form-control {
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +364,7 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = PatchComponent(
|
||||
},
|
||||
nativeControlsForTouch: false,
|
||||
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
|
||||
inactivityTimeout: 2000,
|
||||
inactivityTimeout: 700,
|
||||
preload: "none",
|
||||
playsinline: true,
|
||||
techOrder: ["chromecast", "html5"],
|
||||
@@ -932,15 +932,23 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = PatchComponent(
|
||||
);
|
||||
}, [getPlayer, scene]);
|
||||
|
||||
const pausedBeforeScrubber = useRef(true);
|
||||
|
||||
function onScrubberScroll() {
|
||||
if (started.current) {
|
||||
getPlayer()?.pause();
|
||||
const player = getPlayer();
|
||||
if (started.current && player) {
|
||||
pausedBeforeScrubber.current = player.paused();
|
||||
player.pause();
|
||||
}
|
||||
}
|
||||
|
||||
function onScrubberSeek(seconds: number) {
|
||||
if (started.current) {
|
||||
getPlayer()?.currentTime(seconds);
|
||||
const player = getPlayer();
|
||||
if (started.current && player) {
|
||||
player.currentTime(seconds);
|
||||
if (!pausedBeforeScrubber.current) {
|
||||
player.play();
|
||||
}
|
||||
} else {
|
||||
setTime(seconds);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import React, {
|
||||
useLayoutEffect,
|
||||
} from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Link, RouteComponentProps } from "react-router-dom";
|
||||
import { useHistory, Link, RouteComponentProps } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
@@ -50,6 +50,7 @@ import { lazyComponent } from "src/utils/lazyComponent";
|
||||
import cx from "classnames";
|
||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
||||
import { PatchComponent, PatchContainerComponent } from "src/patch";
|
||||
import { SceneMergeModal } from "../SceneMergeDialog";
|
||||
import { goBackOrReplace } from "src/utils/history";
|
||||
import { FormattedDate } from "src/components/Shared/Date";
|
||||
|
||||
@@ -182,6 +183,7 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
|
||||
|
||||
const Toast = useToast();
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const [updateScene] = useSceneUpdate();
|
||||
const [generateScreenshot] = useSceneGenerateScreenshot();
|
||||
const { configuration } = useConfigurationContext();
|
||||
@@ -205,6 +207,7 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
|
||||
|
||||
const [activeTabKey, setActiveTabKey] = useState("scene-details-panel");
|
||||
|
||||
const [isMerging, setIsMerging] = useState(false);
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);
|
||||
|
||||
@@ -347,6 +350,24 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderMergeDialog() {
|
||||
if (!scene.id) return;
|
||||
return (
|
||||
<SceneMergeModal
|
||||
show={isMerging}
|
||||
onClose={(mergedId) => {
|
||||
setIsMerging(false);
|
||||
if (mergedId !== undefined && mergedId !== scene.id) {
|
||||
// By default, the merge destination is the current scene, but
|
||||
// the user can change it, in which case we need to redirect.
|
||||
history.replace(`/scenes/${mergedId}`);
|
||||
}
|
||||
}}
|
||||
scenes={[{ id: scene.id, title: objectTitle(scene) }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderDeleteDialog() {
|
||||
if (isDeleteAlertOpen) {
|
||||
return (
|
||||
@@ -419,6 +440,14 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
|
||||
<FormattedMessage id="actions.submit_stash_box" />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item
|
||||
key="merge-scene"
|
||||
className="bg-secondary text-white"
|
||||
onClick={() => setIsMerging(true)}
|
||||
>
|
||||
<FormattedMessage id="actions.merge" />
|
||||
...
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
key="delete-scene"
|
||||
className="bg-secondary text-white"
|
||||
@@ -588,6 +617,7 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
|
||||
<title>{title}</title>
|
||||
</Helmet>
|
||||
{maybeRenderSceneGenerateDialog()}
|
||||
{maybeRenderMergeDialog()}
|
||||
{maybeRenderDeleteDialog()}
|
||||
<div
|
||||
className={`scene-tabs order-xl-first order-last ${
|
||||
|
||||
@@ -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 {
|
||||
@@ -731,21 +748,37 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||
onStashIDSelected(item);
|
||||
setIsStashIDSearchOpen(false);
|
||||
}}
|
||||
initialQuery={scene.title ?? ""}
|
||||
/>
|
||||
)}
|
||||
<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"
|
||||
@@ -827,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,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { faTag } from "@fortawesome/free-solid-svg-icons";
|
||||
import { markerTitle } from "src/core/markers";
|
||||
import { Link } from "react-router-dom";
|
||||
import { objectTitle } from "src/core/files";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton";
|
||||
import { ScenePreview } from "./SceneCard";
|
||||
import { TruncatedText } from "../Shared/TruncatedText";
|
||||
@@ -28,154 +29,166 @@ interface ISceneMarkerCardProps {
|
||||
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
|
||||
}
|
||||
|
||||
const SceneMarkerCardPopovers = (props: ISceneMarkerCardProps) => {
|
||||
function maybeRenderPerformerPopoverButton() {
|
||||
if (props.marker.scene.performers.length <= 0) return;
|
||||
const SceneMarkerCardPopovers = PatchComponent(
|
||||
"SceneMarkerCard.Popovers",
|
||||
(props: ISceneMarkerCardProps) => {
|
||||
function maybeRenderPerformerPopoverButton() {
|
||||
if (props.marker.scene.performers.length <= 0) return;
|
||||
|
||||
return (
|
||||
<PerformerPopoverButton
|
||||
performers={props.marker.scene.performers}
|
||||
linkType="scene_marker"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTagPopoverButton() {
|
||||
const popoverContent = [
|
||||
<TagLink
|
||||
key={props.marker.primary_tag.id}
|
||||
tag={props.marker.primary_tag}
|
||||
linkType="scene_marker"
|
||||
/>,
|
||||
];
|
||||
|
||||
props.marker.tags.map((tag) =>
|
||||
popoverContent.push(
|
||||
<TagLink key={tag.id} tag={tag} linkType="scene_marker" />
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
className="tag-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faTag} />
|
||||
<span>{popoverContent.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPopoverButtonGroup() {
|
||||
if (!props.compact) {
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<ButtonGroup className="card-popovers">
|
||||
{maybeRenderPerformerPopoverButton()}
|
||||
{renderTagPopoverButton()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
<PerformerPopoverButton
|
||||
performers={props.marker.scene.performers}
|
||||
linkType="scene_marker"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTagPopoverButton() {
|
||||
const popoverContent = [
|
||||
<TagLink
|
||||
key={props.marker.primary_tag.id}
|
||||
tag={props.marker.primary_tag}
|
||||
linkType="scene_marker"
|
||||
/>,
|
||||
];
|
||||
|
||||
props.marker.tags.map((tag) =>
|
||||
popoverContent.push(
|
||||
<TagLink key={tag.id} tag={tag} linkType="scene_marker" />
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
className="tag-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faTag} />
|
||||
<span>{popoverContent.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPopoverButtonGroup() {
|
||||
if (!props.compact) {
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<ButtonGroup className="card-popovers">
|
||||
{maybeRenderPerformerPopoverButton()}
|
||||
{renderTagPopoverButton()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <>{renderPopoverButtonGroup()}</>;
|
||||
}
|
||||
);
|
||||
|
||||
return <>{renderPopoverButtonGroup()}</>;
|
||||
};
|
||||
|
||||
const SceneMarkerCardDetails = (props: ISceneMarkerCardProps) => {
|
||||
return (
|
||||
<div className="scene-marker-card__details">
|
||||
<span className="scene-marker-card__time">
|
||||
{TextUtils.formatTimestampRange(
|
||||
props.marker.seconds,
|
||||
props.marker.end_seconds ?? undefined
|
||||
)}
|
||||
</span>
|
||||
<TruncatedText
|
||||
className="scene-marker-card__scene"
|
||||
lineCount={3}
|
||||
text={
|
||||
<Link to={NavUtils.makeSceneMarkersSceneUrl(props.marker.scene)}>
|
||||
{objectTitle(props.marker.scene)}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SceneMarkerCardImage = (props: ISceneMarkerCardProps) => {
|
||||
const { configuration } = useConfigurationContext();
|
||||
|
||||
const file = useMemo(
|
||||
() =>
|
||||
props.marker.scene.files.length > 0
|
||||
? props.marker.scene.files[0]
|
||||
: undefined,
|
||||
[props.marker.scene]
|
||||
);
|
||||
|
||||
function isPortrait() {
|
||||
const width = file?.width ? file.width : 0;
|
||||
const height = file?.height ? file.height : 0;
|
||||
return height > width;
|
||||
}
|
||||
|
||||
function maybeRenderSceneSpecsOverlay() {
|
||||
const SceneMarkerCardDetails = PatchComponent(
|
||||
"SceneMarkerCard.Details",
|
||||
(props: ISceneMarkerCardProps) => {
|
||||
return (
|
||||
<div className="scene-specs-overlay">
|
||||
{props.marker.end_seconds && (
|
||||
<span className="overlay-duration">
|
||||
{TextUtils.secondsToTimestamp(
|
||||
props.marker.end_seconds - props.marker.seconds
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<div className="scene-marker-card__details">
|
||||
<span className="scene-marker-card__time">
|
||||
{TextUtils.formatTimestampRange(
|
||||
props.marker.seconds,
|
||||
props.marker.end_seconds ?? undefined
|
||||
)}
|
||||
</span>
|
||||
<TruncatedText
|
||||
className="scene-marker-card__scene"
|
||||
lineCount={3}
|
||||
text={
|
||||
<Link to={NavUtils.makeSceneMarkersSceneUrl(props.marker.scene)}>
|
||||
{objectTitle(props.marker.scene)}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScenePreview
|
||||
image={props.marker.screenshot ?? undefined}
|
||||
video={props.marker.stream ?? undefined}
|
||||
soundActive={configuration?.interface?.soundOnPreview ?? false}
|
||||
isPortrait={isPortrait()}
|
||||
/>
|
||||
{maybeRenderSceneSpecsOverlay()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const SceneMarkerCardImage = PatchComponent(
|
||||
"SceneMarkerCard.Image",
|
||||
(props: ISceneMarkerCardProps) => {
|
||||
const { configuration } = useConfigurationContext();
|
||||
|
||||
export const SceneMarkerCard = (props: ISceneMarkerCardProps) => {
|
||||
function zoomIndex() {
|
||||
if (!props.compact && props.zoomIndex !== undefined) {
|
||||
return `zoom-${props.zoomIndex}`;
|
||||
const file = useMemo(
|
||||
() =>
|
||||
props.marker.scene.files.length > 0
|
||||
? props.marker.scene.files[0]
|
||||
: undefined,
|
||||
[props.marker.scene]
|
||||
);
|
||||
|
||||
function isPortrait() {
|
||||
const width = file?.width ? file.width : 0;
|
||||
const height = file?.height ? file.height : 0;
|
||||
return height > width;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
function maybeRenderSceneSpecsOverlay() {
|
||||
return (
|
||||
<div className="scene-specs-overlay">
|
||||
{props.marker.end_seconds && (
|
||||
<span className="overlay-duration">
|
||||
{TextUtils.secondsToTimestamp(
|
||||
props.marker.end_seconds - props.marker.seconds
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GridCard
|
||||
className={`scene-marker-card ${zoomIndex()}`}
|
||||
url={NavUtils.makeSceneMarkerUrl(props.marker)}
|
||||
title={markerTitle(props.marker)}
|
||||
width={props.cardWidth}
|
||||
linkClassName="scene-marker-card-link"
|
||||
thumbnailSectionClassName="video-section"
|
||||
resumeTime={props.marker.seconds}
|
||||
image={<SceneMarkerCardImage {...props} />}
|
||||
details={<SceneMarkerCardDetails {...props} />}
|
||||
popovers={<SceneMarkerCardPopovers {...props} />}
|
||||
selected={props.selected}
|
||||
selecting={props.selecting}
|
||||
onSelectedChanged={props.onSelectedChanged}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<ScenePreview
|
||||
image={props.marker.screenshot ?? undefined}
|
||||
video={props.marker.stream ?? undefined}
|
||||
soundActive={configuration?.interface?.soundOnPreview ?? false}
|
||||
isPortrait={isPortrait()}
|
||||
/>
|
||||
{maybeRenderSceneSpecsOverlay()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const SceneMarkerCard = PatchComponent(
|
||||
"SceneMarkerCard",
|
||||
(props: ISceneMarkerCardProps) => {
|
||||
function zoomIndex() {
|
||||
if (!props.compact && props.zoomIndex !== undefined) {
|
||||
return `zoom-${props.zoomIndex}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
<GridCard
|
||||
className={`scene-marker-card ${zoomIndex()}`}
|
||||
url={NavUtils.makeSceneMarkerUrl(props.marker)}
|
||||
title={markerTitle(props.marker)}
|
||||
width={props.cardWidth}
|
||||
linkClassName="scene-marker-card-link"
|
||||
thumbnailSectionClassName="video-section"
|
||||
resumeTime={props.marker.seconds}
|
||||
image={<SceneMarkerCardImage {...props} />}
|
||||
details={<SceneMarkerCardDetails {...props} />}
|
||||
popovers={<SceneMarkerCardPopovers {...props} />}
|
||||
selected={props.selected}
|
||||
selecting={props.selecting}
|
||||
onSelectedChanged={props.onSelectedChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
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}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user