mirror of
https://github.com/stashapp/stash.git
synced 2026-06-10 21:42:03 -05:00
Compare commits
43 Commits
update-tri
...
docs-capti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29d8f0cbc4 | ||
|
|
d8552ec9ca | ||
|
|
704041d5e0 | ||
|
|
8d78fd682d | ||
|
|
81c3988777 | ||
|
|
4b5424dd51 | ||
|
|
e69238307c | ||
|
|
019fe81de9 | ||
|
|
5177f71dbd | ||
|
|
497146adc5 | ||
|
|
f81f60e76f | ||
|
|
849a368d3d | ||
|
|
c09913a614 | ||
|
|
c5fe6748c0 | ||
|
|
fe9a6d87d2 | ||
|
|
7d692232ed | ||
|
|
a145576f39 | ||
|
|
574fd680c9 | ||
|
|
e95c1bbc76 | ||
|
|
155dbc370b | ||
|
|
60f1ee2360 | ||
|
|
3d03072da0 | ||
|
|
ed4d17b8f0 | ||
|
|
a91b9c4d92 | ||
|
|
709fdb14de | ||
|
|
46b0b8cba4 | ||
|
|
815ce7139c | ||
|
|
358193e25e | ||
|
|
4aca81ad9b | ||
|
|
c66ef42480 | ||
|
|
d9a316d083 | ||
|
|
96d2b36a08 | ||
|
|
00f5d0d984 | ||
|
|
044ed2708f | ||
|
|
8e697b50eb | ||
|
|
5ea4c507b2 | ||
|
|
10d4fcce8d | ||
|
|
86848e7d70 | ||
|
|
91ac2833f5 | ||
|
|
8ecbf4f7e4 | ||
|
|
0bd4edd9f4 | ||
|
|
af34829f38 | ||
|
|
155c4ec72a |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -12,7 +12,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
COMPILER_IMAGE: stashapp/compiler:10
|
||||
COMPILER_IMAGE: stashapp/compiler:11
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
2
.github/workflows/golangci-lint.yml
vendored
2
.github/workflows/golangci-lint.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
COMPILER_IMAGE: stashapp/compiler:10
|
||||
COMPILER_IMAGE: stashapp/compiler:11
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
model:
|
||||
package: graphql
|
||||
filename: ./pkg/scraper/stashbox/graphql/generated_models.go
|
||||
filename: ./pkg/stashbox/graphql/generated_models.go
|
||||
client:
|
||||
package: graphql
|
||||
filename: ./pkg/scraper/stashbox/graphql/generated_client.go
|
||||
filename: ./pkg/stashbox/graphql/generated_client.go
|
||||
models:
|
||||
Date:
|
||||
model: github.com/99designs/gqlgen/graphql.String
|
||||
SceneDraftInput:
|
||||
model: github.com/stashapp/stash/pkg/scraper/stashbox/graphql.SceneDraftInput
|
||||
endpoint:
|
||||
# This points to stashdb.org currently, but can be directed at any stash-box
|
||||
# instance. It is used for generation only.
|
||||
|
||||
@@ -28,6 +28,11 @@ For further information you can consult the [documentation](https://docs.stashap
|
||||
As of version 0.27.0, Stash doesn't support anymore _Windows 7, 8, Server 2008 and Server 2012._
|
||||
Windows 10 or Server 2016 are at least required.
|
||||
|
||||
#### Mac Users:
|
||||
|
||||
As of version 0.29.0, Stash requires at least _macOS 11 Big Sur._
|
||||
Stash can still be ran through docker on older versions of macOS
|
||||
|
||||
<img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS | <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker
|
||||
:---:|:---:|:---:|:---:
|
||||
[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe)</sub></sup> | [Latest Release](https://github.com/stashapp/stash/releases/latest/download/Stash.app.zip) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/Stash.app.zip)</sub></sup> | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux) <br /> <sup><sub>[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)</sub></sup> <br /> [More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md) <br /> <sup><sub>[Sample docker-compose.yml](docker/production/docker-compose.yml)</sub></sup>
|
||||
|
||||
@@ -16,7 +16,7 @@ ARG STASH_VERSION
|
||||
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
|
||||
|
||||
# Build Backend
|
||||
FROM golang:1.22.8-alpine AS backend
|
||||
FROM golang:1.24.3-alpine AS backend
|
||||
RUN apk add --no-cache make alpine-sdk
|
||||
WORKDIR /stash
|
||||
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
|
||||
|
||||
@@ -17,7 +17,7 @@ ARG STASH_VERSION
|
||||
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
|
||||
|
||||
# Build Backend
|
||||
FROM golang:1.22.8-bullseye AS backend
|
||||
FROM golang:1.24.3-bullseye AS backend
|
||||
RUN apt update && apt install -y build-essential golang
|
||||
WORKDIR /stash
|
||||
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.22.8
|
||||
FROM golang:1.24.3
|
||||
|
||||
LABEL maintainer="https://discord.gg/2TsNFKt"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
user=stashapp
|
||||
repo=compiler
|
||||
version=10
|
||||
version=11
|
||||
|
||||
latest:
|
||||
docker build -t ${user}/${repo}:latest .
|
||||
|
||||
36
go.mod
36
go.mod
@@ -1,11 +1,11 @@
|
||||
module github.com/stashapp/stash
|
||||
|
||||
go 1.22.8
|
||||
go 1.24.3
|
||||
|
||||
require (
|
||||
github.com/99designs/gqlgen v0.17.55
|
||||
github.com/99designs/gqlgen v0.17.73
|
||||
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552
|
||||
github.com/Yamashou/gqlgenc v0.25.3
|
||||
github.com/Yamashou/gqlgenc v0.32.1
|
||||
github.com/anacrolix/dms v1.2.2
|
||||
github.com/antchfx/htmlquery v1.3.0
|
||||
github.com/asticode/go-astisub v0.25.1
|
||||
@@ -43,40 +43,42 @@ require (
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cast v1.6.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/tidwall/gjson v1.16.0
|
||||
github.com/vearutop/statigz v1.4.0
|
||||
github.com/vektah/dataloaden v0.3.0
|
||||
github.com/vektah/gqlparser/v2 v2.5.18
|
||||
github.com/vektah/gqlparser/v2 v2.5.27
|
||||
github.com/vektra/mockery/v2 v2.10.0
|
||||
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e
|
||||
github.com/zencoder/go-dash/v3 v3.0.2
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/image v0.18.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sys v0.28.0
|
||||
golang.org/x/term v0.27.0
|
||||
golang.org/x/text v0.21.0
|
||||
golang.org/x/net v0.40.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/term v0.32.0
|
||||
golang.org/x/text v0.25.0
|
||||
golang.org/x/time v0.10.0
|
||||
gopkg.in/guregu/null.v4 v4.0.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/agnivade/levenshtein v1.2.0 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/antchfx/xpath v1.2.3 // 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/coder/websocket v1.8.12 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // 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.6.0 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // 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/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
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
@@ -86,7 +88,7 @@ require (
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // 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
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
@@ -109,12 +111,12 @@ require (
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/urfave/cli/v2 v2.27.5 // indirect
|
||||
github.com/urfave/cli/v2 v2.27.6 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/mod v0.21.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/tools v0.26.0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
79
go.sum
79
go.sum
@@ -51,23 +51,23 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/99designs/gqlgen v0.17.55 h1:3vzrNWYyzSZjGDFo68e5j9sSauLxfKvLp+6ioRokVtM=
|
||||
github.com/99designs/gqlgen v0.17.55/go.mod h1:3Bq768f8hgVPGZxL8aY9MaYmbxa6llPM/qu1IGH1EJo=
|
||||
github.com/99designs/gqlgen v0.17.73 h1:A3Ki+rHWqKbAOlg5fxiZBnz6OjW3nwupDHEG15gEsrg=
|
||||
github.com/99designs/gqlgen v0.17.73/go.mod h1:2RyGWjy2k7W9jxrs8MOQthXGkD3L3oGr0jXW3Pu8lGg=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
||||
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/PuerkitoBio/goquery v1.9.3 h1:mpJr/ikUA9/GNJB/DBZcGeFDXUtosHRyRrwh7KGdTG0=
|
||||
github.com/PuerkitoBio/goquery v1.9.3/go.mod h1:1ndLHPdTz+DyQPICCWYlYQMPl0oXZj0G6D4LCYA6u4U=
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
|
||||
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552 h1:eukVk+mGmbSZppLw8WJGpEUgMC570eb32y7FOsPW4Kc=
|
||||
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552/go.mod h1:LKbO1i6L1lSlwWx4NHWVECxubHNKFz2YQoEMGXAFVy8=
|
||||
github.com/Yamashou/gqlgenc v0.25.3 h1:mVV8/Ho8EDZUQKQZbQqqXGNq8jc8aQfPpHhZOnTkMNE=
|
||||
github.com/Yamashou/gqlgenc v0.25.3/go.mod h1:G0g1N81xpIklVdnyboW1zwOHcj/n4hNfhTwfN29Rjig=
|
||||
github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY=
|
||||
github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/Yamashou/gqlgenc v0.32.1 h1:EHs9//xQxXlyltkSFXM+fhO2rTXcWNw6FPKRJ6t+iQQ=
|
||||
github.com/Yamashou/gqlgenc v0.32.1/go.mod h1:o5SxKt9d3+oUZ2i0V3CW8lHFyunfLR+KcKHubS4zf5E=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
@@ -84,8 +84,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
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=
|
||||
@@ -162,8 +162,8 @@ github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9M
|
||||
github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -236,6 +236,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
|
||||
github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE=
|
||||
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
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=
|
||||
@@ -243,6 +245,8 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
|
||||
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/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=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
@@ -302,8 +306,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
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.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
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=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
@@ -468,8 +472,9 @@ github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
|
||||
@@ -638,8 +643,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
@@ -652,14 +657,14 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
|
||||
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU=
|
||||
github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE=
|
||||
github.com/vektah/dataloaden v0.3.0 h1:ZfVN2QD6swgvp+tDqdH/OIT/wu3Dhu0cus0k5gIZS84=
|
||||
github.com/vektah/dataloaden v0.3.0/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
|
||||
github.com/vektah/gqlparser/v2 v2.5.18 h1:zSND3GtutylAQ1JpWnTHcqtaRZjl+y3NROeW8vuNo6Y=
|
||||
github.com/vektah/gqlparser/v2 v2.5.18/go.mod h1:6HLzf7JKv9Fi3APymudztFQNmLXR5qJeEo6BOFcXVfc=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||
github.com/vektra/mockery/v2 v2.10.0 h1:MiiQWxwdq7/ET6dCXLaJzSGEN17k758H7JHS9kOdiks=
|
||||
github.com/vektra/mockery/v2 v2.10.0/go.mod h1:m/WO2UzWzqgVX3nvqpRQq70I4Z7jbSCRhdmkgtp+Ab4=
|
||||
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||
@@ -713,8 +718,8 @@ 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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -756,8 +761,8 @@ 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.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -807,8 +812,8 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -838,8 +843,8 @@ 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.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -930,13 +935,13 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -949,8 +954,8 @@ 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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -1015,8 +1020,8 @@ 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.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -35,6 +35,8 @@ models:
|
||||
model: github.com/stashapp/stash/internal/api.BoolMap
|
||||
PluginConfigMap:
|
||||
model: github.com/stashapp/stash/internal/api.PluginConfigMap
|
||||
File:
|
||||
model: github.com/stashapp/stash/internal/api.File
|
||||
VideoFile:
|
||||
fields:
|
||||
# override float fields - #1572
|
||||
|
||||
@@ -6,6 +6,16 @@ type Query {
|
||||
findDefaultFilter(mode: FilterMode!): SavedFilter
|
||||
@deprecated(reason: "default filter now stored in UI config")
|
||||
|
||||
"Find a file by its id or path"
|
||||
findFile(id: ID, path: String): BaseFile!
|
||||
|
||||
"Queries for Files"
|
||||
findFiles(
|
||||
file_filter: FileFilterType
|
||||
filter: FindFilterType
|
||||
ids: [ID!]
|
||||
): FindFilesResultType!
|
||||
|
||||
"Find a scene by ID or Checksum"
|
||||
findScene(id: ID, checksum: String): Scene
|
||||
findSceneByHash(input: SceneHashInput!): Scene
|
||||
|
||||
@@ -7,8 +7,11 @@ type Folder {
|
||||
id: ID!
|
||||
path: String!
|
||||
|
||||
parent_folder_id: ID
|
||||
zip_file_id: ID
|
||||
parent_folder_id: ID @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder!
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
|
||||
@@ -21,8 +24,32 @@ interface BaseFile {
|
||||
path: String!
|
||||
basename: String!
|
||||
|
||||
parent_folder_id: ID!
|
||||
zip_file_id: ID
|
||||
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder!
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
size: Int64!
|
||||
|
||||
fingerprint(type: String!): String
|
||||
fingerprints: [Fingerprint!]!
|
||||
|
||||
created_at: Time!
|
||||
updated_at: Time!
|
||||
}
|
||||
|
||||
type BasicFile implements BaseFile {
|
||||
id: ID!
|
||||
path: String!
|
||||
basename: String!
|
||||
|
||||
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder!
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
size: Int64!
|
||||
@@ -39,8 +66,11 @@ type VideoFile implements BaseFile {
|
||||
path: String!
|
||||
basename: String!
|
||||
|
||||
parent_folder_id: ID!
|
||||
zip_file_id: ID
|
||||
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder!
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
size: Int64!
|
||||
@@ -66,8 +96,11 @@ type ImageFile implements BaseFile {
|
||||
path: String!
|
||||
basename: String!
|
||||
|
||||
parent_folder_id: ID!
|
||||
zip_file_id: ID
|
||||
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder!
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
size: Int64!
|
||||
@@ -75,6 +108,7 @@ type ImageFile implements BaseFile {
|
||||
fingerprint(type: String!): String
|
||||
fingerprints: [Fingerprint!]!
|
||||
|
||||
format: String!
|
||||
width: Int!
|
||||
height: Int!
|
||||
|
||||
@@ -89,8 +123,11 @@ type GalleryFile implements BaseFile {
|
||||
path: String!
|
||||
basename: String!
|
||||
|
||||
parent_folder_id: ID!
|
||||
zip_file_id: ID
|
||||
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder!
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
size: Int64!
|
||||
@@ -125,3 +162,17 @@ input FileSetFingerprintsInput {
|
||||
"only supplied fingerprint types will be modified"
|
||||
fingerprints: [SetFingerprintsInput!]!
|
||||
}
|
||||
|
||||
type FindFilesResultType {
|
||||
count: Int!
|
||||
|
||||
"Total megapixels of any image files"
|
||||
megapixels: Float!
|
||||
"Total duration in seconds of any video files"
|
||||
duration: Float!
|
||||
|
||||
"Total file size in bytes"
|
||||
size: Int!
|
||||
|
||||
files: [BaseFile!]!
|
||||
}
|
||||
|
||||
@@ -168,6 +168,8 @@ input PerformerFilterType {
|
||||
death_year: IntCriterionInput
|
||||
"Filter by studios where performer appears in scene/image/gallery"
|
||||
studios: HierarchicalMultiCriterionInput
|
||||
"Filter by groups where performer appears in scene"
|
||||
groups: HierarchicalMultiCriterionInput
|
||||
"Filter by performers where performer appears with another performer in scene/image/gallery"
|
||||
performers: MultiCriterionInput
|
||||
"Filter by autotag ignore value"
|
||||
@@ -679,6 +681,77 @@ input ImageFilterType {
|
||||
tags_filter: TagFilterType
|
||||
}
|
||||
|
||||
input FileFilterType {
|
||||
AND: FileFilterType
|
||||
OR: FileFilterType
|
||||
NOT: FileFilterType
|
||||
|
||||
path: StringCriterionInput
|
||||
basename: StringCriterionInput
|
||||
dir: StringCriterionInput
|
||||
|
||||
parent_folder: HierarchicalMultiCriterionInput
|
||||
|
||||
"Filter by modification time"
|
||||
mod_time: TimestampCriterionInput
|
||||
|
||||
"Filter files that have an exact match available"
|
||||
duplicated: PHashDuplicationCriterionInput
|
||||
|
||||
"find files based on hash"
|
||||
hashes: [FingerprintFilterInput!]
|
||||
|
||||
video_file_filter: VideoFileFilterInput
|
||||
image_file_filter: ImageFileFilterInput
|
||||
|
||||
scene_count: IntCriterionInput
|
||||
image_count: IntCriterionInput
|
||||
gallery_count: IntCriterionInput
|
||||
|
||||
"Filter by related scenes that meet this criteria"
|
||||
scenes_filter: SceneFilterType
|
||||
"Filter by related images that meet this criteria"
|
||||
images_filter: ImageFilterType
|
||||
"Filter by related galleries that meet this criteria"
|
||||
galleries_filter: GalleryFilterType
|
||||
|
||||
"Filter by creation time"
|
||||
created_at: TimestampCriterionInput
|
||||
"Filter by last update time"
|
||||
updated_at: TimestampCriterionInput
|
||||
}
|
||||
|
||||
input VideoFileFilterInput {
|
||||
resolution: ResolutionCriterionInput
|
||||
orientation: OrientationCriterionInput
|
||||
framerate: IntCriterionInput
|
||||
bitrate: IntCriterionInput
|
||||
format: StringCriterionInput
|
||||
video_codec: StringCriterionInput
|
||||
audio_codec: StringCriterionInput
|
||||
|
||||
"in seconds"
|
||||
duration: IntCriterionInput
|
||||
|
||||
captions: StringCriterionInput
|
||||
|
||||
interactive: Boolean
|
||||
interactive_speed: IntCriterionInput
|
||||
}
|
||||
|
||||
input ImageFileFilterInput {
|
||||
format: StringCriterionInput
|
||||
resolution: ResolutionCriterionInput
|
||||
orientation: OrientationCriterionInput
|
||||
}
|
||||
|
||||
input FingerprintFilterInput {
|
||||
type: String!
|
||||
value: String!
|
||||
"Hamming distance - defaults to 0"
|
||||
distance: Int
|
||||
}
|
||||
|
||||
enum CriterionModifier {
|
||||
"="
|
||||
EQUALS
|
||||
|
||||
@@ -27,6 +27,7 @@ type Group {
|
||||
front_image_path: String # Resolver
|
||||
back_image_path: String # Resolver
|
||||
scene_count(depth: Int): Int! # Resolver
|
||||
performer_count(depth: Int): Int! # Resolver
|
||||
sub_group_count(depth: Int): Int! # Resolver
|
||||
scenes: [Scene!]!
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
|
||||
const (
|
||||
tripwireActivatedErrMsg = "Stash is exposed to the public internet without authentication, and is not serving any more content to protect your privacy. " +
|
||||
"More information and fixes are available at https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet"
|
||||
"More information and fixes are available at https://discourse.stashapp.cc/t/-/1658"
|
||||
|
||||
externalAccessErrMsg = "You have attempted to access Stash over the internet, and authentication is not enabled. " +
|
||||
"This is extremely dangerous! The whole world can see your your stash page and browse your files! " +
|
||||
"Stash is not answering any other requests to protect your privacy. " +
|
||||
"Please read the log entry or visit https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet"
|
||||
"Please read the log entry or visit https://discourse.stashapp.cc/t/-/1658"
|
||||
)
|
||||
|
||||
func allowUnauthenticated(r *http.Request) bool {
|
||||
|
||||
23
internal/api/fields.go
Normal file
23
internal/api/fields.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
)
|
||||
|
||||
type queryFields []string
|
||||
|
||||
func collectQueryFields(ctx context.Context) queryFields {
|
||||
fields := graphql.CollectAllFields(ctx)
|
||||
return queryFields(fields)
|
||||
}
|
||||
|
||||
func (f queryFields) Has(field string) bool {
|
||||
for _, v := range f {
|
||||
if v == field {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
//go:generate go run github.com/vektah/dataloaden TagLoader int *github.com/stashapp/stash/pkg/models.Tag
|
||||
//go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group
|
||||
//go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File
|
||||
//go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder
|
||||
//go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
|
||||
//go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
|
||||
//go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
|
||||
@@ -62,6 +63,7 @@ type Loaders struct {
|
||||
TagByID *TagLoader
|
||||
GroupByID *GroupLoader
|
||||
FileByID *FileLoader
|
||||
FolderByID *FolderLoader
|
||||
}
|
||||
|
||||
type Middleware struct {
|
||||
@@ -117,6 +119,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
|
||||
maxBatch: maxBatch,
|
||||
fetch: m.fetchFiles(ctx),
|
||||
},
|
||||
FolderByID: &FolderLoader{
|
||||
wait: wait,
|
||||
maxBatch: maxBatch,
|
||||
fetch: m.fetchFolders(ctx),
|
||||
},
|
||||
SceneFiles: &SceneFileIDsLoader{
|
||||
wait: wait,
|
||||
maxBatch: maxBatch,
|
||||
@@ -279,6 +286,17 @@ func (m Middleware) fetchFiles(ctx context.Context) func(keys []models.FileID) (
|
||||
}
|
||||
}
|
||||
|
||||
func (m Middleware) fetchFolders(ctx context.Context) func(keys []models.FolderID) ([]*models.Folder, []error) {
|
||||
return func(keys []models.FolderID) (ret []*models.Folder, errs []error) {
|
||||
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
ret, err = m.Repository.Folder.FindMany(ctx, keys)
|
||||
return err
|
||||
})
|
||||
return ret, toErrorSlice(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) {
|
||||
return func(keys []int) (ret [][]models.FileID, errs []error) {
|
||||
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
|
||||
224
internal/api/loaders/folderloader_gen.go
Normal file
224
internal/api/loaders/folderloader_gen.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
|
||||
|
||||
package loaders
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
// FolderLoaderConfig captures the config to create a new FolderLoader
|
||||
type FolderLoaderConfig struct {
|
||||
// Fetch is a method that provides the data for the loader
|
||||
Fetch func(keys []models.FolderID) ([]*models.Folder, []error)
|
||||
|
||||
// Wait is how long wait before sending a batch
|
||||
Wait time.Duration
|
||||
|
||||
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
|
||||
MaxBatch int
|
||||
}
|
||||
|
||||
// NewFolderLoader creates a new FolderLoader given a fetch, wait, and maxBatch
|
||||
func NewFolderLoader(config FolderLoaderConfig) *FolderLoader {
|
||||
return &FolderLoader{
|
||||
fetch: config.Fetch,
|
||||
wait: config.Wait,
|
||||
maxBatch: config.MaxBatch,
|
||||
}
|
||||
}
|
||||
|
||||
// FolderLoader batches and caches requests
|
||||
type FolderLoader struct {
|
||||
// this method provides the data for the loader
|
||||
fetch func(keys []models.FolderID) ([]*models.Folder, []error)
|
||||
|
||||
// how long to done before sending a batch
|
||||
wait time.Duration
|
||||
|
||||
// this will limit the maximum number of keys to send in one batch, 0 = no limit
|
||||
maxBatch int
|
||||
|
||||
// INTERNAL
|
||||
|
||||
// lazily created cache
|
||||
cache map[models.FolderID]*models.Folder
|
||||
|
||||
// the current batch. keys will continue to be collected until timeout is hit,
|
||||
// then everything will be sent to the fetch method and out to the listeners
|
||||
batch *folderLoaderBatch
|
||||
|
||||
// mutex to prevent races
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type folderLoaderBatch struct {
|
||||
keys []models.FolderID
|
||||
data []*models.Folder
|
||||
error []error
|
||||
closing bool
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Load a Folder by key, batching and caching will be applied automatically
|
||||
func (l *FolderLoader) Load(key models.FolderID) (*models.Folder, error) {
|
||||
return l.LoadThunk(key)()
|
||||
}
|
||||
|
||||
// LoadThunk returns a function that when called will block waiting for a Folder.
|
||||
// This method should be used if you want one goroutine to make requests to many
|
||||
// different data loaders without blocking until the thunk is called.
|
||||
func (l *FolderLoader) LoadThunk(key models.FolderID) func() (*models.Folder, error) {
|
||||
l.mu.Lock()
|
||||
if it, ok := l.cache[key]; ok {
|
||||
l.mu.Unlock()
|
||||
return func() (*models.Folder, error) {
|
||||
return it, nil
|
||||
}
|
||||
}
|
||||
if l.batch == nil {
|
||||
l.batch = &folderLoaderBatch{done: make(chan struct{})}
|
||||
}
|
||||
batch := l.batch
|
||||
pos := batch.keyIndex(l, key)
|
||||
l.mu.Unlock()
|
||||
|
||||
return func() (*models.Folder, error) {
|
||||
<-batch.done
|
||||
|
||||
var data *models.Folder
|
||||
if pos < len(batch.data) {
|
||||
data = batch.data[pos]
|
||||
}
|
||||
|
||||
var err error
|
||||
// its convenient to be able to return a single error for everything
|
||||
if len(batch.error) == 1 {
|
||||
err = batch.error[0]
|
||||
} else if batch.error != nil {
|
||||
err = batch.error[pos]
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
l.mu.Lock()
|
||||
l.unsafeSet(key, data)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAll fetches many keys at once. It will be broken into appropriate sized
|
||||
// sub batches depending on how the loader is configured
|
||||
func (l *FolderLoader) LoadAll(keys []models.FolderID) ([]*models.Folder, []error) {
|
||||
results := make([]func() (*models.Folder, error), len(keys))
|
||||
|
||||
for i, key := range keys {
|
||||
results[i] = l.LoadThunk(key)
|
||||
}
|
||||
|
||||
folders := make([]*models.Folder, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
folders[i], errors[i] = thunk()
|
||||
}
|
||||
return folders, errors
|
||||
}
|
||||
|
||||
// LoadAllThunk returns a function that when called will block waiting for a Folders.
|
||||
// This method should be used if you want one goroutine to make requests to many
|
||||
// different data loaders without blocking until the thunk is called.
|
||||
func (l *FolderLoader) LoadAllThunk(keys []models.FolderID) func() ([]*models.Folder, []error) {
|
||||
results := make([]func() (*models.Folder, error), len(keys))
|
||||
for i, key := range keys {
|
||||
results[i] = l.LoadThunk(key)
|
||||
}
|
||||
return func() ([]*models.Folder, []error) {
|
||||
folders := make([]*models.Folder, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
folders[i], errors[i] = thunk()
|
||||
}
|
||||
return folders, errors
|
||||
}
|
||||
}
|
||||
|
||||
// Prime the cache with the provided key and value. If the key already exists, no change is made
|
||||
// and false is returned.
|
||||
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
|
||||
func (l *FolderLoader) Prime(key models.FolderID, value *models.Folder) bool {
|
||||
l.mu.Lock()
|
||||
var found bool
|
||||
if _, found = l.cache[key]; !found {
|
||||
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var
|
||||
// and end up with the whole cache pointing to the same value.
|
||||
cpy := *value
|
||||
l.unsafeSet(key, &cpy)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
return !found
|
||||
}
|
||||
|
||||
// Clear the value at key from the cache, if it exists
|
||||
func (l *FolderLoader) Clear(key models.FolderID) {
|
||||
l.mu.Lock()
|
||||
delete(l.cache, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *FolderLoader) unsafeSet(key models.FolderID, value *models.Folder) {
|
||||
if l.cache == nil {
|
||||
l.cache = map[models.FolderID]*models.Folder{}
|
||||
}
|
||||
l.cache[key] = value
|
||||
}
|
||||
|
||||
// keyIndex will return the location of the key in the batch, if its not found
|
||||
// it will add the key to the batch
|
||||
func (b *folderLoaderBatch) keyIndex(l *FolderLoader, key models.FolderID) int {
|
||||
for i, existingKey := range b.keys {
|
||||
if key == existingKey {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
pos := len(b.keys)
|
||||
b.keys = append(b.keys, key)
|
||||
if pos == 0 {
|
||||
go b.startTimer(l)
|
||||
}
|
||||
|
||||
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
|
||||
if !b.closing {
|
||||
b.closing = true
|
||||
l.batch = nil
|
||||
go b.end(l)
|
||||
}
|
||||
}
|
||||
|
||||
return pos
|
||||
}
|
||||
|
||||
func (b *folderLoaderBatch) startTimer(l *FolderLoader) {
|
||||
time.Sleep(l.wait)
|
||||
l.mu.Lock()
|
||||
|
||||
// we must have hit a batch limit and are already finalizing this batch
|
||||
if b.closing {
|
||||
l.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
l.batch = nil
|
||||
l.mu.Unlock()
|
||||
|
||||
b.end(l)
|
||||
}
|
||||
|
||||
func (b *folderLoaderBatch) end(l *FolderLoader) {
|
||||
b.data, b.error = l.fetch(b.keys)
|
||||
close(b.done)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil"
|
||||
)
|
||||
|
||||
type BaseFile interface {
|
||||
@@ -27,6 +28,29 @@ func convertVisualFile(f models.File) (VisualFile, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func convertBaseFile(f models.File) BaseFile {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch f := f.(type) {
|
||||
case BaseFile:
|
||||
return f
|
||||
case *models.VideoFile:
|
||||
return &VideoFile{VideoFile: f}
|
||||
case *models.ImageFile:
|
||||
return &ImageFile{ImageFile: f}
|
||||
case *models.BaseFile:
|
||||
return &BasicFile{BaseFile: f}
|
||||
default:
|
||||
panic("unknown file type")
|
||||
}
|
||||
}
|
||||
|
||||
func convertBaseFiles(files []models.File) []BaseFile {
|
||||
return sliceutil.Map(files, convertBaseFile)
|
||||
}
|
||||
|
||||
type GalleryFile struct {
|
||||
*models.BaseFile
|
||||
}
|
||||
@@ -62,3 +86,15 @@ func (ImageFile) IsVisualFile() {}
|
||||
func (f *ImageFile) Fingerprints() []models.Fingerprint {
|
||||
return f.ImageFile.Fingerprints
|
||||
}
|
||||
|
||||
type BasicFile struct {
|
||||
*models.BaseFile
|
||||
}
|
||||
|
||||
func (BasicFile) IsBaseFile() {}
|
||||
|
||||
func (BasicFile) IsVisualFile() {}
|
||||
|
||||
func (f *BasicFile) Fingerprints() []models.Fingerprint {
|
||||
return f.BaseFile.Fingerprints
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@ func (r *Resolver) VideoFile() VideoFileResolver {
|
||||
func (r *Resolver) ImageFile() ImageFileResolver {
|
||||
return &imageFileResolver{r}
|
||||
}
|
||||
func (r *Resolver) BasicFile() BasicFileResolver {
|
||||
return &basicFileResolver{r}
|
||||
}
|
||||
func (r *Resolver) Folder() FolderResolver {
|
||||
return &folderResolver{r}
|
||||
}
|
||||
func (r *Resolver) SavedFilter() SavedFilterResolver {
|
||||
return &savedFilterResolver{r}
|
||||
}
|
||||
@@ -125,6 +131,8 @@ type tagResolver struct{ *Resolver }
|
||||
type galleryFileResolver struct{ *Resolver }
|
||||
type videoFileResolver struct{ *Resolver }
|
||||
type imageFileResolver struct{ *Resolver }
|
||||
type basicFileResolver struct{ *Resolver }
|
||||
type folderResolver struct{ *Resolver }
|
||||
type savedFilterResolver struct{ *Resolver }
|
||||
type pluginResolver struct{ *Resolver }
|
||||
type configResultResolver struct{ *Resolver }
|
||||
|
||||
@@ -1,30 +1,80 @@
|
||||
package api
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
|
||||
func (r *galleryFileResolver) Fingerprint(ctx context.Context, obj *GalleryFile, type_ string) (*string, error) {
|
||||
fp := obj.BaseFile.Fingerprints.For(type_)
|
||||
if fp != nil {
|
||||
v := fp.Value()
|
||||
return &v, nil
|
||||
"github.com/stashapp/stash/internal/api/loaders"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func fingerprintResolver(fp models.Fingerprints, type_ string) (*string, error) {
|
||||
fingerprint := fp.For(type_)
|
||||
if fingerprint != nil {
|
||||
value := fingerprint.Value()
|
||||
return &value, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *galleryFileResolver) Fingerprint(ctx context.Context, obj *GalleryFile, type_ string) (*string, error) {
|
||||
return fingerprintResolver(obj.BaseFile.Fingerprints, type_)
|
||||
}
|
||||
|
||||
func (r *imageFileResolver) Fingerprint(ctx context.Context, obj *ImageFile, type_ string) (*string, error) {
|
||||
fp := obj.ImageFile.Fingerprints.For(type_)
|
||||
if fp != nil {
|
||||
v := fp.Value()
|
||||
return &v, nil
|
||||
}
|
||||
return nil, nil
|
||||
return fingerprintResolver(obj.ImageFile.Fingerprints, type_)
|
||||
}
|
||||
|
||||
func (r *videoFileResolver) Fingerprint(ctx context.Context, obj *VideoFile, type_ string) (*string, error) {
|
||||
fp := obj.VideoFile.Fingerprints.For(type_)
|
||||
if fp != nil {
|
||||
v := fp.Value()
|
||||
return &v, nil
|
||||
}
|
||||
return nil, nil
|
||||
return fingerprintResolver(obj.VideoFile.Fingerprints, type_)
|
||||
}
|
||||
|
||||
func (r *basicFileResolver) Fingerprint(ctx context.Context, obj *BasicFile, type_ string) (*string, error) {
|
||||
return fingerprintResolver(obj.BaseFile.Fingerprints, type_)
|
||||
}
|
||||
|
||||
func (r *galleryFileResolver) ParentFolder(ctx context.Context, obj *GalleryFile) (*models.Folder, error) {
|
||||
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
|
||||
}
|
||||
|
||||
func (r *imageFileResolver) ParentFolder(ctx context.Context, obj *ImageFile) (*models.Folder, error) {
|
||||
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
|
||||
}
|
||||
|
||||
func (r *videoFileResolver) ParentFolder(ctx context.Context, obj *VideoFile) (*models.Folder, error) {
|
||||
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
|
||||
}
|
||||
|
||||
func (r *basicFileResolver) ParentFolder(ctx context.Context, obj *BasicFile) (*models.Folder, error) {
|
||||
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
|
||||
}
|
||||
|
||||
func zipFileResolver(ctx context.Context, zipFileID *models.FileID) (*BasicFile, error) {
|
||||
if zipFileID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
f, err := loaders.From(ctx).FileByID.Load(*zipFileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &BasicFile{
|
||||
BaseFile: f.Base(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *galleryFileResolver) ZipFile(ctx context.Context, obj *GalleryFile) (*BasicFile, error) {
|
||||
return zipFileResolver(ctx, obj.ZipFileID)
|
||||
}
|
||||
|
||||
func (r *imageFileResolver) ZipFile(ctx context.Context, obj *ImageFile) (*BasicFile, error) {
|
||||
return zipFileResolver(ctx, obj.ZipFileID)
|
||||
}
|
||||
|
||||
func (r *videoFileResolver) ZipFile(ctx context.Context, obj *VideoFile) (*BasicFile, error) {
|
||||
return zipFileResolver(ctx, obj.ZipFileID)
|
||||
}
|
||||
|
||||
func (r *basicFileResolver) ZipFile(ctx context.Context, obj *BasicFile) (*BasicFile, error) {
|
||||
return zipFileResolver(ctx, obj.ZipFileID)
|
||||
}
|
||||
|
||||
20
internal/api/resolver_model_folder.go
Normal file
20
internal/api/resolver_model_folder.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/stashapp/stash/internal/api/loaders"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (*models.Folder, error) {
|
||||
if obj.ParentFolderID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID)
|
||||
}
|
||||
|
||||
func (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) {
|
||||
return zipFileResolver(ctx, obj.ZipFileID)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/stashapp/stash/internal/api/urlbuilders"
|
||||
"github.com/stashapp/stash/pkg/group"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/performer"
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
)
|
||||
|
||||
@@ -181,6 +182,17 @@ func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group, depth
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *groupResolver) PerformerCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = performer.CountByGroupID(ctx, r.repository.Performer, obj.ID, depth)
|
||||
return err
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *groupResolver) Scenes(ctx context.Context, obj *models.Group) (ret []*models.Scene, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
|
||||
@@ -249,18 +249,19 @@ func (r *queryResolver) ValidateStashBoxCredentials(ctx context.Context, input c
|
||||
if valid {
|
||||
status = fmt.Sprintf("Successfully authenticated as %s", user.Me.Name)
|
||||
} else {
|
||||
errorStr := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(strings.ToLower(err.Error()), "doctype"):
|
||||
case strings.Contains(errorStr, "doctype"):
|
||||
// Index file returned rather than graphql
|
||||
status = "Invalid endpoint"
|
||||
case strings.Contains(err.Error(), "request failed"):
|
||||
case strings.Contains(errorStr, "request failed"):
|
||||
status = "No response from server"
|
||||
case strings.HasPrefix(err.Error(), "invalid character") ||
|
||||
strings.HasPrefix(err.Error(), "illegal base64 data") ||
|
||||
err.Error() == "unexpected end of JSON input" ||
|
||||
err.Error() == "token contains an invalid number of segments":
|
||||
case strings.Contains(errorStr, "invalid character") ||
|
||||
strings.Contains(errorStr, "illegal base64 data") ||
|
||||
strings.Contains(errorStr, "unexpected end of json input") ||
|
||||
strings.Contains(errorStr, "token contains an invalid number of segments"):
|
||||
status = "Malformed API key."
|
||||
case err.Error() == "" || err.Error() == "signature is invalid":
|
||||
case strings.Contains(errorStr, "signature is invalid"):
|
||||
status = "Invalid or expired API key."
|
||||
default:
|
||||
status = fmt.Sprintf("Unknown error: %s", err)
|
||||
|
||||
120
internal/api/resolver_query_find_file.go
Normal file
120
internal/api/resolver_query_find_file.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindFile(ctx context.Context, id *string, path *string) (BaseFile, error) {
|
||||
var ret models.File
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.File
|
||||
var err error
|
||||
switch {
|
||||
case id != nil:
|
||||
idInt, err := strconv.Atoi(*id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var files []models.File
|
||||
files, err = qb.Find(ctx, models.FileID(idInt))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) > 0 {
|
||||
ret = files[0]
|
||||
}
|
||||
case path != nil:
|
||||
ret, err = qb.FindByPath(ctx, *path)
|
||||
if err == nil && ret == nil {
|
||||
return errors.New("file not found")
|
||||
}
|
||||
default:
|
||||
return errors.New("either id or path must be provided")
|
||||
}
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return convertBaseFile(ret), nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindFiles(
|
||||
ctx context.Context,
|
||||
fileFilter *models.FileFilterType,
|
||||
filter *models.FindFilterType,
|
||||
ids []string,
|
||||
) (ret *FindFilesResultType, err error) {
|
||||
var fileIDs []models.FileID
|
||||
if len(ids) > 0 {
|
||||
fileIDsInt, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileIDs = models.FileIDsFromInts(fileIDsInt)
|
||||
}
|
||||
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
var files []models.File
|
||||
var err error
|
||||
|
||||
fields := collectQueryFields(ctx)
|
||||
result := &models.FileQueryResult{}
|
||||
|
||||
if len(fileIDs) > 0 {
|
||||
files, err = r.repository.File.Find(ctx, fileIDs...)
|
||||
if err == nil {
|
||||
result.Count = len(files)
|
||||
for _, f := range files {
|
||||
if asVideo, ok := f.(*models.VideoFile); ok {
|
||||
result.TotalDuration += asVideo.Duration
|
||||
}
|
||||
if asImage, ok := f.(*models.ImageFile); ok {
|
||||
result.Megapixels += asImage.Megapixels()
|
||||
}
|
||||
|
||||
result.TotalSize += f.Base().Size
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result, err = r.repository.File.Query(ctx, models.FileQueryOptions{
|
||||
QueryOptions: models.QueryOptions{
|
||||
FindFilter: filter,
|
||||
Count: fields.Has("count"),
|
||||
},
|
||||
FileFilter: fileFilter,
|
||||
TotalDuration: fields.Has("duration"),
|
||||
Megapixels: fields.Has("megapixels"),
|
||||
TotalSize: fields.Has("size"),
|
||||
})
|
||||
if err == nil {
|
||||
files, err = result.Resolve(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ret = &FindFilesResultType{
|
||||
Count: result.Count,
|
||||
Files: convertBaseFiles(files),
|
||||
Duration: result.TotalDuration,
|
||||
Megapixels: result.Megapixels,
|
||||
Size: int(result.TotalSize),
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
@@ -20,6 +20,7 @@ func (rs pluginRoutes) Routes() chi.Router {
|
||||
|
||||
r.Route("/{pluginId}", func(r chi.Router) {
|
||||
r.Use(rs.PluginCtx)
|
||||
r.Get("/assets", rs.Assets)
|
||||
r.Get("/assets/*", rs.Assets)
|
||||
r.Get("/javascript", rs.Javascript)
|
||||
r.Get("/css", rs.CSS)
|
||||
|
||||
@@ -207,7 +207,7 @@ func Initialize() (*Server, error) {
|
||||
r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
||||
setPageSecurityHeaders(w, r, pluginCache.ListPlugins())
|
||||
endpoint := getProxyPrefix(r) + gqlEndpoint
|
||||
gqlPlayground.Handler("GraphQL playground", endpoint)(w, r)
|
||||
gqlPlayground.Handler("GraphQL playground", endpoint, gqlPlayground.WithGraphiqlEnablePluginExplorer(true))(w, r)
|
||||
})
|
||||
|
||||
r.Mount("/performer", server.getPerformerRoutes())
|
||||
|
||||
@@ -1534,7 +1534,7 @@ func (i *Config) GetDefaultGenerateSettings() *models.GenerateMetadataOptions {
|
||||
}
|
||||
|
||||
// GetDangerousAllowPublicWithoutAuth determines if the security feature is enabled.
|
||||
// See https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet
|
||||
// See https://discourse.stashapp.cc/t/-/1658
|
||||
func (i *Config) GetDangerousAllowPublicWithoutAuth() bool {
|
||||
return i.getBool(dangerousAllowPublicWithoutAuth)
|
||||
}
|
||||
|
||||
@@ -9,15 +9,34 @@ import (
|
||||
type FileQueryOptions struct {
|
||||
QueryOptions
|
||||
FileFilter *FileFilterType
|
||||
|
||||
TotalDuration bool
|
||||
Megapixels bool
|
||||
TotalSize bool
|
||||
}
|
||||
|
||||
type FileFilterType struct {
|
||||
And *FileFilterType `json:"AND"`
|
||||
Or *FileFilterType `json:"OR"`
|
||||
Not *FileFilterType `json:"NOT"`
|
||||
OperatorFilter[FileFilterType]
|
||||
|
||||
// Filter by path
|
||||
Path *StringCriterionInput `json:"path"`
|
||||
|
||||
Basename *StringCriterionInput `json:"basename"`
|
||||
Dir *StringCriterionInput `json:"dir"`
|
||||
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder"`
|
||||
ModTime *TimestampCriterionInput `json:"mod_time"`
|
||||
Duplicated *PHashDuplicationCriterionInput `json:"duplicated"`
|
||||
Hashes []*FingerprintFilterInput `json:"hashes"`
|
||||
VideoFileFilter *VideoFileFilterInput `json:"video_file_filter"`
|
||||
ImageFileFilter *ImageFileFilterInput `json:"image_file_filter"`
|
||||
SceneCount *IntCriterionInput `json:"scene_count"`
|
||||
ImageCount *IntCriterionInput `json:"image_count"`
|
||||
GalleryCount *IntCriterionInput `json:"gallery_count"`
|
||||
ScenesFilter *SceneFilterType `json:"scenes_filter"`
|
||||
ImagesFilter *ImageFilterType `json:"images_filter"`
|
||||
GalleriesFilter *GalleryFilterType `json:"galleries_filter"`
|
||||
CreatedAt *TimestampCriterionInput `json:"created_at"`
|
||||
UpdatedAt *TimestampCriterionInput `json:"updated_at"`
|
||||
}
|
||||
|
||||
func PathsFileFilter(paths []string) *FileFilterType {
|
||||
@@ -53,10 +72,10 @@ func PathsFileFilter(paths []string) *FileFilterType {
|
||||
}
|
||||
|
||||
type FileQueryResult struct {
|
||||
// can't use QueryResult because id type is wrong
|
||||
|
||||
IDs []FileID
|
||||
Count int
|
||||
QueryResult[FileID]
|
||||
TotalDuration float64
|
||||
Megapixels float64
|
||||
TotalSize int64
|
||||
|
||||
getter FileGetter
|
||||
files []File
|
||||
|
||||
@@ -200,3 +200,31 @@ type CustomFieldCriterionInput struct {
|
||||
Value []any `json:"value"`
|
||||
Modifier CriterionModifier `json:"modifier"`
|
||||
}
|
||||
|
||||
type FingerprintFilterInput struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
// Hamming distance - defaults to 0
|
||||
Distance *int `json:"distance,omitempty"`
|
||||
}
|
||||
|
||||
type VideoFileFilterInput struct {
|
||||
Format *StringCriterionInput `json:"format,omitempty"`
|
||||
Resolution *ResolutionCriterionInput `json:"resolution,omitempty"`
|
||||
Orientation *OrientationCriterionInput `json:"orientation,omitempty"`
|
||||
Framerate *IntCriterionInput `json:"framerate,omitempty"`
|
||||
Bitrate *IntCriterionInput `json:"bitrate,omitempty"`
|
||||
VideoCodec *StringCriterionInput `json:"video_codec,omitempty"`
|
||||
AudioCodec *StringCriterionInput `json:"audio_codec,omitempty"`
|
||||
// in seconds
|
||||
Duration *IntCriterionInput `json:"duration,omitempty"`
|
||||
Captions *StringCriterionInput `json:"captions,omitempty"`
|
||||
Interactive *bool `json:"interactive,omitempty"`
|
||||
InteractiveSpeed *IntCriterionInput `json:"interactive_speed,omitempty"`
|
||||
}
|
||||
|
||||
type ImageFileFilterInput struct {
|
||||
Format *StringCriterionInput `json:"format,omitempty"`
|
||||
Resolution *ResolutionCriterionInput `json:"resolution,omitempty"`
|
||||
Orientation *OrientationCriterionInput `json:"orientation,omitempty"`
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ type ImageQueryOptions struct {
|
||||
}
|
||||
|
||||
type ImageQueryResult struct {
|
||||
QueryResult
|
||||
QueryResult[int]
|
||||
Megapixels float64
|
||||
TotalSize float64
|
||||
|
||||
|
||||
@@ -178,6 +178,29 @@ func (_m *FolderReaderWriter) FindByZipFileID(ctx context.Context, zipFileID mod
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindMany provides a mock function with given fields: ctx, id
|
||||
func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID) ([]*models.Folder, error) {
|
||||
ret := _m.Called(ctx, id)
|
||||
|
||||
var r0 []*models.Folder
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) []*models.Folder); ok {
|
||||
r0 = rf(ctx, id)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Folder)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok {
|
||||
r1 = rf(ctx, id)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Update provides a mock function with given fields: ctx, f
|
||||
func (_m *FolderReaderWriter) Update(ctx context.Context, f *models.Folder) error {
|
||||
ret := _m.Called(ctx, f)
|
||||
|
||||
@@ -79,6 +79,14 @@ func (i FileID) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(i.String()))
|
||||
}
|
||||
|
||||
func FileIDsFromInts(ids []int) []FileID {
|
||||
ret := make([]FileID, len(ids))
|
||||
for i, id := range ids {
|
||||
ret[i] = FileID(id)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// DirEntry represents a file or directory in the file system.
|
||||
type DirEntry struct {
|
||||
ZipFileID *FileID `json:"zip_file_id"`
|
||||
@@ -252,6 +260,10 @@ func (f ImageFile) GetHeight() int {
|
||||
return f.Height
|
||||
}
|
||||
|
||||
func (f ImageFile) Megapixels() float64 {
|
||||
return float64(f.Width*f.Height) / 1e6
|
||||
}
|
||||
|
||||
func (f ImageFile) GetFormat() string {
|
||||
return f.Format
|
||||
}
|
||||
|
||||
@@ -178,6 +178,8 @@ type PerformerFilterType struct {
|
||||
DeathYear *IntCriterionInput `json:"death_year"`
|
||||
// Filter by studios where performer appears in scene/image/gallery
|
||||
Studios *HierarchicalMultiCriterionInput `json:"studios"`
|
||||
// Filter by groups where performer appears in scene
|
||||
Groups *HierarchicalMultiCriterionInput `json:"groups"`
|
||||
// Filter by performers where performer appears with another performer in scene/image/gallery
|
||||
Performers *MultiCriterionInput `json:"performers"`
|
||||
// Filter by autotag ignore value
|
||||
|
||||
@@ -5,7 +5,7 @@ type QueryOptions struct {
|
||||
Count bool
|
||||
}
|
||||
|
||||
type QueryResult struct {
|
||||
IDs []int
|
||||
type QueryResult[T comparable] struct {
|
||||
IDs []T
|
||||
Count int
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import "context"
|
||||
// FolderGetter provides methods to get folders by ID.
|
||||
type FolderGetter interface {
|
||||
Find(ctx context.Context, id FolderID) (*Folder, error)
|
||||
FindMany(ctx context.Context, id []FolderID) ([]*Folder, error)
|
||||
}
|
||||
|
||||
// FolderFinder provides methods to find folders.
|
||||
|
||||
@@ -126,7 +126,7 @@ type SceneQueryOptions struct {
|
||||
}
|
||||
|
||||
type SceneQueryResult struct {
|
||||
QueryResult
|
||||
QueryResult[int]
|
||||
TotalDuration float64
|
||||
TotalSize float64
|
||||
|
||||
|
||||
@@ -19,6 +19,18 @@ func CountByStudioID(ctx context.Context, r models.PerformerQueryer, id int, dep
|
||||
return r.QueryCount(ctx, filter, nil)
|
||||
}
|
||||
|
||||
func CountByGroupID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) {
|
||||
filter := &models.PerformerFilterType{
|
||||
Groups: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{strconv.Itoa(id)},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Depth: depth,
|
||||
},
|
||||
}
|
||||
|
||||
return r.QueryCount(ctx, filter, nil)
|
||||
}
|
||||
|
||||
func CountByTagID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) {
|
||||
filter := &models.PerformerFilterType{
|
||||
Tags: &models.HierarchicalMultiCriterionInput{
|
||||
|
||||
@@ -108,7 +108,11 @@ xPathScrapers:
|
||||
Image:
|
||||
selector: //div[contains(@class,'image-container')]//a/img/@src
|
||||
Gender:
|
||||
fixed: "Female"
|
||||
selector: //h1/*[1]/*[1]/text()Add commentMore actions
|
||||
postProcess:
|
||||
- replace:
|
||||
- regex: .+ identifies as (.+)
|
||||
with: $1
|
||||
DeathDate:
|
||||
selector: //div[contains(text(),'Passed away on')]
|
||||
postProcess:
|
||||
@@ -124,7 +128,7 @@ xPathScrapers:
|
||||
- regex: \skg
|
||||
with: ""
|
||||
|
||||
# Last Updated January 2, 2024
|
||||
# Last Updated June 22, 2025
|
||||
`
|
||||
|
||||
func getFreeonesScraper(globalConfig GlobalConfig) scraper {
|
||||
|
||||
@@ -81,6 +81,6 @@ func LogExternalAccessError(err ExternalAccessError) {
|
||||
"You probably forwarded a port from your router. At the very least, add a password to stash in the settings. \n"+
|
||||
"Stash will not serve requests until you edit config.yml, remove the security_tripwire_accessed_from_public_internet key and restart stash. \n"+
|
||||
"This behaviour can be overridden (but not recommended) by setting dangerous_allow_public_without_auth to true in config.yml. \n"+
|
||||
"More information is available at https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet \n"+
|
||||
"More information is available at https://discourse.stashapp.cc/t/-/1658 \n"+
|
||||
"Stash is not answering any other requests to protect your privacy.", net.IP(err).String())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package sqlite
|
||||
const defaultBatchSize = 1000
|
||||
|
||||
// batchExec executes the provided function in batches of the provided size.
|
||||
func batchExec(ids []int, batchSize int, fn func(batch []int) error) error {
|
||||
func batchExec[T any](ids []T, batchSize int, fn func(batch []T) error) error {
|
||||
for i := 0; i < len(ids); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(ids) {
|
||||
|
||||
@@ -70,6 +70,17 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite
|
||||
}
|
||||
}
|
||||
|
||||
func joinedStringCriterionHandler(c *models.StringCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if c != nil {
|
||||
if addJoinFn != nil {
|
||||
addJoinFn(f)
|
||||
}
|
||||
stringCriterionHandler(c, column)(ctx, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if modifier.IsValid() {
|
||||
|
||||
@@ -275,6 +275,43 @@ func (r fileQueryRows) resolve() []models.File {
|
||||
return ret
|
||||
}
|
||||
|
||||
type fileRepositoryType struct {
|
||||
repository
|
||||
scenes joinRepository
|
||||
images joinRepository
|
||||
galleries joinRepository
|
||||
}
|
||||
|
||||
var (
|
||||
fileRepository = fileRepositoryType{
|
||||
repository: repository{
|
||||
tableName: sceneTable,
|
||||
idColumn: idColumn,
|
||||
},
|
||||
scenes: joinRepository{
|
||||
repository: repository{
|
||||
tableName: scenesFilesTable,
|
||||
idColumn: fileIDColumn,
|
||||
},
|
||||
fkColumn: sceneIDColumn,
|
||||
},
|
||||
images: joinRepository{
|
||||
repository: repository{
|
||||
tableName: imagesFilesTable,
|
||||
idColumn: fileIDColumn,
|
||||
},
|
||||
fkColumn: imageIDColumn,
|
||||
},
|
||||
galleries: joinRepository{
|
||||
repository: repository{
|
||||
tableName: galleriesFilesTable,
|
||||
idColumn: fileIDColumn,
|
||||
},
|
||||
fkColumn: galleryIDColumn,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type FileStore struct {
|
||||
repository
|
||||
|
||||
@@ -830,9 +867,11 @@ func (qb *FileStore) makeFilter(ctx context.Context, fileFilter *models.FileFilt
|
||||
query.not(qb.makeFilter(ctx, fileFilter.Not))
|
||||
}
|
||||
|
||||
query.handleCriterion(ctx, pathCriterionHandler(fileFilter.Path, "folders.path", "files.basename", nil))
|
||||
filter := filterBuilderFromHandler(ctx, &fileFilterHandler{
|
||||
fileFilter: fileFilter,
|
||||
})
|
||||
|
||||
return query
|
||||
return filter
|
||||
}
|
||||
|
||||
func (qb *FileStore) Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error) {
|
||||
@@ -890,7 +929,7 @@ func (qb *FileStore) Query(ctx context.Context, options models.FileQueryOptions)
|
||||
}
|
||||
|
||||
func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.FileQueryOptions, query queryBuilder) (*models.FileQueryResult, error) {
|
||||
if !options.Count {
|
||||
if !options.Count && !options.TotalDuration && !options.Megapixels && !options.TotalSize {
|
||||
// nothing to do - return empty result
|
||||
return models.NewFileQueryResult(qb), nil
|
||||
}
|
||||
@@ -898,14 +937,43 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File
|
||||
aggregateQuery := qb.newQuery()
|
||||
|
||||
if options.Count {
|
||||
aggregateQuery.addColumn("COUNT(temp.id) as total")
|
||||
aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total")
|
||||
}
|
||||
|
||||
if options.TotalDuration {
|
||||
query.addJoins(
|
||||
join{
|
||||
table: videoFileTable,
|
||||
onClause: "files.id = video_files.file_id",
|
||||
},
|
||||
)
|
||||
query.addColumn("COALESCE(video_files.duration, 0) as duration")
|
||||
aggregateQuery.addColumn("COALESCE(SUM(temp.duration), 0) as duration")
|
||||
}
|
||||
if options.Megapixels {
|
||||
query.addJoins(
|
||||
join{
|
||||
table: imageFileTable,
|
||||
onClause: "files.id = image_files.file_id",
|
||||
},
|
||||
)
|
||||
query.addColumn("COALESCE(image_files.width, 0) * COALESCE(image_files.height, 0) as megapixels")
|
||||
aggregateQuery.addColumn("COALESCE(SUM(temp.megapixels), 0) / 1000000 as megapixels")
|
||||
}
|
||||
|
||||
if options.TotalSize {
|
||||
query.addColumn("COALESCE(files.size, 0) as size")
|
||||
aggregateQuery.addColumn("COALESCE(SUM(temp.size), 0) as size")
|
||||
}
|
||||
|
||||
const includeSortPagination = false
|
||||
aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination))
|
||||
|
||||
out := struct {
|
||||
Total int
|
||||
Total int
|
||||
Duration float64
|
||||
Megapixels float64
|
||||
Size int64
|
||||
}{}
|
||||
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
|
||||
return nil, err
|
||||
@@ -913,6 +981,9 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File
|
||||
|
||||
ret := models.NewFileQueryResult(qb)
|
||||
ret.Count = out.Total
|
||||
ret.Megapixels = out.Megapixels
|
||||
ret.TotalDuration = out.Duration
|
||||
ret.TotalSize = out.Size
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
302
pkg/sqlite/file_filter.go
Normal file
302
pkg/sqlite/file_filter.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
type fileFilterHandler struct {
|
||||
fileFilter *models.FileFilterType
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) validate() error {
|
||||
fileFilter := qb.fileFilter
|
||||
if fileFilter == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := validateFilterCombination(fileFilter.OperatorFilter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if subFilter := fileFilter.SubFilter(); subFilter != nil {
|
||||
sqb := &fileFilterHandler{fileFilter: subFilter}
|
||||
if err := sqb.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
fileFilter := qb.fileFilter
|
||||
if fileFilter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := qb.validate(); err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
|
||||
sf := fileFilter.SubFilter()
|
||||
if sf != nil {
|
||||
sub := &fileFilterHandler{sf}
|
||||
handleSubFilter(ctx, sub, f, fileFilter.OperatorFilter)
|
||||
}
|
||||
|
||||
f.handleCriterion(ctx, qb.criterionHandler())
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) criterionHandler() criterionHandler {
|
||||
fileFilter := qb.fileFilter
|
||||
return compoundHandler{
|
||||
&videoFileFilterHandler{
|
||||
filter: fileFilter.VideoFileFilter,
|
||||
},
|
||||
&imageFileFilterHandler{
|
||||
filter: fileFilter.ImageFileFilter,
|
||||
},
|
||||
|
||||
pathCriterionHandler(fileFilter.Path, "folders.path", "files.basename", nil),
|
||||
stringCriterionHandler(fileFilter.Basename, "files.basename"),
|
||||
stringCriterionHandler(fileFilter.Dir, "folders.path"),
|
||||
×tampCriterionHandler{fileFilter.ModTime, "files.mod_time", nil},
|
||||
|
||||
qb.parentFolderCriterionHandler(fileFilter.ParentFolder),
|
||||
|
||||
qb.sceneCountCriterionHandler(fileFilter.SceneCount),
|
||||
qb.imageCountCriterionHandler(fileFilter.ImageCount),
|
||||
qb.galleryCountCriterionHandler(fileFilter.GalleryCount),
|
||||
|
||||
qb.hashesCriterionHandler(fileFilter.Hashes),
|
||||
|
||||
qb.phashDuplicatedCriterionHandler(fileFilter.Duplicated),
|
||||
×tampCriterionHandler{fileFilter.CreatedAt, "files.created_at", nil},
|
||||
×tampCriterionHandler{fileFilter.UpdatedAt, "files.updated_at", nil},
|
||||
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "scenes_files.scene_id",
|
||||
relatedRepo: sceneRepository.repository,
|
||||
relatedHandler: &sceneFilterHandler{fileFilter.ScenesFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
fileRepository.scenes.innerJoin(f, "", "files.id")
|
||||
},
|
||||
},
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "images_files.image_id",
|
||||
relatedRepo: imageRepository.repository,
|
||||
relatedHandler: &imageFilterHandler{fileFilter.ImagesFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
fileRepository.images.innerJoin(f, "", "files.id")
|
||||
},
|
||||
},
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "galleries_files.gallery_id",
|
||||
relatedRepo: galleryRepository.repository,
|
||||
relatedHandler: &galleryFilterHandler{fileFilter.GalleriesFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
fileRepository.galleries.innerJoin(f, "", "files.id")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if folder == nil {
|
||||
return
|
||||
}
|
||||
|
||||
folderCopy := *folder
|
||||
switch folderCopy.Modifier {
|
||||
case models.CriterionModifierEquals:
|
||||
folderCopy.Modifier = models.CriterionModifierIncludesAll
|
||||
case models.CriterionModifierNotEquals:
|
||||
folderCopy.Modifier = models.CriterionModifierExcludes
|
||||
}
|
||||
|
||||
hh := hierarchicalMultiCriterionHandlerBuilder{
|
||||
primaryTable: fileTable,
|
||||
foreignTable: folderTable,
|
||||
foreignFK: "parent_folder_id",
|
||||
parentFK: "parent_folder_id",
|
||||
}
|
||||
|
||||
hh.handler(&folderCopy)(ctx, f)
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) sceneCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: fileTable,
|
||||
joinTable: scenesFilesTable,
|
||||
primaryFK: fileIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(c)
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) imageCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: fileTable,
|
||||
joinTable: imagesFilesTable,
|
||||
primaryFK: fileIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(c)
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) galleryCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: fileTable,
|
||||
joinTable: galleriesFilesTable,
|
||||
primaryFK: fileIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(c)
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
// TODO: Wishlist item: Implement Distance matching
|
||||
if duplicatedFilter != nil {
|
||||
var v string
|
||||
if *duplicatedFilter.Duplicated {
|
||||
v = ">"
|
||||
} else {
|
||||
v = "="
|
||||
}
|
||||
|
||||
f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "files.id = scph.file_id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) hashesCriterionHandler(hashes []*models.FingerprintFilterInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
// TODO - this won't work for AND/OR combinations
|
||||
for i, hash := range hashes {
|
||||
t := fmt.Sprintf("file_fingerprints_%d", i)
|
||||
f.addLeftJoin(fingerprintTable, t, fmt.Sprintf("files.id = %s.file_id AND %s.type = ?", t, t), hash.Type)
|
||||
|
||||
value, _ := utils.StringToPhash(hash.Value)
|
||||
distance := 0
|
||||
if hash.Distance != nil {
|
||||
distance = *hash.Distance
|
||||
}
|
||||
|
||||
if distance > 0 {
|
||||
// needed to avoid a type mismatch
|
||||
f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t))
|
||||
f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance)
|
||||
} else {
|
||||
// use the default handler
|
||||
intCriterionHandler(&models.IntCriterionInput{
|
||||
Value: int(value),
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}, t+".fingerprint", nil)(ctx, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type videoFileFilterHandler struct {
|
||||
filter *models.VideoFileFilterInput
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
videoFileFilter := qb.filter
|
||||
if videoFileFilter == nil {
|
||||
return
|
||||
}
|
||||
f.handleCriterion(ctx, qb.criterionHandler())
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) criterionHandler() criterionHandler {
|
||||
videoFileFilter := qb.filter
|
||||
return compoundHandler{
|
||||
joinedStringCriterionHandler(videoFileFilter.Format, "video_files.format", qb.addVideoFilesTable),
|
||||
floatIntCriterionHandler(videoFileFilter.Duration, "video_files.duration", qb.addVideoFilesTable),
|
||||
resolutionCriterionHandler(videoFileFilter.Resolution, "video_files.height", "video_files.width", qb.addVideoFilesTable),
|
||||
orientationCriterionHandler(videoFileFilter.Orientation, "video_files.height", "video_files.width", qb.addVideoFilesTable),
|
||||
floatIntCriterionHandler(videoFileFilter.Framerate, "ROUND(video_files.frame_rate)", qb.addVideoFilesTable),
|
||||
intCriterionHandler(videoFileFilter.Bitrate, "video_files.bit_rate", qb.addVideoFilesTable),
|
||||
qb.codecCriterionHandler(videoFileFilter.VideoCodec, "video_files.video_codec", qb.addVideoFilesTable),
|
||||
qb.codecCriterionHandler(videoFileFilter.AudioCodec, "video_files.audio_codec", qb.addVideoFilesTable),
|
||||
|
||||
boolCriterionHandler(videoFileFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable),
|
||||
intCriterionHandler(videoFileFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable),
|
||||
|
||||
qb.captionCriterionHandler(videoFileFilter.Captions),
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) addVideoFilesTable(f *filterBuilder) {
|
||||
f.addLeftJoin(videoFileTable, "", "video_files.file_id = files.id")
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if codec != nil {
|
||||
if addJoinFn != nil {
|
||||
addJoinFn(f)
|
||||
}
|
||||
|
||||
stringCriterionHandler(codec, codecColumn)(ctx, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc {
|
||||
h := stringListCriterionHandlerBuilder{
|
||||
primaryTable: sceneTable,
|
||||
primaryFK: sceneIDColumn,
|
||||
joinTable: videoCaptionsTable,
|
||||
stringColumn: captionCodeColumn,
|
||||
addJoinTable: func(f *filterBuilder) {
|
||||
f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = files.id")
|
||||
},
|
||||
excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) {
|
||||
excludeClause := `files.id NOT IN (
|
||||
SELECT files.id from files
|
||||
INNER JOIN video_captions on video_captions.file_id = files.id
|
||||
WHERE video_captions.language_code LIKE ?
|
||||
)`
|
||||
f.addWhere(excludeClause, criterion.Value)
|
||||
|
||||
// TODO - should we also exclude null values?
|
||||
},
|
||||
}
|
||||
|
||||
return h.handler(captions)
|
||||
}
|
||||
|
||||
type imageFileFilterHandler struct {
|
||||
filter *models.ImageFileFilterInput
|
||||
}
|
||||
|
||||
func (qb *imageFileFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
ff := qb.filter
|
||||
if ff == nil {
|
||||
return
|
||||
}
|
||||
f.handleCriterion(ctx, qb.criterionHandler())
|
||||
}
|
||||
|
||||
func (qb *imageFileFilterHandler) criterionHandler() criterionHandler {
|
||||
ff := qb.filter
|
||||
return compoundHandler{
|
||||
joinedStringCriterionHandler(ff.Format, "image_files.format", qb.addImageFilesTable),
|
||||
resolutionCriterionHandler(ff.Resolution, "image_files.height", "image_files.width", qb.addImageFilesTable),
|
||||
orientationCriterionHandler(ff.Orientation, "image_files.height", "image_files.width", qb.addImageFilesTable),
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *imageFileFilterHandler) addImageFilesTable(f *filterBuilder) {
|
||||
f.addLeftJoin(imageFileTable, "", "image_files.file_id = files.id")
|
||||
}
|
||||
101
pkg/sqlite/file_filter_test.go
Normal file
101
pkg/sqlite/file_filter_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFileQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
findFilter *models.FindFilterType
|
||||
filter *models.FileFilterType
|
||||
includeIdxs []int
|
||||
includeIDs []int
|
||||
excludeIdxs []int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "path",
|
||||
filter: &models.FileFilterType{
|
||||
Path: &models.StringCriterionInput{
|
||||
Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "basename"),
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{fileIdxStartVideoFiles},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "basename",
|
||||
filter: &models.FileFilterType{
|
||||
Basename: &models.StringCriterionInput{
|
||||
Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "basename"),
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{fileIdxStartVideoFiles},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "dir",
|
||||
filter: &models.FileFilterType{
|
||||
Path: &models.StringCriterionInput{
|
||||
Value: folderPaths[folderIdxWithSceneFiles],
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIDs: []int{int(sceneFileIDs[sceneIdxWithGroup])},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "parent folder",
|
||||
filter: &models.FileFilterType{
|
||||
ParentFolder: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(int(folderIDs[folderIdxWithSceneFiles])),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIDs: []int{int(sceneFileIDs[sceneIdxWithGroup])},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
// TODO - add more tests for other file filters
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
results, err := db.File.Query(ctx, models.FileQueryOptions{
|
||||
FileFilter: tt.filter,
|
||||
QueryOptions: models.QueryOptions{
|
||||
FindFilter: tt.findFilter,
|
||||
},
|
||||
})
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
include := indexesToIDs(sceneIDs, tt.includeIdxs)
|
||||
include = append(include, tt.includeIDs...)
|
||||
exclude := indexesToIDs(sceneIDs, tt.excludeIdxs)
|
||||
|
||||
for _, i := range include {
|
||||
assert.Contains(results.IDs, models.FileID(i))
|
||||
}
|
||||
for _, e := range exclude {
|
||||
assert.NotContains(results.IDs, models.FileID(e))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
@@ -225,6 +226,52 @@ func (qb *FolderStore) Find(ctx context.Context, id models.FolderID) (*models.Fo
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// FindByIDs finds multiple folders by their IDs.
|
||||
// No check is made to see if the folders exist, and the order of the returned folders
|
||||
// is not guaranteed to be the same as the order of the input IDs.
|
||||
func (qb *FolderStore) FindByIDs(ctx context.Context, ids []models.FolderID) ([]*models.Folder, error) {
|
||||
folders := make([]*models.Folder, 0, len(ids))
|
||||
|
||||
table := qb.table()
|
||||
if err := batchExec(ids, defaultBatchSize, func(batch []models.FolderID) error {
|
||||
q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch))
|
||||
unsorted, err := qb.getMany(ctx, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
folders = append(folders, unsorted...)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) FindMany(ctx context.Context, ids []models.FolderID) ([]*models.Folder, error) {
|
||||
folders := make([]*models.Folder, len(ids))
|
||||
|
||||
unsorted, err := qb.FindByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, s := range unsorted {
|
||||
i := slices.Index(ids, s.ID)
|
||||
folders[i] = s
|
||||
}
|
||||
|
||||
for i := range folders {
|
||||
if folders[i] == nil {
|
||||
return nil, fmt.Errorf("folder with id %d not found", ids[i])
|
||||
}
|
||||
}
|
||||
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) FindByPath(ctx context.Context, p string) (*models.Folder, error) {
|
||||
q := qb.selectDataset().Prepared(true).Where(qb.table().Col("path").Eq(p))
|
||||
|
||||
|
||||
@@ -155,6 +155,8 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler {
|
||||
|
||||
qb.studiosCriterionHandler(filter.Studios),
|
||||
|
||||
qb.groupsCriterionHandler(filter.Groups),
|
||||
|
||||
qb.appearsWithCriterionHandler(filter.Performers),
|
||||
|
||||
qb.tagCountCriterionHandler(filter.TagCount),
|
||||
@@ -487,6 +489,119 @@ func (qb *performerFilterHandler) studiosCriterionHandler(studios *models.Hierar
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *performerFilterHandler) groupsCriterionHandler(groups *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if groups != nil {
|
||||
if groups.Modifier == models.CriterionModifierIsNull || groups.Modifier == models.CriterionModifierNotNull {
|
||||
var notClause string
|
||||
if groups.Modifier == models.CriterionModifierNotNull {
|
||||
notClause = "NOT"
|
||||
}
|
||||
|
||||
f.addLeftJoin(performersScenesTable, "", "performers_scenes.performer_id = performers.id")
|
||||
f.addLeftJoin(groupsScenesTable, "", "performers_scenes.scene_id = groups_scenes.scene_id")
|
||||
|
||||
f.addWhere(fmt.Sprintf("%s groups_scenes.group_id IS NULL", notClause))
|
||||
return
|
||||
}
|
||||
|
||||
if len(groups.Value) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var clauseCondition string
|
||||
|
||||
switch groups.Modifier {
|
||||
case models.CriterionModifierIncludes:
|
||||
// return performers who appear in scenes with any of the given groups
|
||||
clauseCondition = "NOT"
|
||||
case models.CriterionModifierExcludes:
|
||||
// exclude performers who appear in scenes with any of the given groups
|
||||
clauseCondition = ""
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
const derivedPerformerGroupTable = "performer_group"
|
||||
|
||||
// Simplified approach: direct group-scene-performer relationship without hierarchy
|
||||
var args []interface{}
|
||||
for _, val := range groups.Value {
|
||||
args = append(args, val)
|
||||
}
|
||||
|
||||
// If depth is specified and not 0, we need hierarchy, otherwise use simple approach
|
||||
depthVal := 0
|
||||
if groups.Depth != nil {
|
||||
depthVal = *groups.Depth
|
||||
}
|
||||
|
||||
if depthVal == 0 {
|
||||
// Simple case: no hierarchy, direct group relationship
|
||||
f.addWith(fmt.Sprintf("group_values(id) AS (VALUES %s)", strings.Repeat("(?),", len(groups.Value)-1)+"(?)"), args...)
|
||||
|
||||
templStr := `SELECT performer_id FROM {joinTable}
|
||||
INNER JOIN {primaryTable} ON {joinTable}.scene_id = {primaryTable}.scene_id
|
||||
INNER JOIN group_values ON {primaryTable}.{groupFK} = group_values.id`
|
||||
|
||||
formatMaps := []utils.StrFormatMap{
|
||||
{
|
||||
"primaryTable": groupsScenesTable,
|
||||
"joinTable": performersScenesTable,
|
||||
"primaryFK": sceneIDColumn,
|
||||
"groupFK": groupIDColumn,
|
||||
},
|
||||
}
|
||||
|
||||
var unions []string
|
||||
for _, c := range formatMaps {
|
||||
unions = append(unions, utils.StrFormat(templStr, c))
|
||||
}
|
||||
|
||||
f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerGroupTable, strings.Join(unions, " UNION ")))
|
||||
} else {
|
||||
// Complex case: with hierarchy
|
||||
var depthCondition string
|
||||
if depthVal != -1 {
|
||||
depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal)
|
||||
}
|
||||
|
||||
// Build recursive CTE for group hierarchy
|
||||
hierarchyQuery := fmt.Sprintf(`group_hierarchy AS (
|
||||
SELECT sub_id AS root_id, sub_id AS item_id, 0 AS depth FROM groups_relations WHERE sub_id IN%s
|
||||
UNION
|
||||
SELECT root_id, sub_id, depth + 1 FROM groups_relations INNER JOIN group_hierarchy ON item_id = containing_id %s
|
||||
)`, getInBinding(len(groups.Value)), depthCondition)
|
||||
|
||||
f.addRecursiveWith(hierarchyQuery, args...)
|
||||
|
||||
templStr := `SELECT performer_id FROM {joinTable}
|
||||
INNER JOIN {primaryTable} ON {joinTable}.scene_id = {primaryTable}.scene_id
|
||||
INNER JOIN group_hierarchy ON {primaryTable}.{groupFK} = group_hierarchy.item_id`
|
||||
|
||||
formatMaps := []utils.StrFormatMap{
|
||||
{
|
||||
"primaryTable": groupsScenesTable,
|
||||
"joinTable": performersScenesTable,
|
||||
"primaryFK": sceneIDColumn,
|
||||
"groupFK": groupIDColumn,
|
||||
},
|
||||
}
|
||||
|
||||
var unions []string
|
||||
for _, c := range formatMaps {
|
||||
unions = append(unions, utils.StrFormat(templStr, c))
|
||||
}
|
||||
|
||||
f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerGroupTable, strings.Join(unions, " UNION ")))
|
||||
}
|
||||
|
||||
f.addLeftJoin(derivedPerformerGroupTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerGroupTable))
|
||||
f.addWhere(fmt.Sprintf("%s.performer_id IS %s NULL", derivedPerformerGroupTable, clauseCondition))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *performerFilterHandler) appearsWithCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if performers != nil {
|
||||
|
||||
@@ -74,7 +74,9 @@ var (
|
||||
table: imagesTagsJoinTable,
|
||||
idColumn: imagesTagsJoinTable.Col(imageIDColumn),
|
||||
},
|
||||
fkColumn: imagesTagsJoinTable.Col(tagIDColumn),
|
||||
fkColumn: imagesTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
}
|
||||
|
||||
imagesPerformersTableMgr = &joinTable{
|
||||
@@ -112,7 +114,9 @@ var (
|
||||
table: galleriesTagsJoinTable,
|
||||
idColumn: galleriesTagsJoinTable.Col(galleryIDColumn),
|
||||
},
|
||||
fkColumn: galleriesTagsJoinTable.Col(tagIDColumn),
|
||||
fkColumn: galleriesTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
}
|
||||
|
||||
galleriesPerformersTableMgr = &joinTable{
|
||||
@@ -168,7 +172,9 @@ var (
|
||||
table: scenesTagsJoinTable,
|
||||
idColumn: scenesTagsJoinTable.Col(sceneIDColumn),
|
||||
},
|
||||
fkColumn: scenesTagsJoinTable.Col(tagIDColumn),
|
||||
fkColumn: scenesTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
}
|
||||
|
||||
scenesPerformersTableMgr = &joinTable{
|
||||
@@ -274,7 +280,9 @@ var (
|
||||
table: performersTagsJoinTable,
|
||||
idColumn: performersTagsJoinTable.Col(performerIDColumn),
|
||||
},
|
||||
fkColumn: performersTagsJoinTable.Col(tagIDColumn),
|
||||
fkColumn: performersTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
}
|
||||
|
||||
performersStashIDsTableMgr = &stashIDTable{
|
||||
@@ -304,7 +312,9 @@ var (
|
||||
table: studiosTagsJoinTable,
|
||||
idColumn: studiosTagsJoinTable.Col(studioIDColumn),
|
||||
},
|
||||
fkColumn: studiosTagsJoinTable.Col(tagIDColumn),
|
||||
fkColumn: studiosTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
}
|
||||
|
||||
studiosStashIDsTableMgr = &stashIDTable{
|
||||
@@ -336,7 +346,7 @@ var (
|
||||
},
|
||||
fkColumn: tagRelationsJoinTable.Col(tagParentIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: tagTableMgr.table.Col("name").Asc(),
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
}
|
||||
|
||||
tagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert()
|
||||
@@ -363,7 +373,7 @@ var (
|
||||
},
|
||||
fkColumn: groupsTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: tagTableMgr.table.Col("name").Asc(),
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
}
|
||||
|
||||
groupRelationshipTableMgr = &table{
|
||||
|
||||
@@ -607,7 +607,7 @@ func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType,
|
||||
|
||||
if q := findFilter.Q; q != nil && *q != "" {
|
||||
query.join(tagAliasesTable, "", "tag_aliases.tag_id = tags.id")
|
||||
searchColumns := []string{"tags.name", "tag_aliases.alias"}
|
||||
searchColumns := []string{"tags.name", "tag_aliases.alias", "tags.sort_name"}
|
||||
query.parseQueryString(searchColumns, *q)
|
||||
}
|
||||
|
||||
|
||||
@@ -197,13 +197,13 @@ func TestConcurrentExclusiveAndReadTxn(t *testing.T) {
|
||||
_ = txn.WithReadTxn(ctx, db, func(ctx context.Context) error {
|
||||
// wait for first thread
|
||||
if err := waitForOtherThread(c); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := signalOtherThread(c); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@ const DefaultMaxRequestsPerMinute = 240
|
||||
|
||||
// Client represents the client interface to a stash-box server instance.
|
||||
type Client struct {
|
||||
client *graphql.Client
|
||||
box models.StashBox
|
||||
client *graphql.Client
|
||||
httpClient *http.Client
|
||||
box models.StashBox
|
||||
|
||||
maxRequestsPerMinute int
|
||||
|
||||
@@ -70,6 +71,7 @@ func NewClient(box models.StashBox, options ...ClientOption) *Client {
|
||||
ret := &Client{
|
||||
box: box,
|
||||
maxRequestsPerMinute: DefaultMaxRequestsPerMinute,
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
if box.MaxRequestsPerMinute > 0 {
|
||||
@@ -84,7 +86,7 @@ func NewClient(box models.StashBox, options ...ClientOption) *Client {
|
||||
limitRequests := rateLimit(ret.maxRequestsPerMinute)
|
||||
|
||||
client := &graphql.Client{
|
||||
Client: clientv2.NewClient(http.DefaultClient, box.Endpoint, nil, authHeader, limitRequests),
|
||||
Client: clientv2.NewClient(ret.httpClient, box.Endpoint, nil, authHeader, limitRequests),
|
||||
}
|
||||
|
||||
ret.client = client
|
||||
@@ -92,10 +94,6 @@ func NewClient(box models.StashBox, options ...ClientOption) *Client {
|
||||
return ret
|
||||
}
|
||||
|
||||
func (c Client) getHTTPClient() *http.Client {
|
||||
return c.client.Client.Client
|
||||
}
|
||||
|
||||
func (c Client) GetUser(ctx context.Context) (*graphql.Me, error) {
|
||||
return c.client.Me(ctx)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ package graphql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/Yamashou/gqlgenc/clientv2"
|
||||
)
|
||||
@@ -28,7 +27,7 @@ type Client struct {
|
||||
Client *clientv2.Client
|
||||
}
|
||||
|
||||
func NewClient(cli *http.Client, baseURL string, options *clientv2.Options, interceptors ...clientv2.RequestInterceptor) StashBoxGraphQLClient {
|
||||
func NewClient(cli clientv2.HttpClient, baseURL string, options *clientv2.Options, interceptors ...clientv2.RequestInterceptor) StashBoxGraphQLClient {
|
||||
return &Client{Client: clientv2.NewClient(cli, baseURL, options, interceptors...)}
|
||||
}
|
||||
|
||||
@@ -493,148 +492,148 @@ func (t *SceneFragment) GetFingerprints() []*FingerprintFragment {
|
||||
}
|
||||
|
||||
type StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
func (t *StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
type SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
}
|
||||
|
||||
func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
func (t *StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &SceneFragment_Studio_StudioFragment_Parent{}
|
||||
t = &StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *SceneFragment_Studio_StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
type FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
}
|
||||
|
||||
func (t *FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
t = &SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
type FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
}
|
||||
|
||||
func (t *FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
func (t *FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
t = &FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
}
|
||||
|
||||
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
func (t *FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
t = &FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
type SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
}
|
||||
|
||||
func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
type FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
}
|
||||
|
||||
func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
t = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
type FindStudio_FindStudio_StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
}
|
||||
|
||||
func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string {
|
||||
func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindStudio_FindStudio_StudioFragment_Parent{}
|
||||
t = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindStudio_FindStudio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindStudio_FindStudio_StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindStudio_FindStudio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindStudio_FindStudio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type Me_Me struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
@@ -870,6 +871,23 @@ type SceneDraft struct {
|
||||
|
||||
func (SceneDraft) IsDraftData() {}
|
||||
|
||||
type SceneDraftInput struct {
|
||||
ID *string `json:"id,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Details *string `json:"details,omitempty"`
|
||||
Director *string `json:"director,omitempty"`
|
||||
URL *string `json:"url,omitempty"`
|
||||
Urls []string `json:"urls,omitempty"`
|
||||
Date *string `json:"date,omitempty"`
|
||||
ProductionDate *string `json:"production_date,omitempty"`
|
||||
Studio *DraftEntityInput `json:"studio,omitempty"`
|
||||
Performers []*DraftEntityInput `json:"performers"`
|
||||
Tags []*DraftEntityInput `json:"tags,omitempty"`
|
||||
Image *graphql.Upload `json:"image,omitempty"`
|
||||
Fingerprints []*FingerprintInput `json:"fingerprints"`
|
||||
}
|
||||
|
||||
type SceneEdit struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Details *string `json:"details,omitempty"`
|
||||
@@ -1361,7 +1379,7 @@ func (e BreastTypeEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *BreastTypeEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *BreastTypeEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1378,6 +1396,20 @@ func (e BreastTypeEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *BreastTypeEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e BreastTypeEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type CriterionModifier string
|
||||
|
||||
const (
|
||||
@@ -1423,7 +1455,7 @@ func (e CriterionModifier) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *CriterionModifier) UnmarshalGQL(v interface{}) error {
|
||||
func (e *CriterionModifier) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1440,6 +1472,20 @@ func (e CriterionModifier) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *CriterionModifier) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e CriterionModifier) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type DateAccuracyEnum string
|
||||
|
||||
const (
|
||||
@@ -1466,7 +1512,7 @@ func (e DateAccuracyEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *DateAccuracyEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *DateAccuracyEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1483,6 +1529,20 @@ func (e DateAccuracyEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *DateAccuracyEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e DateAccuracyEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type EditSortEnum string
|
||||
|
||||
const (
|
||||
@@ -1509,7 +1569,7 @@ func (e EditSortEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *EditSortEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *EditSortEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1526,6 +1586,20 @@ func (e EditSortEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *EditSortEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e EditSortEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type EthnicityEnum string
|
||||
|
||||
const (
|
||||
@@ -1562,7 +1636,7 @@ func (e EthnicityEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *EthnicityEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *EthnicityEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1579,6 +1653,20 @@ func (e EthnicityEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *EthnicityEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e EthnicityEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type EthnicityFilterEnum string
|
||||
|
||||
const (
|
||||
@@ -1617,7 +1705,7 @@ func (e EthnicityFilterEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *EthnicityFilterEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *EthnicityFilterEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1634,6 +1722,20 @@ func (e EthnicityFilterEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *EthnicityFilterEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e EthnicityFilterEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type EyeColorEnum string
|
||||
|
||||
const (
|
||||
@@ -1666,7 +1768,7 @@ func (e EyeColorEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *EyeColorEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *EyeColorEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1683,6 +1785,20 @@ func (e EyeColorEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *EyeColorEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e EyeColorEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type FavoriteFilter string
|
||||
|
||||
const (
|
||||
@@ -1709,7 +1825,7 @@ func (e FavoriteFilter) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *FavoriteFilter) UnmarshalGQL(v interface{}) error {
|
||||
func (e *FavoriteFilter) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1726,6 +1842,20 @@ func (e FavoriteFilter) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *FavoriteFilter) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e FavoriteFilter) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type FingerprintAlgorithm string
|
||||
|
||||
const (
|
||||
@@ -1752,7 +1882,7 @@ func (e FingerprintAlgorithm) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *FingerprintAlgorithm) UnmarshalGQL(v interface{}) error {
|
||||
func (e *FingerprintAlgorithm) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1769,6 +1899,20 @@ func (e FingerprintAlgorithm) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *FingerprintAlgorithm) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e FingerprintAlgorithm) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type FingerprintSubmissionType string
|
||||
|
||||
const (
|
||||
@@ -1798,7 +1942,7 @@ func (e FingerprintSubmissionType) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *FingerprintSubmissionType) UnmarshalGQL(v interface{}) error {
|
||||
func (e *FingerprintSubmissionType) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1815,6 +1959,20 @@ func (e FingerprintSubmissionType) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *FingerprintSubmissionType) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e FingerprintSubmissionType) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type GenderEnum string
|
||||
|
||||
const (
|
||||
@@ -1847,7 +2005,7 @@ func (e GenderEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *GenderEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *GenderEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1864,6 +2022,20 @@ func (e GenderEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *GenderEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e GenderEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type GenderFilterEnum string
|
||||
|
||||
const (
|
||||
@@ -1898,7 +2070,7 @@ func (e GenderFilterEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *GenderFilterEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *GenderFilterEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1915,6 +2087,20 @@ func (e GenderFilterEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *GenderFilterEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e GenderFilterEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type HairColorEnum string
|
||||
|
||||
const (
|
||||
@@ -1955,7 +2141,7 @@ func (e HairColorEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *HairColorEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *HairColorEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1972,6 +2158,20 @@ func (e HairColorEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *HairColorEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e HairColorEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type NotificationEnum string
|
||||
|
||||
const (
|
||||
@@ -2014,7 +2214,7 @@ func (e NotificationEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *NotificationEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *NotificationEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2031,6 +2231,20 @@ func (e NotificationEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *NotificationEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e NotificationEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type OperationEnum string
|
||||
|
||||
const (
|
||||
@@ -2059,7 +2273,7 @@ func (e OperationEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *OperationEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *OperationEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2076,6 +2290,20 @@ func (e OperationEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *OperationEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e OperationEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type PerformerSortEnum string
|
||||
|
||||
const (
|
||||
@@ -2114,7 +2342,7 @@ func (e PerformerSortEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *PerformerSortEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *PerformerSortEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2131,6 +2359,20 @@ func (e PerformerSortEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *PerformerSortEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e PerformerSortEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type RoleEnum string
|
||||
|
||||
const (
|
||||
@@ -2173,7 +2415,7 @@ func (e RoleEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *RoleEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *RoleEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2190,6 +2432,20 @@ func (e RoleEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *RoleEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e RoleEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type SceneSortEnum string
|
||||
|
||||
const (
|
||||
@@ -2220,7 +2476,7 @@ func (e SceneSortEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *SceneSortEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *SceneSortEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2237,6 +2493,20 @@ func (e SceneSortEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *SceneSortEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e SceneSortEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type SortDirectionEnum string
|
||||
|
||||
const (
|
||||
@@ -2261,7 +2531,7 @@ func (e SortDirectionEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *SortDirectionEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *SortDirectionEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2278,6 +2548,20 @@ func (e SortDirectionEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *SortDirectionEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e SortDirectionEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type StudioSortEnum string
|
||||
|
||||
const (
|
||||
@@ -2304,7 +2588,7 @@ func (e StudioSortEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *StudioSortEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *StudioSortEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2321,6 +2605,20 @@ func (e StudioSortEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *StudioSortEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e StudioSortEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type TagGroupEnum string
|
||||
|
||||
const (
|
||||
@@ -2347,7 +2645,7 @@ func (e TagGroupEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *TagGroupEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *TagGroupEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2364,6 +2662,20 @@ func (e TagGroupEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *TagGroupEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e TagGroupEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type TagSortEnum string
|
||||
|
||||
const (
|
||||
@@ -2390,7 +2702,7 @@ func (e TagSortEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *TagSortEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *TagSortEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2407,6 +2719,20 @@ func (e TagSortEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *TagSortEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e TagSortEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type TargetTypeEnum string
|
||||
|
||||
const (
|
||||
@@ -2435,7 +2761,7 @@ func (e TargetTypeEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *TargetTypeEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *TargetTypeEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2452,6 +2778,20 @@ func (e TargetTypeEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *TargetTypeEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e TargetTypeEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type UserChangeEmailStatus string
|
||||
|
||||
const (
|
||||
@@ -2484,7 +2824,7 @@ func (e UserChangeEmailStatus) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *UserChangeEmailStatus) UnmarshalGQL(v interface{}) error {
|
||||
func (e *UserChangeEmailStatus) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2501,6 +2841,20 @@ func (e UserChangeEmailStatus) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *UserChangeEmailStatus) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e UserChangeEmailStatus) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type UserVotedFilterEnum string
|
||||
|
||||
const (
|
||||
@@ -2529,7 +2883,7 @@ func (e UserVotedFilterEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *UserVotedFilterEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *UserVotedFilterEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2546,6 +2900,20 @@ func (e UserVotedFilterEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *UserVotedFilterEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e UserVotedFilterEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type ValidSiteTypeEnum string
|
||||
|
||||
const (
|
||||
@@ -2572,7 +2940,7 @@ func (e ValidSiteTypeEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *ValidSiteTypeEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *ValidSiteTypeEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2589,6 +2957,20 @@ func (e ValidSiteTypeEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *ValidSiteTypeEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e ValidSiteTypeEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type VoteStatusEnum string
|
||||
|
||||
const (
|
||||
@@ -2623,7 +3005,7 @@ func (e VoteStatusEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *VoteStatusEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *VoteStatusEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2640,6 +3022,20 @@ func (e VoteStatusEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *VoteStatusEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e VoteStatusEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type VoteTypeEnum string
|
||||
|
||||
const (
|
||||
@@ -2672,7 +3068,7 @@ func (e VoteTypeEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *VoteTypeEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *VoteTypeEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2688,3 +3084,17 @@ func (e *VoteTypeEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e VoteTypeEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *VoteTypeEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e VoteTypeEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package graphql
|
||||
|
||||
import "github.com/99designs/gqlgen/graphql"
|
||||
|
||||
// Override for generated struct due to mistaken omitempty
|
||||
// https://github.com/Yamashou/gqlgenc/issues/77
|
||||
type SceneDraftInput struct {
|
||||
ID *string `json:"id,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Details *string `json:"details,omitempty"`
|
||||
Director *string `json:"director,omitempty"`
|
||||
URL *string `json:"url,omitempty"`
|
||||
Date *string `json:"date,omitempty"`
|
||||
Studio *DraftEntityInput `json:"studio,omitempty"`
|
||||
Performers []*DraftEntityInput `json:"performers"`
|
||||
Tags []*DraftEntityInput `json:"tags,omitempty"`
|
||||
Image *graphql.Upload `json:"image,omitempty"`
|
||||
Fingerprints []*FingerprintInput `json:"fingerprints"`
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
@@ -183,7 +182,7 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen
|
||||
if len(s.Images) > 0 {
|
||||
// TODO - #454 code sorts images by aspect ratio according to a wanted
|
||||
// orientation. I'm just grabbing the first for now
|
||||
ss.Image = getFirstImage(ctx, c.getHTTPClient(), s.Images)
|
||||
ss.Image = getFirstImage(ctx, c.httpClient, s.Images)
|
||||
}
|
||||
|
||||
ss.URLs = make([]string, len(s.Urls))
|
||||
@@ -288,11 +287,8 @@ func newSceneDraftInput(d SceneDraft, endpoint string) graphql.SceneDraftInput {
|
||||
if scene.Director != "" {
|
||||
draft.Director = &scene.Director
|
||||
}
|
||||
// TODO - draft does not accept multiple URLs. Use single URL for now.
|
||||
if len(scene.URLs.List()) > 0 {
|
||||
url := strings.TrimSpace(scene.URLs.List()[0])
|
||||
draft.URL = &url
|
||||
}
|
||||
draft.Urls = scene.URLs.List()
|
||||
|
||||
if scene.Date != nil {
|
||||
v := scene.Date.String()
|
||||
draft.Date = &v
|
||||
|
||||
@@ -28,6 +28,8 @@ fragment GroupData on Group {
|
||||
back_image_path
|
||||
scene_count
|
||||
scene_count_all: scene_count(depth: -1)
|
||||
performer_count
|
||||
performer_count_all: performer_count(depth: -1)
|
||||
sub_group_count
|
||||
sub_group_count_all: sub_group_count(depth: -1)
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"terser": "^5.9.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "~4.8.4",
|
||||
"vite": "^4.5.11",
|
||||
"vite": "^4.5.14",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-tsconfig-paths": "^4.0.5"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useLocation,
|
||||
useRouteMatch,
|
||||
} from "react-router-dom";
|
||||
import { IntlProvider, CustomFormats } from "react-intl";
|
||||
import { IntlProvider, CustomFormats, FormattedMessage } from "react-intl";
|
||||
import { Helmet } from "react-helmet";
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import mergeWith from "lodash-es/mergeWith";
|
||||
@@ -49,6 +49,7 @@ import { ConnectionMonitor } from "./ConnectionMonitor";
|
||||
import { PatchFunction } from "./patch";
|
||||
|
||||
import moment from "moment/min/moment-with-locales";
|
||||
import { ErrorMessage } from "./components/Shared/ErrorMessage";
|
||||
|
||||
const Performers = lazyComponent(
|
||||
() => import("./components/Performers/Performers")
|
||||
@@ -102,6 +103,14 @@ const AppContainer: React.FC<React.PropsWithChildren<{}>> = PatchFunction(
|
||||
}
|
||||
) as React.FC;
|
||||
|
||||
const MainContainer: React.FC = ({ children }) => {
|
||||
return (
|
||||
<div className={`main container-fluid ${appleRendering ? "apple" : ""}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function translateLanguageLocale(l: string) {
|
||||
// intl doesn't support all locales, so we need to map some to supported ones
|
||||
switch (l) {
|
||||
@@ -287,14 +296,40 @@ export const App: React.FC = () => {
|
||||
|
||||
const titleProps = makeTitleProps();
|
||||
|
||||
if (!messages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.error) {
|
||||
return (
|
||||
<IntlProvider
|
||||
locale={intlLanguage}
|
||||
messages={messages}
|
||||
formats={intlFormats}
|
||||
>
|
||||
<MainContainer>
|
||||
<ErrorMessage
|
||||
message={
|
||||
<FormattedMessage
|
||||
id="errors.loading_type"
|
||||
values={{ type: "configuration" }}
|
||||
/>
|
||||
}
|
||||
error={config.error.message}
|
||||
/>
|
||||
</MainContainer>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{messages ? (
|
||||
<IntlProvider
|
||||
locale={intlLanguage}
|
||||
messages={messages}
|
||||
formats={intlFormats}
|
||||
>
|
||||
<IntlProvider
|
||||
locale={intlLanguage}
|
||||
messages={messages}
|
||||
formats={intlFormats}
|
||||
>
|
||||
<ToastProvider>
|
||||
<PluginsLoader>
|
||||
<AppContainer>
|
||||
<ConfigurationProvider
|
||||
@@ -302,31 +337,23 @@ export const App: React.FC = () => {
|
||||
loading={config.loading}
|
||||
>
|
||||
{maybeRenderReleaseNotes()}
|
||||
<ToastProvider>
|
||||
<ConnectionMonitor />
|
||||
<Suspense fallback={<LoadingIndicator />}>
|
||||
<LightboxProvider>
|
||||
<ManualProvider>
|
||||
<InteractiveProvider>
|
||||
<Helmet {...titleProps} />
|
||||
{maybeRenderNavbar()}
|
||||
<div
|
||||
className={`main container-fluid ${
|
||||
appleRendering ? "apple" : ""
|
||||
}`}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</InteractiveProvider>
|
||||
</ManualProvider>
|
||||
</LightboxProvider>
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
<ConnectionMonitor />
|
||||
<Suspense fallback={<LoadingIndicator />}>
|
||||
<LightboxProvider>
|
||||
<ManualProvider>
|
||||
<InteractiveProvider>
|
||||
<Helmet {...titleProps} />
|
||||
{maybeRenderNavbar()}
|
||||
<MainContainer>{renderContent()}</MainContainer>
|
||||
</InteractiveProvider>
|
||||
</ManualProvider>
|
||||
</LightboxProvider>
|
||||
</Suspense>
|
||||
</ConfigurationProvider>
|
||||
</AppContainer>
|
||||
</PluginsLoader>
|
||||
</IntlProvider>
|
||||
) : null}
|
||||
</ToastProvider>
|
||||
</IntlProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { StudioOverlay } from "../Shared/GridCard/StudioOverlay";
|
||||
import { GalleryPreviewScrubber } from "./GalleryPreviewScrubber";
|
||||
import cx from "classnames";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
interface IGalleryPreviewProps {
|
||||
gallery: GQL.SlimGalleryDataFragment;
|
||||
@@ -53,7 +54,7 @@ export const GalleryPreview: React.FC<IGalleryPreviewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
interface IGalleryCardProps {
|
||||
gallery: GQL.SlimGalleryDataFragment;
|
||||
cardWidth?: number;
|
||||
selecting?: boolean;
|
||||
@@ -62,148 +63,179 @@ interface IProps {
|
||||
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
|
||||
}
|
||||
|
||||
export const GalleryCard: React.FC<IProps> = (props) => {
|
||||
const history = useHistory();
|
||||
const GalleryCardPopovers = PatchComponent(
|
||||
"GalleryCard.Popovers",
|
||||
(props: IGalleryCardProps) => {
|
||||
function maybeRenderScenePopoverButton() {
|
||||
if (props.gallery.scenes.length === 0) return;
|
||||
|
||||
function maybeRenderScenePopoverButton() {
|
||||
if (props.gallery.scenes.length === 0) return;
|
||||
const popoverContent = props.gallery.scenes.map((scene) => (
|
||||
<SceneLink key={scene.id} scene={scene} />
|
||||
));
|
||||
|
||||
const popoverContent = props.gallery.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>{props.gallery.scenes.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderTagPopoverButton() {
|
||||
if (props.gallery.tags.length <= 0) return;
|
||||
|
||||
const popoverContent = props.gallery.tags.map((tag) => (
|
||||
<TagLink key={tag.id} tag={tag} linkType="gallery" />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
className="tag-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faTag} />
|
||||
<span>{props.gallery.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderPerformerPopoverButton() {
|
||||
if (props.gallery.performers.length <= 0) return;
|
||||
|
||||
return (
|
||||
<PerformerPopoverButton
|
||||
performers={props.gallery.performers}
|
||||
linkType="gallery"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderImagesPopoverButton() {
|
||||
if (!props.gallery.image_count) return;
|
||||
|
||||
return (
|
||||
<PopoverCountButton
|
||||
className="image-count"
|
||||
type="image"
|
||||
count={props.gallery.image_count}
|
||||
url={NavUtils.makeGalleryImagesUrl(props.gallery)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOrganized() {
|
||||
if (props.gallery.organized) {
|
||||
return (
|
||||
<OverlayTrigger
|
||||
overlay={<Tooltip id="organised-tooltip">{"Organized"}</Tooltip>}
|
||||
<HoverPopover
|
||||
className="scene-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<div className="organized">
|
||||
<Button className="minimal">
|
||||
<Icon icon={faBox} />
|
||||
</Button>
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faPlayCircle} />
|
||||
<span>{props.gallery.scenes.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (
|
||||
props.gallery.scenes.length > 0 ||
|
||||
props.gallery.performers.length > 0 ||
|
||||
props.gallery.tags.length > 0 ||
|
||||
props.gallery.organized ||
|
||||
props.gallery.image_count > 0
|
||||
) {
|
||||
function maybeRenderTagPopoverButton() {
|
||||
if (props.gallery.tags.length <= 0) return;
|
||||
|
||||
const popoverContent = props.gallery.tags.map((tag) => (
|
||||
<TagLink key={tag.id} tag={tag} linkType="gallery" />
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<ButtonGroup className="card-popovers">
|
||||
{maybeRenderImagesPopoverButton()}
|
||||
{maybeRenderTagPopoverButton()}
|
||||
{maybeRenderPerformerPopoverButton()}
|
||||
{maybeRenderScenePopoverButton()}
|
||||
{maybeRenderOrganized()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
<HoverPopover
|
||||
className="tag-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faTag} />
|
||||
<span>{props.gallery.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<GridCard
|
||||
className={`gallery-card zoom-${props.zoomIndex}`}
|
||||
url={`/galleries/${props.gallery.id}`}
|
||||
width={props.cardWidth}
|
||||
title={galleryTitle(props.gallery)}
|
||||
linkClassName="gallery-card-header"
|
||||
image={
|
||||
<>
|
||||
<GalleryPreview
|
||||
gallery={props.gallery}
|
||||
onScrubberClick={(i) => {
|
||||
history.push(`/galleries/${props.gallery.id}/images/${i}`);
|
||||
}}
|
||||
/>
|
||||
<RatingBanner rating={props.gallery.rating100} />
|
||||
</>
|
||||
function maybeRenderPerformerPopoverButton() {
|
||||
if (props.gallery.performers.length <= 0) return;
|
||||
|
||||
return (
|
||||
<PerformerPopoverButton
|
||||
performers={props.gallery.performers}
|
||||
linkType="gallery"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderImagesPopoverButton() {
|
||||
if (!props.gallery.image_count) return;
|
||||
|
||||
return (
|
||||
<PopoverCountButton
|
||||
className="image-count"
|
||||
type="image"
|
||||
count={props.gallery.image_count}
|
||||
url={NavUtils.makeGalleryImagesUrl(props.gallery)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOrganized() {
|
||||
if (props.gallery.organized) {
|
||||
return (
|
||||
<OverlayTrigger
|
||||
overlay={<Tooltip id="organised-tooltip">{"Organized"}</Tooltip>}
|
||||
placement="bottom"
|
||||
>
|
||||
<div className="organized">
|
||||
<Button className="minimal">
|
||||
<Icon icon={faBox} />
|
||||
</Button>
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}
|
||||
overlays={<StudioOverlay studio={props.gallery.studio} />}
|
||||
details={
|
||||
<div className="gallery-card__details">
|
||||
<span className="gallery-card__date">{props.gallery.date}</span>
|
||||
<TruncatedText
|
||||
className="gallery-card__description"
|
||||
text={props.gallery.details}
|
||||
lineCount={3}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (
|
||||
props.gallery.scenes.length > 0 ||
|
||||
props.gallery.performers.length > 0 ||
|
||||
props.gallery.tags.length > 0 ||
|
||||
props.gallery.organized ||
|
||||
props.gallery.image_count > 0
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<ButtonGroup className="card-popovers">
|
||||
{maybeRenderImagesPopoverButton()}
|
||||
{maybeRenderTagPopoverButton()}
|
||||
{maybeRenderPerformerPopoverButton()}
|
||||
{maybeRenderScenePopoverButton()}
|
||||
{maybeRenderOrganized()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
popovers={maybeRenderPopoverButtonGroup()}
|
||||
selected={props.selected}
|
||||
selecting={props.selecting}
|
||||
onSelectedChanged={props.onSelectedChanged}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return <>{maybeRenderPopoverButtonGroup()}</>;
|
||||
}
|
||||
);
|
||||
|
||||
const GalleryCardDetails = PatchComponent(
|
||||
"GalleryCard.Details",
|
||||
(props: IGalleryCardProps) => {
|
||||
return (
|
||||
<div className="gallery-card__details">
|
||||
<span className="gallery-card__date">{props.gallery.date}</span>
|
||||
<TruncatedText
|
||||
className="gallery-card__description"
|
||||
text={props.gallery.details}
|
||||
lineCount={3}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const GalleryCardOverlays = PatchComponent(
|
||||
"GalleryCard.Overlays",
|
||||
(props: IGalleryCardProps) => {
|
||||
return <StudioOverlay studio={props.gallery.studio} />;
|
||||
}
|
||||
);
|
||||
|
||||
const GalleryCardImage = PatchComponent(
|
||||
"GalleryCard.Image",
|
||||
(props: IGalleryCardProps) => {
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<>
|
||||
<GalleryPreview
|
||||
gallery={props.gallery}
|
||||
onScrubberClick={(i) => {
|
||||
history.push(`/galleries/${props.gallery.id}/images/${i}`);
|
||||
}}
|
||||
/>
|
||||
<RatingBanner rating={props.gallery.rating100} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const GalleryCard = PatchComponent(
|
||||
"GalleryCard",
|
||||
(props: IGalleryCardProps) => {
|
||||
return (
|
||||
<GridCard
|
||||
className={`gallery-card zoom-${props.zoomIndex}`}
|
||||
url={`/galleries/${props.gallery.id}`}
|
||||
width={props.cardWidth}
|
||||
title={galleryTitle(props.gallery)}
|
||||
linkClassName="gallery-card-header"
|
||||
image={<GalleryCardImage {...props} />}
|
||||
overlays={<GalleryCardOverlays {...props} />}
|
||||
details={<GalleryCardDetails {...props} />}
|
||||
popovers={<GalleryCardPopovers {...props} />}
|
||||
selected={props.selected}
|
||||
selecting={props.selecting}
|
||||
onSelectedChanged={props.onSelectedChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -167,7 +167,7 @@ export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
setIsDeleteAlertOpen(false);
|
||||
if (deleted) {
|
||||
history.push("/galleries");
|
||||
history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,9 +41,10 @@ import {
|
||||
} from "src/components/Shared/DetailsPage/Tabs";
|
||||
import { Button, Tab, Tabs } from "react-bootstrap";
|
||||
import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel";
|
||||
import { GroupPerformersPanel } from "./GroupPerformersPanel";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
|
||||
const validTabs = ["default", "scenes", "subgroups"] as const;
|
||||
const validTabs = ["default", "scenes", "performers", "subgroups"] as const;
|
||||
type TabKey = (typeof validTabs)[number];
|
||||
|
||||
function isTabKey(tab: string): tab is TabKey {
|
||||
@@ -55,15 +56,23 @@ const GroupTabs: React.FC<{
|
||||
group: GQL.GroupDataFragment;
|
||||
abbreviateCounter: boolean;
|
||||
}> = ({ tabKey, group, abbreviateCounter }) => {
|
||||
const { scene_count: sceneCount, sub_group_count: groupCount } = group;
|
||||
const {
|
||||
scene_count: sceneCount,
|
||||
performer_count: performerCount,
|
||||
sub_group_count: groupCount,
|
||||
} = group;
|
||||
|
||||
const populatedDefaultTab = useMemo(() => {
|
||||
if (sceneCount == 0 && groupCount !== 0) {
|
||||
return "subgroups";
|
||||
if (sceneCount == 0) {
|
||||
if (performerCount != 0) {
|
||||
return "performers";
|
||||
} else if (groupCount !== 0) {
|
||||
return "subgroups";
|
||||
}
|
||||
}
|
||||
|
||||
return "scenes";
|
||||
}, [sceneCount, groupCount]);
|
||||
}, [sceneCount, performerCount, groupCount]);
|
||||
|
||||
const { setTabKey } = useTabKey({
|
||||
tabKey,
|
||||
@@ -92,6 +101,18 @@ const GroupTabs: React.FC<{
|
||||
>
|
||||
<GroupScenesPanel active={tabKey === "scenes"} group={group} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="performers"
|
||||
title={
|
||||
<TabTitleCounter
|
||||
messageID="performers"
|
||||
count={performerCount}
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<GroupPerformersPanel active={tabKey === "performers"} group={group} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="subgroups"
|
||||
title={
|
||||
@@ -252,10 +273,10 @@ const GroupPage: React.FC<IProps> = ({ group, tabKey }) => {
|
||||
await deleteGroup();
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// redirect to groups page
|
||||
history.push(`/groups`);
|
||||
history.goBack();
|
||||
}
|
||||
|
||||
function toggleEditing(value?: boolean) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useGroupFilterHook } from "src/core/groups";
|
||||
import { PerformerList } from "src/components/Performers/PerformerList";
|
||||
import { View } from "src/components/List/views";
|
||||
|
||||
interface IGroupPerformersPanel {
|
||||
active: boolean;
|
||||
group: GQL.GroupDataFragment;
|
||||
showChildGroupContent?: boolean;
|
||||
}
|
||||
|
||||
export const GroupPerformersPanel: React.FC<IGroupPerformersPanel> = ({
|
||||
active,
|
||||
group,
|
||||
showChildGroupContent,
|
||||
}) => {
|
||||
const filterHook = useGroupFilterHook(group, showChildGroupContent);
|
||||
|
||||
return (
|
||||
<PerformerList
|
||||
filterHook={filterHook}
|
||||
alterQuery={active}
|
||||
view={View.GroupPerformers}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -156,7 +156,7 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
setIsDeleteAlertOpen(false);
|
||||
if (deleted) {
|
||||
history.push("/images");
|
||||
history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
IListFilterOperation,
|
||||
ListOperationButtons,
|
||||
} from "./ListOperationButtons";
|
||||
import { ButtonToolbar } from "react-bootstrap";
|
||||
import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap";
|
||||
import { View } from "./views";
|
||||
import { IListSelect, useFilterOperations } from "./util";
|
||||
import { SidebarIcon } from "../Shared/Sidebar";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
export interface IItemListOperation<T extends QueryResult> {
|
||||
text: string;
|
||||
@@ -41,6 +43,7 @@ export interface IFilteredListToolbar {
|
||||
onDelete?: () => void;
|
||||
operations?: IListFilterOperation[];
|
||||
zoomable?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
}
|
||||
|
||||
export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||
@@ -53,7 +56,9 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||
onDelete,
|
||||
operations,
|
||||
zoomable = false,
|
||||
onToggleSidebar,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const filterOptions = filter.options;
|
||||
const { setDisplayMode, setZoom } = useFilterOperations({
|
||||
filter,
|
||||
@@ -63,29 +68,50 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||
|
||||
return (
|
||||
<ButtonToolbar className="filtered-list-toolbar">
|
||||
{showEditFilter && (
|
||||
<ListFilter
|
||||
onFilterUpdate={setFilter}
|
||||
filter={filter}
|
||||
openFilterDialog={() => showEditFilter()}
|
||||
view={view}
|
||||
<ButtonGroup>
|
||||
{onToggleSidebar && (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
className="sidebar-toggle-button"
|
||||
onClick={onToggleSidebar}
|
||||
variant="secondary"
|
||||
title={intl.formatMessage({ id: "actions.sidebar.open" })}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup>
|
||||
{showEditFilter && (
|
||||
<ListFilter
|
||||
onFilterUpdate={setFilter}
|
||||
filter={filter}
|
||||
openFilterDialog={() => showEditFilter()}
|
||||
view={view}
|
||||
withSidebar={!!onToggleSidebar}
|
||||
/>
|
||||
)}
|
||||
<ListOperationButtons
|
||||
onSelectAll={onSelectAll}
|
||||
onSelectNone={onSelectNone}
|
||||
otherOperations={operations}
|
||||
itemsSelected={selectedIds.size > 0}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
<ListOperationButtons
|
||||
onSelectAll={onSelectAll}
|
||||
onSelectNone={onSelectNone}
|
||||
otherOperations={operations}
|
||||
itemsSelected={selectedIds.size > 0}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
<ListViewOptions
|
||||
displayMode={filter.displayMode}
|
||||
displayModeOptions={filterOptions.displayModeOptions}
|
||||
onSetDisplayMode={setDisplayMode}
|
||||
zoomIndex={zoomable ? filter.zoomIndex : undefined}
|
||||
onSetZoom={zoomable ? setZoom : undefined}
|
||||
/>
|
||||
<ButtonGroup>
|
||||
<ListViewOptions
|
||||
displayMode={filter.displayMode}
|
||||
displayModeOptions={filterOptions.displayModeOptions}
|
||||
onSetDisplayMode={setDisplayMode}
|
||||
zoomIndex={zoomable ? filter.zoomIndex : undefined}
|
||||
onSetZoom={zoomable ? setZoom : undefined}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</ButtonGroup>
|
||||
<ButtonGroup></ButtonGroup>
|
||||
</ButtonToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { BooleanCriterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import {
|
||||
BooleanCriterion,
|
||||
CriterionOption,
|
||||
} from "src/models/list-filter/criteria/criterion";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { Option, SidebarListFilter } from "./SidebarListFilter";
|
||||
|
||||
interface IBooleanFilter {
|
||||
criterion: BooleanCriterion;
|
||||
@@ -43,3 +48,86 @@ export const BooleanFilter: React.FC<IBooleanFilter> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISidebarFilter {
|
||||
title?: React.ReactNode;
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
}
|
||||
|
||||
export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
|
||||
title,
|
||||
option,
|
||||
filter,
|
||||
setFilter,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const trueLabel = intl.formatMessage({
|
||||
id: "true",
|
||||
});
|
||||
const falseLabel = intl.formatMessage({
|
||||
id: "false",
|
||||
});
|
||||
|
||||
const trueOption = useMemo(
|
||||
() => ({
|
||||
id: "true",
|
||||
label: trueLabel,
|
||||
}),
|
||||
[trueLabel]
|
||||
);
|
||||
|
||||
const falseOption = useMemo(
|
||||
() => ({
|
||||
id: "false",
|
||||
label: falseLabel,
|
||||
}),
|
||||
[falseLabel]
|
||||
);
|
||||
|
||||
const criteria = filter.criteriaFor(option.type) as BooleanCriterion[];
|
||||
const criterion = criteria.length > 0 ? criteria[0] : null;
|
||||
|
||||
const selected: Option[] = useMemo(() => {
|
||||
if (!criterion) return [];
|
||||
|
||||
if (criterion.value === "true") {
|
||||
return [trueOption];
|
||||
} else if (criterion.value === "false") {
|
||||
return [falseOption];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [trueOption, falseOption, criterion]);
|
||||
|
||||
const options: Option[] = useMemo(() => {
|
||||
return [trueOption, falseOption].filter((o) => !selected.includes(o));
|
||||
}, [selected, trueOption, falseOption]);
|
||||
|
||||
function onSelect(item: Option) {
|
||||
const newCriterion = criterion ? criterion.clone() : option.makeCriterion();
|
||||
|
||||
newCriterion.value = item.id;
|
||||
|
||||
setFilter(filter.replaceCriteria(option.type, [newCriterion]));
|
||||
}
|
||||
|
||||
function onUnselect() {
|
||||
setFilter(filter.removeCriterion(option.type));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarListFilter
|
||||
title={title}
|
||||
candidates={options}
|
||||
onSelect={onSelect}
|
||||
onUnselect={onUnselect}
|
||||
selected={selected}
|
||||
singleValue
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Badge, Button } from "react-bootstrap";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
interface IFilterButtonProps {
|
||||
filter: ListFilterModel;
|
||||
@@ -13,10 +14,16 @@ export const FilterButton: React.FC<IFilterButtonProps> = ({
|
||||
filter,
|
||||
onClick,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const count = useMemo(() => filter.count(), [filter]);
|
||||
|
||||
return (
|
||||
<Button variant="secondary" className="filter-button" onClick={onClick}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="filter-button"
|
||||
onClick={onClick}
|
||||
title={intl.formatMessage({ id: "search_filter.edit_filter" })}
|
||||
>
|
||||
<Icon icon={faFilter} />
|
||||
{count ? (
|
||||
<Badge pill variant="info">
|
||||
|
||||
96
ui/v2.5/src/components/List/Filters/FilterSidebar.tsx
Normal file
96
ui/v2.5/src/components/List/Filters/FilterSidebar.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { SidebarSection, SidebarToolbar } from "src/components/Shared/Sidebar";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { FilterButton } from "./FilterButton";
|
||||
import { SearchTermInput } from "../ListFilter";
|
||||
import { SidebarSavedFilterList } from "../SavedFilterList";
|
||||
import { View } from "../views";
|
||||
import useFocus from "src/utils/focus";
|
||||
import ScreenUtils from "src/utils/screen";
|
||||
import Mousetrap from "mousetrap";
|
||||
|
||||
export const FilteredSidebarToolbar: React.FC<{
|
||||
onClose?: () => void;
|
||||
}> = ({ onClose, children }) => {
|
||||
return <SidebarToolbar onClose={onClose}>{children}</SidebarToolbar>;
|
||||
};
|
||||
|
||||
export const FilteredSidebarHeader: React.FC<{
|
||||
sidebarOpen: boolean;
|
||||
onClose?: () => void;
|
||||
showEditFilter: () => void;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (filter: ListFilterModel) => void;
|
||||
view?: View;
|
||||
}> = ({ sidebarOpen, onClose, showEditFilter, filter, setFilter, view }) => {
|
||||
const focus = useFocus();
|
||||
const [, setFocus] = focus;
|
||||
|
||||
// Set the focus on the input field when the sidebar is opened
|
||||
// Don't do this on mobile devices
|
||||
useEffect(() => {
|
||||
if (sidebarOpen && !ScreenUtils.isMobile()) {
|
||||
setFocus();
|
||||
}
|
||||
}, [sidebarOpen, setFocus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilteredSidebarToolbar onClose={onClose} />
|
||||
<div className="sidebar-search-container">
|
||||
<SearchTermInput
|
||||
filter={filter}
|
||||
onFilterUpdate={setFilter}
|
||||
focus={focus}
|
||||
/>
|
||||
<FilterButton onClick={() => showEditFilter()} filter={filter} />
|
||||
</div>
|
||||
<SidebarSection
|
||||
className="sidebar-saved-filters"
|
||||
text={<FormattedMessage id="search_filter.saved_filters" />}
|
||||
>
|
||||
<SidebarSavedFilterList
|
||||
filter={filter}
|
||||
onSetFilter={setFilter}
|
||||
view={view}
|
||||
/>
|
||||
</SidebarSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function useFilteredSidebarKeybinds(props: {
|
||||
showSidebar: boolean;
|
||||
setShowSidebar: (show: boolean) => void;
|
||||
}) {
|
||||
const { showSidebar, setShowSidebar } = props;
|
||||
|
||||
// Show the sidebar when the user presses the "/" key
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("/", (e) => {
|
||||
if (!showSidebar) {
|
||||
setShowSidebar(true);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("/");
|
||||
};
|
||||
}, [showSidebar, setShowSidebar]);
|
||||
|
||||
// Hide the sidebar when the user presses the "Esc" key
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("esc", (e) => {
|
||||
if (showSidebar) {
|
||||
setShowSidebar(false);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("esc");
|
||||
};
|
||||
}, [showSidebar, setShowSidebar]);
|
||||
}
|
||||
@@ -1,10 +1,28 @@
|
||||
import React from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { FilterSelect, SelectObject } from "src/components/Shared/Select";
|
||||
import { objectTitle } from "src/core/files";
|
||||
import { galleryTitle } from "src/core/galleries";
|
||||
import { ModifierCriterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { ILabeledId } from "src/models/list-filter/types";
|
||||
import { ILoadResults, useCacheResults } from "src/hooks/data";
|
||||
import {
|
||||
CriterionOption,
|
||||
ModifierCriterion,
|
||||
} from "src/models/list-filter/criteria/criterion";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import {
|
||||
IHierarchicalLabelValue,
|
||||
ILabeledId,
|
||||
ILabeledValueListValue,
|
||||
} from "src/models/list-filter/types";
|
||||
import { Option } from "./SidebarListFilter";
|
||||
import {
|
||||
CriterionModifier,
|
||||
FilterMode,
|
||||
InputMaybe,
|
||||
IntCriterionInput,
|
||||
SceneFilterType,
|
||||
} from "src/core/generated-graphql";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
interface ILabeledIdFilterProps {
|
||||
criterion: ModifierCriterion<ILabeledId[]>;
|
||||
@@ -63,3 +81,454 @@ export const LabeledIdFilter: React.FC<ILabeledIdFilterProps> = ({
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
|
||||
type ModifierValue = "any" | "none" | "any_of" | "only" | "include_subs";
|
||||
|
||||
export function getModifierCandidates(props: {
|
||||
modifier: CriterionModifier;
|
||||
defaultModifier: CriterionModifier;
|
||||
hasSelected?: boolean;
|
||||
hasExcluded?: boolean;
|
||||
singleValue?: boolean;
|
||||
hierarchical?: boolean;
|
||||
}) {
|
||||
const {
|
||||
modifier,
|
||||
defaultModifier,
|
||||
hasSelected,
|
||||
hasExcluded,
|
||||
singleValue,
|
||||
hierarchical,
|
||||
} = props;
|
||||
const ret: ModifierValue[] = [];
|
||||
|
||||
if (modifier === defaultModifier && !hasSelected && !hasExcluded) {
|
||||
ret.push("any");
|
||||
}
|
||||
if (modifier === defaultModifier && !hasSelected && !hasExcluded) {
|
||||
ret.push("none");
|
||||
}
|
||||
if (!singleValue && modifier === defaultModifier && hasSelected) {
|
||||
ret.push("any_of");
|
||||
}
|
||||
if (
|
||||
hierarchical &&
|
||||
modifier === defaultModifier &&
|
||||
(hasSelected || hasExcluded)
|
||||
) {
|
||||
ret.push("include_subs");
|
||||
}
|
||||
if (
|
||||
!singleValue &&
|
||||
modifier === defaultModifier &&
|
||||
hasSelected &&
|
||||
!hasExcluded
|
||||
) {
|
||||
ret.push("only");
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function modifierValueToModifier(key: ModifierValue): CriterionModifier {
|
||||
switch (key) {
|
||||
case "any":
|
||||
return CriterionModifier.NotNull;
|
||||
case "none":
|
||||
return CriterionModifier.IsNull;
|
||||
case "any_of":
|
||||
return CriterionModifier.Includes;
|
||||
case "only":
|
||||
return CriterionModifier.Equals;
|
||||
}
|
||||
|
||||
throw new Error("Invalid modifier value");
|
||||
}
|
||||
|
||||
function getDefaultModifier(singleValue: boolean) {
|
||||
if (singleValue) {
|
||||
return CriterionModifier.Includes;
|
||||
}
|
||||
return CriterionModifier.IncludesAll;
|
||||
}
|
||||
|
||||
export function useSelectionState(props: {
|
||||
criterion: ModifierCriterion<ILabeledValueListValue>;
|
||||
setCriterion: (c: ModifierCriterion<ILabeledValueListValue>) => void;
|
||||
singleValue?: boolean;
|
||||
hierarchical?: boolean;
|
||||
includeSubMessageID?: string;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
criterion,
|
||||
setCriterion,
|
||||
singleValue = false,
|
||||
hierarchical = false,
|
||||
includeSubMessageID,
|
||||
} = props;
|
||||
const { modifier } = criterion;
|
||||
|
||||
const defaultModifier = getDefaultModifier(singleValue);
|
||||
|
||||
const selectedModifiers = useMemo(() => {
|
||||
return {
|
||||
any: modifier === CriterionModifier.NotNull,
|
||||
none: modifier === CriterionModifier.IsNull,
|
||||
any_of: !singleValue && modifier === CriterionModifier.Includes,
|
||||
only: !singleValue && modifier === CriterionModifier.Equals,
|
||||
include_subs:
|
||||
hierarchical &&
|
||||
modifier === defaultModifier &&
|
||||
(criterion.value as IHierarchicalLabelValue).depth === -1,
|
||||
};
|
||||
}, [modifier, singleValue, criterion.value, defaultModifier, hierarchical]);
|
||||
|
||||
const selected = useMemo(() => {
|
||||
const modifierValues: Option[] = Object.entries(selectedModifiers)
|
||||
.filter((v) => v[1])
|
||||
.map((v) => {
|
||||
const messageID =
|
||||
v[0] === "include_subs"
|
||||
? includeSubMessageID
|
||||
: `criterion_modifier_values.${v[0]}`;
|
||||
|
||||
return {
|
||||
id: v[0],
|
||||
label: `(${intl.formatMessage({
|
||||
id: messageID,
|
||||
})})`,
|
||||
className: "modifier-object",
|
||||
};
|
||||
});
|
||||
|
||||
return modifierValues.concat(
|
||||
criterion.value.items.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
}))
|
||||
);
|
||||
}, [intl, selectedModifiers, criterion.value.items, includeSubMessageID]);
|
||||
|
||||
const excluded = useMemo(() => {
|
||||
return criterion.value.excluded.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
}));
|
||||
}, [criterion.value.excluded]);
|
||||
|
||||
const includingOnly = modifier == CriterionModifier.Equals;
|
||||
const excludingOnly =
|
||||
modifier == CriterionModifier.Excludes ||
|
||||
modifier == CriterionModifier.NotEquals;
|
||||
|
||||
const onSelect = useCallback(
|
||||
(v: Option, exclude: boolean) => {
|
||||
const newCriterion: ModifierCriterion<ILabeledValueListValue> =
|
||||
criterion.clone();
|
||||
|
||||
if (v.className === "modifier-object") {
|
||||
if (v.id === "include_subs") {
|
||||
(newCriterion.value as IHierarchicalLabelValue).depth = -1;
|
||||
setCriterion(newCriterion);
|
||||
return;
|
||||
}
|
||||
|
||||
newCriterion.modifier = modifierValueToModifier(v.id as ModifierValue);
|
||||
setCriterion(newCriterion);
|
||||
return;
|
||||
}
|
||||
|
||||
// if only exclude is allowed, then add to excluded
|
||||
if (excludingOnly) {
|
||||
exclude = true;
|
||||
}
|
||||
|
||||
const items = !exclude ? criterion.value.items : criterion.value.excluded;
|
||||
const newItems = [...items, v];
|
||||
|
||||
if (!exclude) {
|
||||
newCriterion.value.items = newItems;
|
||||
} else {
|
||||
newCriterion.value.excluded = newItems;
|
||||
}
|
||||
setCriterion(newCriterion);
|
||||
},
|
||||
[excludingOnly, criterion, setCriterion]
|
||||
);
|
||||
|
||||
const onUnselect = useCallback(
|
||||
(v: Option, exclude: boolean) => {
|
||||
const newCriterion = criterion.clone();
|
||||
|
||||
if (v.className === "modifier-object") {
|
||||
if (v.id === "include_subs") {
|
||||
newCriterion.value.depth = 0;
|
||||
setCriterion(newCriterion);
|
||||
return;
|
||||
}
|
||||
newCriterion.modifier = defaultModifier;
|
||||
setCriterion(newCriterion);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = !exclude ? criterion.value.items : criterion.value.excluded;
|
||||
const newItems = items.filter((i) => i.id !== v.id);
|
||||
|
||||
if (!exclude) {
|
||||
newCriterion.value.items = newItems;
|
||||
} else {
|
||||
newCriterion.value.excluded = newItems;
|
||||
}
|
||||
setCriterion(newCriterion);
|
||||
},
|
||||
[criterion, setCriterion, defaultModifier]
|
||||
);
|
||||
|
||||
return { selected, excluded, onSelect, onUnselect, includingOnly };
|
||||
}
|
||||
|
||||
export function useCriterion(
|
||||
option: CriterionOption,
|
||||
filter: ListFilterModel,
|
||||
setFilter: (f: ListFilterModel) => void
|
||||
) {
|
||||
const criterion = useMemo(() => {
|
||||
const ret = filter.criteria.find(
|
||||
(c) => c.criterionOption.type === option.type
|
||||
);
|
||||
if (ret) return ret as ModifierCriterion<ILabeledValueListValue>;
|
||||
|
||||
const newCriterion = filter.makeCriterion(
|
||||
option.type
|
||||
) as ModifierCriterion<ILabeledValueListValue>;
|
||||
return newCriterion;
|
||||
}, [filter, option]);
|
||||
|
||||
const setCriterion = useCallback(
|
||||
(c: ModifierCriterion<ILabeledValueListValue>) => {
|
||||
const newCriteria = filter.criteria.filter(
|
||||
(cc) => cc.criterionOption.type !== option.type
|
||||
);
|
||||
|
||||
if (c.isValid()) newCriteria.push(c);
|
||||
|
||||
setFilter(filter.setCriteria(newCriteria));
|
||||
},
|
||||
[option.type, setFilter, filter]
|
||||
);
|
||||
|
||||
return { criterion, setCriterion };
|
||||
}
|
||||
|
||||
export function useQueryState(
|
||||
useQuery: (
|
||||
q: string,
|
||||
filter: ListFilterModel,
|
||||
skip: boolean
|
||||
) => ILoadResults<ILabeledId[]>,
|
||||
filter: ListFilterModel,
|
||||
skip: boolean
|
||||
) {
|
||||
const [query, setQuery] = useState("");
|
||||
const { results: queryResults } = useCacheResults(
|
||||
useQuery(query, filter, skip)
|
||||
);
|
||||
|
||||
return { query, setQuery, queryResults };
|
||||
}
|
||||
|
||||
export function useCandidates(props: {
|
||||
criterion: ModifierCriterion<ILabeledValueListValue>;
|
||||
queryResults: ILabeledId[] | undefined;
|
||||
selected: Option[];
|
||||
excluded: Option[];
|
||||
hierarchical?: boolean;
|
||||
singleValue?: boolean;
|
||||
includeSubMessageID?: string;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
criterion,
|
||||
queryResults,
|
||||
selected,
|
||||
excluded,
|
||||
hierarchical = false,
|
||||
singleValue = false,
|
||||
includeSubMessageID,
|
||||
} = props;
|
||||
const { modifier } = criterion;
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (
|
||||
!queryResults ||
|
||||
modifier === CriterionModifier.IsNull ||
|
||||
modifier === CriterionModifier.NotNull
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return queryResults.filter(
|
||||
(p) =>
|
||||
selected.find((s) => s.id === p.id) === undefined &&
|
||||
excluded.find((s) => s.id === p.id) === undefined
|
||||
);
|
||||
}, [queryResults, modifier, selected, excluded]);
|
||||
|
||||
const defaultModifier = getDefaultModifier(singleValue);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const hierarchicalCandidate =
|
||||
hierarchical && (criterion.value as IHierarchicalLabelValue).depth !== -1;
|
||||
|
||||
const modifierCandidates: Option[] = getModifierCandidates({
|
||||
modifier,
|
||||
defaultModifier,
|
||||
hasSelected: selected.length > 0,
|
||||
hasExcluded: excluded.length > 0,
|
||||
singleValue,
|
||||
hierarchical: hierarchicalCandidate,
|
||||
}).map((v) => {
|
||||
const messageID =
|
||||
v === "include_subs"
|
||||
? includeSubMessageID
|
||||
: `criterion_modifier_values.${v}`;
|
||||
|
||||
return {
|
||||
id: v,
|
||||
label: `(${intl.formatMessage({
|
||||
id: messageID,
|
||||
})})`,
|
||||
className: "modifier-object",
|
||||
canExclude: false,
|
||||
};
|
||||
});
|
||||
|
||||
return modifierCandidates.concat(
|
||||
(results ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
}))
|
||||
);
|
||||
}, [
|
||||
defaultModifier,
|
||||
intl,
|
||||
modifier,
|
||||
singleValue,
|
||||
results,
|
||||
selected,
|
||||
excluded,
|
||||
criterion.value,
|
||||
hierarchical,
|
||||
includeSubMessageID,
|
||||
]);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function useLabeledIdFilterState(props: {
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
useQuery: (
|
||||
q: string,
|
||||
filter: ListFilterModel,
|
||||
skip: boolean
|
||||
) => ILoadResults<ILabeledId[]>;
|
||||
singleValue?: boolean;
|
||||
hierarchical?: boolean;
|
||||
includeSubMessageID?: string;
|
||||
}) {
|
||||
const {
|
||||
option,
|
||||
filter,
|
||||
setFilter,
|
||||
useQuery,
|
||||
singleValue = false,
|
||||
hierarchical = false,
|
||||
includeSubMessageID,
|
||||
} = props;
|
||||
|
||||
// defer querying until the user opens the filter
|
||||
const [skip, setSkip] = useState(true);
|
||||
|
||||
const { query, setQuery, queryResults } = useQueryState(
|
||||
useQuery,
|
||||
filter,
|
||||
skip
|
||||
);
|
||||
|
||||
const { criterion, setCriterion } = useCriterion(option, filter, setFilter);
|
||||
|
||||
const { selected, excluded, onSelect, onUnselect, includingOnly } =
|
||||
useSelectionState({
|
||||
criterion,
|
||||
setCriterion,
|
||||
singleValue,
|
||||
hierarchical,
|
||||
includeSubMessageID,
|
||||
});
|
||||
|
||||
const candidates = useCandidates({
|
||||
criterion,
|
||||
queryResults,
|
||||
selected,
|
||||
excluded,
|
||||
hierarchical,
|
||||
singleValue,
|
||||
includeSubMessageID,
|
||||
});
|
||||
|
||||
const onOpen = useCallback(() => {
|
||||
setSkip(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
candidates,
|
||||
onSelect,
|
||||
onUnselect,
|
||||
selected,
|
||||
excluded,
|
||||
canExclude: !includingOnly,
|
||||
query,
|
||||
setQuery,
|
||||
onOpen,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeQueryVariables(query: string, extraProps: {}) {
|
||||
return {
|
||||
filter: {
|
||||
q: query,
|
||||
per_page: 200,
|
||||
},
|
||||
...extraProps,
|
||||
};
|
||||
}
|
||||
|
||||
interface IFilterType {
|
||||
scenes_filter?: InputMaybe<SceneFilterType>;
|
||||
scene_count?: InputMaybe<IntCriterionInput>;
|
||||
}
|
||||
|
||||
export function setObjectFilter(
|
||||
out: IFilterType,
|
||||
mode: FilterMode,
|
||||
relatedFilterOutput: SceneFilterType
|
||||
) {
|
||||
const empty = Object.keys(relatedFilterOutput).length === 0;
|
||||
|
||||
switch (mode) {
|
||||
case FilterMode.Scenes:
|
||||
// if empty, only get objects with scenes
|
||||
if (empty) {
|
||||
out.scene_count = {
|
||||
modifier: CriterionModifier.GreaterThan,
|
||||
value: 0,
|
||||
};
|
||||
}
|
||||
out.scenes_filter = relatedFilterOutput;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,96 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React, { ReactNode, useMemo } from "react";
|
||||
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
|
||||
import { useFindPerformersQuery } from "src/core/generated-graphql";
|
||||
import {
|
||||
CriterionModifier,
|
||||
FindPerformersForSelectQueryVariables,
|
||||
PerformerDataFragment,
|
||||
PerformerFilterType,
|
||||
useFindPerformersForSelectQuery,
|
||||
} from "src/core/generated-graphql";
|
||||
import { ObjectsFilter } from "./SelectableFilter";
|
||||
import { sortByRelevance } from "src/utils/query";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
|
||||
import {
|
||||
makeQueryVariables,
|
||||
setObjectFilter,
|
||||
useLabeledIdFilterState,
|
||||
} from "./LabeledIdFilter";
|
||||
import { SidebarListFilter } from "./SidebarListFilter";
|
||||
|
||||
interface IPerformersFilter {
|
||||
criterion: PerformersCriterion;
|
||||
setCriterion: (c: PerformersCriterion) => void;
|
||||
}
|
||||
|
||||
function usePerformerQuery(query: string) {
|
||||
const { data, loading } = useFindPerformersQuery({
|
||||
variables: {
|
||||
filter: {
|
||||
q: query,
|
||||
per_page: 200,
|
||||
},
|
||||
},
|
||||
interface IHasModifier {
|
||||
modifier: CriterionModifier;
|
||||
}
|
||||
|
||||
function queryVariables(
|
||||
query: string,
|
||||
f?: ListFilterModel
|
||||
): FindPerformersForSelectQueryVariables {
|
||||
const performerFilter: PerformerFilterType = {};
|
||||
|
||||
if (f) {
|
||||
const filterOutput = f.makeFilter();
|
||||
|
||||
// if performer modifier is includes, take it out of the filter
|
||||
if (
|
||||
(filterOutput.performers as IHasModifier)?.modifier ===
|
||||
CriterionModifier.Includes
|
||||
) {
|
||||
delete filterOutput.performers;
|
||||
|
||||
// TODO - look for same in AND?
|
||||
}
|
||||
|
||||
setObjectFilter(performerFilter, f.mode, filterOutput);
|
||||
}
|
||||
|
||||
return makeQueryVariables(query, { performer_filter: performerFilter });
|
||||
}
|
||||
|
||||
function sortResults(
|
||||
query: string,
|
||||
performers?: Pick<PerformerDataFragment, "name" | "alias_list" | "id">[]
|
||||
) {
|
||||
return sortByRelevance(
|
||||
query,
|
||||
performers ?? [],
|
||||
(p) => p.name,
|
||||
(p) => p.alias_list
|
||||
).map((p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
label: p.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function usePerformerQueryFilter(
|
||||
query: string,
|
||||
f?: ListFilterModel,
|
||||
skip?: boolean
|
||||
) {
|
||||
const { data, loading } = useFindPerformersForSelectQuery({
|
||||
variables: queryVariables(query, f),
|
||||
skip,
|
||||
});
|
||||
|
||||
const results = useMemo(() => {
|
||||
return sortByRelevance(
|
||||
query,
|
||||
data?.findPerformers.performers ?? [],
|
||||
(p) => p.name,
|
||||
(p) => p.alias_list
|
||||
).map((p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
label: p.name,
|
||||
};
|
||||
});
|
||||
}, [data, query]);
|
||||
const results = useMemo(
|
||||
() => sortResults(query, data?.findPerformers.performers),
|
||||
[data, query]
|
||||
);
|
||||
|
||||
return { results, loading };
|
||||
}
|
||||
|
||||
function usePerformerQuery(query: string, skip?: boolean) {
|
||||
return usePerformerQueryFilter(query, undefined, skip);
|
||||
}
|
||||
|
||||
const PerformersFilter: React.FC<IPerformersFilter> = ({
|
||||
criterion,
|
||||
setCriterion,
|
||||
@@ -49,4 +104,20 @@ const PerformersFilter: React.FC<IPerformersFilter> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarPerformersFilter: React.FC<{
|
||||
title?: ReactNode;
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
}> = ({ title, option, filter, setFilter }) => {
|
||||
const state = useLabeledIdFilterState({
|
||||
filter,
|
||||
setFilter,
|
||||
option,
|
||||
useQuery: usePerformerQueryFilter,
|
||||
});
|
||||
|
||||
return <SidebarListFilter {...state} title={title} />;
|
||||
};
|
||||
|
||||
export default PerformersFilter;
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import React from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import React, { useMemo } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { CriterionModifier } from "../../../core/generated-graphql";
|
||||
import { INumberValue } from "../../../models/list-filter/types";
|
||||
import { ModifierCriterion } from "../../../models/list-filter/criteria/criterion";
|
||||
import {
|
||||
CriterionOption,
|
||||
ModifierCriterion,
|
||||
} from "../../../models/list-filter/criteria/criterion";
|
||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||
import { RatingStars } from "src/components/Shared/Rating/RatingStars";
|
||||
import {
|
||||
defaultRatingStarPrecision,
|
||||
defaultRatingSystemOptions,
|
||||
} from "src/utils/rating";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { RatingCriterion } from "src/models/list-filter/criteria/rating";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { Option, SidebarListFilter } from "./SidebarListFilter";
|
||||
|
||||
interface IRatingFilterProps {
|
||||
criterion: ModifierCriterion<INumberValue>;
|
||||
@@ -59,3 +71,136 @@ export const RatingFilter: React.FC<IRatingFilterProps> = ({
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
interface ISidebarFilter {
|
||||
title?: React.ReactNode;
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
}
|
||||
|
||||
const any = "any";
|
||||
const none = "none";
|
||||
|
||||
export const SidebarRatingFilter: React.FC<ISidebarFilter> = ({
|
||||
title,
|
||||
option,
|
||||
filter,
|
||||
setFilter,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const anyLabel = `(${intl.formatMessage({
|
||||
id: "criterion_modifier_values.any",
|
||||
})})`;
|
||||
const noneLabel = `(${intl.formatMessage({
|
||||
id: "criterion_modifier_values.none",
|
||||
})})`;
|
||||
|
||||
const anyOption = useMemo(
|
||||
() => ({
|
||||
id: "any",
|
||||
label: anyLabel,
|
||||
className: "modifier-object",
|
||||
}),
|
||||
[anyLabel]
|
||||
);
|
||||
|
||||
const noneOption = useMemo(
|
||||
() => ({
|
||||
id: "none",
|
||||
label: noneLabel,
|
||||
className: "modifier-object",
|
||||
}),
|
||||
[noneLabel]
|
||||
);
|
||||
|
||||
const { configuration: config } = React.useContext(ConfigurationContext);
|
||||
const ratingSystemOptions =
|
||||
config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions;
|
||||
|
||||
const options: Option[] = useMemo(() => {
|
||||
return [anyOption, noneOption];
|
||||
}, [anyOption, noneOption]);
|
||||
|
||||
const criteria = filter.criteriaFor(option.type) as RatingCriterion[];
|
||||
const criterion = criteria.length > 0 ? criteria[0] : null;
|
||||
|
||||
const selected: Option[] = useMemo(() => {
|
||||
if (!criterion) return [];
|
||||
|
||||
if (criterion.modifier === CriterionModifier.NotNull) {
|
||||
return [anyOption];
|
||||
} else if (criterion.modifier === CriterionModifier.IsNull) {
|
||||
return [noneOption];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [anyOption, noneOption, criterion]);
|
||||
|
||||
const ratingValue = useMemo(() => {
|
||||
if (!criterion || criterion.modifier !== CriterionModifier.GreaterThan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return criterion.value.value ?? null;
|
||||
}, [criterion]);
|
||||
|
||||
function onSelect(item: Option) {
|
||||
const newCriterion = criterion ? criterion.clone() : option.makeCriterion();
|
||||
|
||||
if (item.id === any) {
|
||||
newCriterion.modifier = CriterionModifier.NotNull;
|
||||
// newCriterion.value
|
||||
} else if (item.id === none) {
|
||||
newCriterion.modifier = CriterionModifier.IsNull;
|
||||
}
|
||||
|
||||
setFilter(filter.replaceCriteria(option.type, [newCriterion]));
|
||||
}
|
||||
|
||||
function onUnselect() {
|
||||
setFilter(filter.removeCriterion(option.type));
|
||||
}
|
||||
|
||||
function onRatingValueChange(value: number | null) {
|
||||
const newCriterion = criterion ? criterion.clone() : option.makeCriterion();
|
||||
if (value === null) {
|
||||
setFilter(filter.removeCriterion(option.type));
|
||||
return;
|
||||
}
|
||||
|
||||
newCriterion.modifier = CriterionModifier.GreaterThan;
|
||||
newCriterion.value.value = value - 1;
|
||||
|
||||
setFilter(filter.replaceCriteria(option.type, [newCriterion]));
|
||||
}
|
||||
|
||||
const ratingStars = (
|
||||
<div className="no-icon-margin">
|
||||
<RatingStars
|
||||
value={ratingValue}
|
||||
onSetRating={onRatingValueChange}
|
||||
precision={
|
||||
ratingSystemOptions.starPrecision ?? defaultRatingStarPrecision
|
||||
}
|
||||
orMore
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<SidebarListFilter
|
||||
title={title}
|
||||
candidates={options}
|
||||
onSelect={onSelect}
|
||||
onUnselect={onUnselect}
|
||||
selected={selected}
|
||||
singleValue
|
||||
preCandidates={ratingValue === null ? ratingStars : undefined}
|
||||
preSelected={ratingValue !== null ? ratingStars : undefined}
|
||||
/>
|
||||
<div></div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
364
ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx
Normal file
364
ui/v2.5/src/components/List/Filters/SidebarListFilter.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import {
|
||||
faCheckCircle,
|
||||
faMinus,
|
||||
faPlus,
|
||||
faTimesCircle,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { faTimesCircle as faTimesCircleRegular } from "@fortawesome/free-regular-svg-icons";
|
||||
import { ClearableInput } from "src/components/Shared/ClearableInput";
|
||||
import { useIntl } from "react-intl";
|
||||
import { keyboardClickHandler } from "src/utils/keyboard";
|
||||
import { useDebounce } from "src/hooks/debounce";
|
||||
import useFocus from "src/utils/focus";
|
||||
import cx from "classnames";
|
||||
import ScreenUtils from "src/utils/screen";
|
||||
import { SidebarSection } from "src/components/Shared/Sidebar";
|
||||
import { TruncatedInlineText } from "src/components/Shared/TruncatedText";
|
||||
|
||||
interface ISelectedItem {
|
||||
className?: string;
|
||||
label: string;
|
||||
excluded?: boolean;
|
||||
onClick: () => void;
|
||||
// true if the object is a special modifier value
|
||||
modifier?: boolean;
|
||||
}
|
||||
|
||||
const SelectedItem: React.FC<ISelectedItem> = ({
|
||||
className,
|
||||
label,
|
||||
excluded = false,
|
||||
onClick,
|
||||
modifier = false,
|
||||
}) => {
|
||||
const iconClassName = excluded ? "exclude-icon" : "include-button";
|
||||
const spanClassName = excluded
|
||||
? "excluded-object-label"
|
||||
: "selected-object-label";
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const icon = useMemo(() => {
|
||||
if (!hovered) {
|
||||
return excluded ? faTimesCircle : faCheckCircle;
|
||||
}
|
||||
|
||||
return faTimesCircleRegular;
|
||||
}, [hovered, excluded]);
|
||||
|
||||
function onMouseOver() {
|
||||
setHovered(true);
|
||||
}
|
||||
|
||||
function onMouseOut() {
|
||||
setHovered(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cx("selected-object", className, {
|
||||
"modifier-object": modifier,
|
||||
})}
|
||||
>
|
||||
<a
|
||||
onClick={() => onClick()}
|
||||
onKeyDown={keyboardClickHandler(onClick)}
|
||||
onMouseEnter={() => onMouseOver()}
|
||||
onMouseLeave={() => onMouseOut()}
|
||||
onFocus={() => onMouseOver()}
|
||||
onBlur={() => onMouseOut()}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="label-group">
|
||||
<Icon className={`fa-fw ${iconClassName}`} icon={icon} />
|
||||
<TruncatedInlineText className={spanClassName} text={label} />
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const CandidateItem: React.FC<{
|
||||
className?: string;
|
||||
onSelect: (exclude: boolean) => void;
|
||||
label: string;
|
||||
canExclude?: boolean;
|
||||
modifier?: boolean;
|
||||
singleValue?: boolean;
|
||||
}> = ({
|
||||
onSelect,
|
||||
label,
|
||||
canExclude,
|
||||
modifier = false,
|
||||
singleValue = false,
|
||||
className,
|
||||
}) => {
|
||||
const singleValueClass = singleValue ? "single-value" : "";
|
||||
const includeIcon = (
|
||||
<Icon
|
||||
className={`fa-fw include-button ${singleValueClass}`}
|
||||
icon={faPlus}
|
||||
/>
|
||||
);
|
||||
const excludeIcon = (
|
||||
<Icon className={`fa-fw exclude-icon ${singleValueClass}`} icon={faMinus} />
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cx("unselected-object", className, {
|
||||
"modifier-object": modifier,
|
||||
})}
|
||||
>
|
||||
<a
|
||||
onClick={() => onSelect(false)}
|
||||
onKeyDown={keyboardClickHandler(() => onSelect(false))}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="label-group">
|
||||
{includeIcon}
|
||||
<TruncatedInlineText
|
||||
className="unselected-object-label"
|
||||
text={label}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{/* TODO item count */}
|
||||
{/* <span className="object-count">{p.id}</span> */}
|
||||
{canExclude && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(true);
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="minimal exclude-button"
|
||||
>
|
||||
<span className="exclude-button-text">exclude</span>
|
||||
{excludeIcon}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export type Option<T = unknown> = {
|
||||
id: string;
|
||||
className?: string;
|
||||
value?: T;
|
||||
label: string;
|
||||
canExclude?: boolean; // defaults to true
|
||||
};
|
||||
|
||||
export const SelectedList: React.FC<{
|
||||
items: Option[];
|
||||
onUnselect: (item: Option) => void;
|
||||
excluded?: boolean;
|
||||
}> = ({ items, onUnselect, excluded }) => {
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={cx("selected-list", { "excluded-list": excluded })}>
|
||||
{items.map((p) => (
|
||||
<SelectedItem
|
||||
key={p.id}
|
||||
className={p.className}
|
||||
label={p.label}
|
||||
excluded={excluded}
|
||||
onClick={() => onUnselect(p)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
const QueryField: React.FC<{
|
||||
focus: ReturnType<typeof useFocus>;
|
||||
value: string;
|
||||
setValue: (query: string) => void;
|
||||
}> = ({ focus, value, setValue }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [displayQuery, setDisplayQuery] = useState(value);
|
||||
const debouncedSetQuery = useDebounce(setValue, 250);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayQuery(value);
|
||||
}, [value]);
|
||||
|
||||
const onQueryChange = useCallback(
|
||||
(input: string) => {
|
||||
setDisplayQuery(input);
|
||||
debouncedSetQuery(input);
|
||||
},
|
||||
[debouncedSetQuery, setDisplayQuery]
|
||||
);
|
||||
|
||||
return (
|
||||
<ClearableInput
|
||||
focus={focus}
|
||||
value={displayQuery}
|
||||
setValue={(v) => onQueryChange(v)}
|
||||
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IQueryableProps {
|
||||
inputFocus?: ReturnType<typeof useFocus>;
|
||||
query?: string;
|
||||
setQuery?: (query: string) => void;
|
||||
}
|
||||
|
||||
export const CandidateList: React.FC<
|
||||
{
|
||||
items: Option[];
|
||||
onSelect: (item: Option, exclude: boolean) => void;
|
||||
canExclude?: boolean;
|
||||
singleValue?: boolean;
|
||||
} & IQueryableProps
|
||||
> = ({
|
||||
inputFocus,
|
||||
query,
|
||||
setQuery,
|
||||
items,
|
||||
onSelect,
|
||||
canExclude,
|
||||
singleValue,
|
||||
}) => {
|
||||
const showQueryField =
|
||||
inputFocus !== undefined && query !== undefined && setQuery !== undefined;
|
||||
|
||||
return (
|
||||
<div className="queryable-candidate-list">
|
||||
{showQueryField && (
|
||||
<QueryField
|
||||
focus={inputFocus}
|
||||
value={query}
|
||||
setValue={(v) => setQuery(v)}
|
||||
/>
|
||||
)}
|
||||
<ul>
|
||||
{items.map((p) => (
|
||||
<CandidateItem
|
||||
key={p.id}
|
||||
className={p.className}
|
||||
onSelect={(exclude) => onSelect(p, exclude)}
|
||||
label={p.label}
|
||||
canExclude={canExclude && (p.canExclude ?? true)}
|
||||
singleValue={singleValue}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarListFilter: React.FC<{
|
||||
title: React.ReactNode;
|
||||
selected: Option[];
|
||||
excluded?: Option[];
|
||||
candidates: Option[];
|
||||
singleValue?: boolean;
|
||||
onSelect: (item: Option, exclude: boolean) => void;
|
||||
onUnselect: (item: Option, exclude: boolean) => void;
|
||||
canExclude?: boolean;
|
||||
query?: string;
|
||||
setQuery?: (query: string) => void;
|
||||
preSelected?: React.ReactNode;
|
||||
postSelected?: React.ReactNode;
|
||||
preCandidates?: React.ReactNode;
|
||||
postCandidates?: React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
}> = ({
|
||||
title,
|
||||
selected,
|
||||
excluded,
|
||||
candidates,
|
||||
onSelect,
|
||||
onUnselect,
|
||||
canExclude,
|
||||
query,
|
||||
setQuery,
|
||||
singleValue = false,
|
||||
preCandidates,
|
||||
postCandidates,
|
||||
preSelected,
|
||||
postSelected,
|
||||
onOpen,
|
||||
}) => {
|
||||
// TODO - sort items?
|
||||
|
||||
const inputFocus = useFocus();
|
||||
const [, setInputFocus] = inputFocus;
|
||||
|
||||
function unselectHook(item: Option, exclude: boolean) {
|
||||
onUnselect(item, exclude);
|
||||
|
||||
// focus the input box
|
||||
// don't do this on touch devices, as it's annoying
|
||||
if (!ScreenUtils.isTouch()) {
|
||||
setInputFocus();
|
||||
}
|
||||
}
|
||||
|
||||
function selectHook(item: Option, exclude: boolean) {
|
||||
onSelect(item, exclude);
|
||||
|
||||
// reset filter query after selecting
|
||||
setQuery?.("");
|
||||
|
||||
// focus the input box
|
||||
// don't do this on touch devices, as it's annoying
|
||||
if (!ScreenUtils.isTouch()) {
|
||||
setInputFocus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarSection
|
||||
className="sidebar-list-filter"
|
||||
text={title}
|
||||
outsideCollapse={
|
||||
<>
|
||||
{preSelected ? <div className="extra">{preSelected}</div> : null}
|
||||
<SelectedList
|
||||
items={selected}
|
||||
onUnselect={(i) => unselectHook(i, false)}
|
||||
/>
|
||||
{excluded && (
|
||||
<SelectedList
|
||||
items={excluded}
|
||||
onUnselect={(i) => unselectHook(i, true)}
|
||||
excluded
|
||||
/>
|
||||
)}
|
||||
{postSelected ? <div className="extra">{postSelected}</div> : null}
|
||||
</>
|
||||
}
|
||||
onOpen={onOpen}
|
||||
>
|
||||
{preCandidates ? <div className="extra">{preCandidates}</div> : null}
|
||||
<CandidateList
|
||||
items={candidates}
|
||||
onSelect={selectHook}
|
||||
canExclude={canExclude}
|
||||
inputFocus={inputFocus}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
singleValue={singleValue}
|
||||
/>
|
||||
{postCandidates ? <div className="extra">{postCandidates}</div> : null}
|
||||
</SidebarSection>
|
||||
);
|
||||
};
|
||||
|
||||
export function useStaticResults<T>(r: T) {
|
||||
return () => ({ results: r, loading: false });
|
||||
}
|
||||
@@ -1,41 +1,83 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { useFindStudiosQuery } from "src/core/generated-graphql";
|
||||
import React, { ReactNode, useMemo } from "react";
|
||||
import {
|
||||
StudioDataFragment,
|
||||
StudioFilterType,
|
||||
useFindStudiosForSelectQuery,
|
||||
} from "src/core/generated-graphql";
|
||||
import { HierarchicalObjectsFilter } from "./SelectableFilter";
|
||||
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||
import { sortByRelevance } from "src/utils/query";
|
||||
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import {
|
||||
makeQueryVariables,
|
||||
setObjectFilter,
|
||||
useLabeledIdFilterState,
|
||||
} from "./LabeledIdFilter";
|
||||
import { SidebarListFilter } from "./SidebarListFilter";
|
||||
|
||||
interface IStudiosFilter {
|
||||
criterion: StudiosCriterion;
|
||||
setCriterion: (c: StudiosCriterion) => void;
|
||||
}
|
||||
|
||||
function useStudioQuery(query: string) {
|
||||
const { data, loading } = useFindStudiosQuery({
|
||||
variables: {
|
||||
filter: {
|
||||
q: query,
|
||||
per_page: 200,
|
||||
},
|
||||
},
|
||||
function queryVariables(query: string, f?: ListFilterModel) {
|
||||
const studioFilter: StudioFilterType = {};
|
||||
|
||||
if (f) {
|
||||
const filterOutput = f.makeFilter();
|
||||
|
||||
// always remove studio filter from the filter
|
||||
// since modifier is includes
|
||||
delete filterOutput.studios;
|
||||
|
||||
// TODO - look for same in AND?
|
||||
|
||||
setObjectFilter(studioFilter, f.mode, filterOutput);
|
||||
}
|
||||
|
||||
return makeQueryVariables(query, { studio_filter: studioFilter });
|
||||
}
|
||||
|
||||
function sortResults(
|
||||
query: string,
|
||||
studios: Pick<StudioDataFragment, "id" | "name" | "aliases">[]
|
||||
) {
|
||||
return sortByRelevance(
|
||||
query,
|
||||
studios ?? [],
|
||||
(s) => s.name,
|
||||
(s) => s.aliases
|
||||
).map((p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
label: p.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function useStudioQueryFilter(
|
||||
query: string,
|
||||
filter?: ListFilterModel,
|
||||
skip?: boolean
|
||||
) {
|
||||
const { data, loading } = useFindStudiosForSelectQuery({
|
||||
variables: queryVariables(query, filter),
|
||||
skip,
|
||||
});
|
||||
|
||||
const results = useMemo(() => {
|
||||
return sortByRelevance(
|
||||
query,
|
||||
data?.findStudios.studios ?? [],
|
||||
(s) => s.name,
|
||||
(s) => s.aliases
|
||||
).map((p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
label: p.name,
|
||||
};
|
||||
});
|
||||
}, [data, query]);
|
||||
const results = useMemo(
|
||||
() => sortResults(query, data?.findStudios.studios ?? []),
|
||||
[data?.findStudios.studios, query]
|
||||
);
|
||||
|
||||
return { results, loading };
|
||||
}
|
||||
|
||||
function useStudioQuery(query: string, skip?: boolean) {
|
||||
return useStudioQueryFilter(query, undefined, skip);
|
||||
}
|
||||
|
||||
const StudiosFilter: React.FC<IStudiosFilter> = ({
|
||||
criterion,
|
||||
setCriterion,
|
||||
@@ -50,4 +92,23 @@ const StudiosFilter: React.FC<IStudiosFilter> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarStudiosFilter: React.FC<{
|
||||
title?: ReactNode;
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
}> = ({ title, option, filter, setFilter }) => {
|
||||
const state = useLabeledIdFilterState({
|
||||
filter,
|
||||
setFilter,
|
||||
option,
|
||||
useQuery: useStudioQueryFilter,
|
||||
singleValue: true,
|
||||
hierarchical: true,
|
||||
includeSubMessageID: "subsidiary_studios",
|
||||
});
|
||||
|
||||
return <SidebarListFilter {...state} title={title} />;
|
||||
};
|
||||
|
||||
export default StudiosFilter;
|
||||
|
||||
@@ -1,41 +1,92 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { useFindTagsQuery } from "src/core/generated-graphql";
|
||||
import React, { ReactNode, useMemo } from "react";
|
||||
import {
|
||||
CriterionModifier,
|
||||
TagDataFragment,
|
||||
TagFilterType,
|
||||
useFindTagsForSelectQuery,
|
||||
} from "src/core/generated-graphql";
|
||||
import { HierarchicalObjectsFilter } from "./SelectableFilter";
|
||||
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||
import { sortByRelevance } from "src/utils/query";
|
||||
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import {
|
||||
makeQueryVariables,
|
||||
setObjectFilter,
|
||||
useLabeledIdFilterState,
|
||||
} from "./LabeledIdFilter";
|
||||
import { SidebarListFilter } from "./SidebarListFilter";
|
||||
import { TagsCriterion } from "src/models/list-filter/criteria/tags";
|
||||
|
||||
interface ITagsFilter {
|
||||
criterion: StudiosCriterion;
|
||||
setCriterion: (c: StudiosCriterion) => void;
|
||||
criterion: TagsCriterion;
|
||||
setCriterion: (c: TagsCriterion) => void;
|
||||
}
|
||||
|
||||
function useTagQuery(query: string) {
|
||||
const { data, loading } = useFindTagsQuery({
|
||||
variables: {
|
||||
filter: {
|
||||
q: query,
|
||||
per_page: 200,
|
||||
},
|
||||
},
|
||||
interface IHasModifier {
|
||||
modifier: CriterionModifier;
|
||||
}
|
||||
|
||||
function queryVariables(query: string, f?: ListFilterModel) {
|
||||
const tagFilter: TagFilterType = {};
|
||||
|
||||
if (f) {
|
||||
const filterOutput = f.makeFilter();
|
||||
|
||||
// if tag modifier is includes, take it out of the filter
|
||||
if (
|
||||
(filterOutput.tags as IHasModifier)?.modifier ===
|
||||
CriterionModifier.Includes
|
||||
) {
|
||||
delete filterOutput.tags;
|
||||
|
||||
// TODO - look for same in AND?
|
||||
}
|
||||
|
||||
setObjectFilter(tagFilter, f.mode, filterOutput);
|
||||
}
|
||||
|
||||
return makeQueryVariables(query, { tag_filter: tagFilter });
|
||||
}
|
||||
|
||||
function sortResults(
|
||||
query: string,
|
||||
tags: Pick<TagDataFragment, "id" | "name" | "aliases">[]
|
||||
) {
|
||||
return sortByRelevance(
|
||||
query,
|
||||
tags ?? [],
|
||||
(t) => t.name,
|
||||
(t) => t.aliases
|
||||
).map((p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
label: p.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function useTagQueryFilter(
|
||||
query: string,
|
||||
filter?: ListFilterModel,
|
||||
skip?: boolean
|
||||
) {
|
||||
const { data, loading } = useFindTagsForSelectQuery({
|
||||
variables: queryVariables(query, filter),
|
||||
skip,
|
||||
});
|
||||
|
||||
const results = useMemo(() => {
|
||||
return sortByRelevance(
|
||||
query,
|
||||
data?.findTags.tags ?? [],
|
||||
(t) => t.name,
|
||||
(t) => t.aliases
|
||||
).map((p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
label: p.name,
|
||||
};
|
||||
});
|
||||
}, [data, query]);
|
||||
const results = useMemo(
|
||||
() => sortResults(query, data?.findTags.tags ?? []),
|
||||
[data, query]
|
||||
);
|
||||
|
||||
return { results, loading };
|
||||
}
|
||||
|
||||
function useTagQuery(query: string, skip?: boolean) {
|
||||
return useTagQueryFilter(query, undefined, skip);
|
||||
}
|
||||
|
||||
const TagsFilter: React.FC<ITagsFilter> = ({ criterion, setCriterion }) => {
|
||||
return (
|
||||
<HierarchicalObjectsFilter
|
||||
@@ -46,4 +97,22 @@ const TagsFilter: React.FC<ITagsFilter> = ({ criterion, setCriterion }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarTagsFilter: React.FC<{
|
||||
title?: ReactNode;
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
}> = ({ title, option, filter, setFilter }) => {
|
||||
const state = useLabeledIdFilterState({
|
||||
filter,
|
||||
setFilter,
|
||||
option,
|
||||
useQuery: useTagQueryFilter,
|
||||
hierarchical: true,
|
||||
includeSubMessageID: "sub_tags",
|
||||
});
|
||||
|
||||
return <SidebarListFilter {...state} title={title} />;
|
||||
};
|
||||
|
||||
export default TagsFilter;
|
||||
|
||||
@@ -61,11 +61,13 @@ export function useDebouncedSearchInput(
|
||||
export const SearchTermInput: React.FC<{
|
||||
filter: ListFilterModel;
|
||||
onFilterUpdate: (newFilter: ListFilterModel) => void;
|
||||
}> = ({ filter, onFilterUpdate }) => {
|
||||
focus?: ReturnType<typeof useFocus>;
|
||||
}> = ({ filter, onFilterUpdate, focus: providedFocus }) => {
|
||||
const intl = useIntl();
|
||||
const [localInput, setLocalInput] = useState(filter.searchTerm);
|
||||
|
||||
const focus = useFocus();
|
||||
const localFocus = useFocus();
|
||||
const focus = providedFocus ?? localFocus;
|
||||
const [, setQueryFocus] = focus;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -233,6 +235,7 @@ interface IListFilterProps {
|
||||
filter: ListFilterModel;
|
||||
view?: View;
|
||||
openFilterDialog: () => void;
|
||||
withSidebar?: boolean;
|
||||
}
|
||||
|
||||
export const ListFilter: React.FC<IListFilterProps> = ({
|
||||
@@ -240,6 +243,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||
filter,
|
||||
openFilterDialog,
|
||||
view,
|
||||
withSidebar,
|
||||
}) => {
|
||||
const filterOptions = filter.options;
|
||||
|
||||
@@ -313,31 +317,38 @@ export const ListFilter: React.FC<IListFilterProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 d-flex">
|
||||
<SearchTermInput filter={filter} onFilterUpdate={onFilterUpdate} />
|
||||
</div>
|
||||
{!withSidebar && (
|
||||
<div className="d-flex">
|
||||
<SearchTermInput filter={filter} onFilterUpdate={onFilterUpdate} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ButtonGroup className="mr-2 mb-2">
|
||||
<SavedFilterDropdown
|
||||
filter={filter}
|
||||
onSetFilter={(f) => {
|
||||
onFilterUpdate(f);
|
||||
}}
|
||||
view={view}
|
||||
/>
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
<Tooltip id="filter-tooltip">
|
||||
<FormattedMessage id="search_filter.name" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<FilterButton onClick={() => openFilterDialog()} filter={filter} />
|
||||
</OverlayTrigger>
|
||||
</ButtonGroup>
|
||||
{!withSidebar && (
|
||||
<ButtonGroup className="mr-2">
|
||||
<SavedFilterDropdown
|
||||
filter={filter}
|
||||
onSetFilter={(f) => {
|
||||
onFilterUpdate(f);
|
||||
}}
|
||||
view={view}
|
||||
/>
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
<Tooltip id="filter-tooltip">
|
||||
<FormattedMessage id="search_filter.name" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<FilterButton
|
||||
onClick={() => openFilterDialog()}
|
||||
filter={filter}
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
|
||||
<Dropdown as={ButtonGroup} className="mr-2 mb-2">
|
||||
<Dropdown as={ButtonGroup} className="mr-2">
|
||||
<InputGroup.Prepend>
|
||||
<Dropdown.Toggle variant="secondary">
|
||||
{currentSortBy
|
||||
|
||||
@@ -22,7 +22,7 @@ export const OperationDropdown: React.FC<PropsWithChildren<{}>> = ({
|
||||
if (!children) return null;
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<Dropdown as={ButtonGroup}>
|
||||
<Dropdown.Toggle variant="secondary" id="more-menu">
|
||||
<Icon icon={faEllipsisH} />
|
||||
</Dropdown.Toggle>
|
||||
@@ -116,7 +116,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
|
||||
|
||||
if (buttons.length > 0) {
|
||||
return (
|
||||
<ButtonGroup className="ml-2 mb-2">
|
||||
<ButtonGroup className="ml-2">
|
||||
{buttons.map((button) => {
|
||||
return (
|
||||
<OverlayTrigger
|
||||
@@ -206,7 +206,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
|
||||
<>
|
||||
{maybeRenderButtons()}
|
||||
|
||||
<div className="mx-2 mb-2">{renderMore()}</div>
|
||||
<ButtonGroup className="ml-2">{renderMore()}</ButtonGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import Mousetrap from "mousetrap";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Form,
|
||||
OverlayTrigger,
|
||||
Tooltip,
|
||||
} from "react-bootstrap";
|
||||
import { Button, Dropdown, Overlay, Popover } from "react-bootstrap";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
import { useIntl } from "react-intl";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import {
|
||||
faChevronDown,
|
||||
faList,
|
||||
faSquare,
|
||||
faTags,
|
||||
faThLarge,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { ZoomSelect } from "./ZoomSlider";
|
||||
|
||||
interface IListViewOptionsProps {
|
||||
zoomIndex?: number;
|
||||
@@ -25,6 +21,38 @@ interface IListViewOptionsProps {
|
||||
displayModeOptions: DisplayMode[];
|
||||
}
|
||||
|
||||
function getIcon(option: DisplayMode) {
|
||||
switch (option) {
|
||||
case DisplayMode.Grid:
|
||||
return faThLarge;
|
||||
case DisplayMode.List:
|
||||
return faList;
|
||||
case DisplayMode.Wall:
|
||||
return faSquare;
|
||||
case DisplayMode.Tagger:
|
||||
return faTags;
|
||||
}
|
||||
}
|
||||
|
||||
function getLabelId(option: DisplayMode) {
|
||||
let displayModeId = "unknown";
|
||||
switch (option) {
|
||||
case DisplayMode.Grid:
|
||||
displayModeId = "grid";
|
||||
break;
|
||||
case DisplayMode.List:
|
||||
displayModeId = "list";
|
||||
break;
|
||||
case DisplayMode.Wall:
|
||||
displayModeId = "wall";
|
||||
break;
|
||||
case DisplayMode.Tagger:
|
||||
displayModeId = "tagger";
|
||||
break;
|
||||
}
|
||||
return `display_mode.${displayModeId}`;
|
||||
}
|
||||
|
||||
export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
|
||||
zoomIndex,
|
||||
onSetZoom,
|
||||
@@ -37,6 +65,9 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
const overlayTarget = useRef(null);
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("v g", () => {
|
||||
if (displayModeOptions.includes(DisplayMode.Grid)) {
|
||||
@@ -53,82 +84,16 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
|
||||
onSetDisplayMode(DisplayMode.Wall);
|
||||
}
|
||||
});
|
||||
Mousetrap.bind("+", () => {
|
||||
if (onSetZoom && zoomIndex !== undefined && zoomIndex < maxZoom) {
|
||||
onSetZoom(zoomIndex + 1);
|
||||
}
|
||||
});
|
||||
Mousetrap.bind("-", () => {
|
||||
if (onSetZoom && zoomIndex !== undefined && zoomIndex > minZoom) {
|
||||
onSetZoom(zoomIndex - 1);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("v g");
|
||||
Mousetrap.unbind("v l");
|
||||
Mousetrap.unbind("v w");
|
||||
Mousetrap.unbind("+");
|
||||
Mousetrap.unbind("-");
|
||||
};
|
||||
});
|
||||
|
||||
function maybeRenderDisplayModeOptions() {
|
||||
function getIcon(option: DisplayMode) {
|
||||
switch (option) {
|
||||
case DisplayMode.Grid:
|
||||
return faThLarge;
|
||||
case DisplayMode.List:
|
||||
return faList;
|
||||
case DisplayMode.Wall:
|
||||
return faSquare;
|
||||
case DisplayMode.Tagger:
|
||||
return faTags;
|
||||
}
|
||||
}
|
||||
function getLabel(option: DisplayMode) {
|
||||
let displayModeId = "unknown";
|
||||
switch (option) {
|
||||
case DisplayMode.Grid:
|
||||
displayModeId = "grid";
|
||||
break;
|
||||
case DisplayMode.List:
|
||||
displayModeId = "list";
|
||||
break;
|
||||
case DisplayMode.Wall:
|
||||
displayModeId = "wall";
|
||||
break;
|
||||
case DisplayMode.Tagger:
|
||||
displayModeId = "tagger";
|
||||
break;
|
||||
}
|
||||
return intl.formatMessage({ id: `display_mode.${displayModeId}` });
|
||||
}
|
||||
|
||||
if (displayModeOptions.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup className="mb-2">
|
||||
{displayModeOptions.map((option) => (
|
||||
<OverlayTrigger
|
||||
key={option}
|
||||
overlay={
|
||||
<Tooltip id="display-mode-tooltip">{getLabel(option)}</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
active={displayMode === option}
|
||||
onClick={() => onSetDisplayMode(option)}
|
||||
>
|
||||
<Icon icon={getIcon(option)} />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
);
|
||||
function getLabel(option: DisplayMode) {
|
||||
return intl.formatMessage({ id: getLabelId(option) });
|
||||
}
|
||||
|
||||
function onChangeZoom(v: number) {
|
||||
@@ -137,29 +102,61 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderZoom() {
|
||||
if (onSetZoom && displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<div className="ml-2 mb-2 d-none d-sm-inline-flex">
|
||||
<Form.Control
|
||||
className="zoom-slider ml-1"
|
||||
type="range"
|
||||
min={minZoom}
|
||||
max={maxZoom}
|
||||
value={zoomIndex}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChangeZoom(Number.parseInt(e.currentTarget.value, 10))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{maybeRenderDisplayModeOptions()}
|
||||
{maybeRenderZoom()}
|
||||
<Button
|
||||
className="display-mode-select"
|
||||
ref={overlayTarget}
|
||||
variant="secondary"
|
||||
title={intl.formatMessage(
|
||||
{ id: "display_mode.label_current" },
|
||||
{ current: getLabel(displayMode) }
|
||||
)}
|
||||
onClick={() => setShowOptions(!showOptions)}
|
||||
>
|
||||
<Icon icon={getIcon(displayMode)} />
|
||||
<Icon size="xs" icon={faChevronDown} />
|
||||
</Button>
|
||||
<Overlay
|
||||
target={overlayTarget.current}
|
||||
show={showOptions}
|
||||
placement="bottom"
|
||||
rootClose
|
||||
onHide={() => setShowOptions(false)}
|
||||
>
|
||||
{({ placement, arrowProps, show: _show, ...props }) => (
|
||||
<div className="popover" {...props} style={{ ...props.style }}>
|
||||
<Popover.Content className="display-mode-popover">
|
||||
<div className="display-mode-menu">
|
||||
{onSetZoom &&
|
||||
zoomIndex !== undefined &&
|
||||
displayMode === DisplayMode.Grid ? (
|
||||
<div className="zoom-slider-container">
|
||||
<ZoomSelect
|
||||
minZoom={minZoom}
|
||||
maxZoom={maxZoom}
|
||||
zoomIndex={zoomIndex}
|
||||
onChangeZoom={onChangeZoom}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{displayModeOptions.map((option) => (
|
||||
<Dropdown.Item
|
||||
key={option}
|
||||
active={displayMode === option}
|
||||
onClick={() => {
|
||||
setShowOptions(false);
|
||||
onSetDisplayMode(option);
|
||||
}}
|
||||
>
|
||||
<Icon icon={getIcon(option)} /> {getLabel(option)}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
import React, { PropsWithChildren, useMemo } from "react";
|
||||
import { QueryResult } from "@apollo/client";
|
||||
import { ApolloError, QueryResult } from "@apollo/client";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { Pagination, PaginationIndex } from "./Pagination";
|
||||
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
import { ErrorMessage } from "../Shared/ErrorMessage";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
export const LoadedContent: React.FC<
|
||||
PropsWithChildren<{
|
||||
loading?: boolean;
|
||||
error?: ApolloError;
|
||||
}>
|
||||
> = ({ loading, error, children }) => {
|
||||
if (loading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={
|
||||
<FormattedMessage
|
||||
id="errors.loading_type"
|
||||
values={{ type: "items" }}
|
||||
/>
|
||||
}
|
||||
error={error.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export const PagedList: React.FC<
|
||||
PropsWithChildren<{
|
||||
@@ -61,15 +89,8 @@ export const PagedList: React.FC<
|
||||
]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (result.loading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
if (result.error) {
|
||||
return <h1>{result.error.message}</h1>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadedContent loading={result.loading} error={result.error}>
|
||||
{children}
|
||||
{!!pages && (
|
||||
<>
|
||||
@@ -77,7 +98,7 @@ export const PagedList: React.FC<
|
||||
{pagination}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</LoadedContent>
|
||||
);
|
||||
}, [
|
||||
result.loading,
|
||||
|
||||
@@ -13,12 +13,20 @@ import useFocus from "src/utils/focus";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { faCheck, faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useStopWheelScroll } from "src/utils/form";
|
||||
import { Placement } from "react-bootstrap/esm/Overlay";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
const PageCount: React.FC<{
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
onChangePage: (page: number) => void;
|
||||
}> = ({ totalPages, currentPage, onChangePage }) => {
|
||||
pagePopupPlacement?: Placement;
|
||||
}> = ({
|
||||
totalPages,
|
||||
currentPage,
|
||||
onChangePage,
|
||||
pagePopupPlacement = "bottom",
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const currentPageCtrl = useRef(null);
|
||||
const [pageInput, pageFocus] = useFocus();
|
||||
@@ -94,7 +102,7 @@ const PageCount: React.FC<{
|
||||
<Overlay
|
||||
target={currentPageCtrl.current}
|
||||
show={showSelectPage}
|
||||
placement="bottom"
|
||||
placement={pagePopupPlacement}
|
||||
rootClose
|
||||
onHide={() => setShowSelectPage(false)}
|
||||
>
|
||||
@@ -138,9 +146,11 @@ interface IPaginationProps {
|
||||
totalItems: number;
|
||||
metadataByline?: React.ReactNode;
|
||||
onChangePage: (page: number) => void;
|
||||
pagePopupPlacement?: Placement;
|
||||
}
|
||||
|
||||
interface IPaginationIndexProps {
|
||||
loading?: boolean;
|
||||
itemsPerPage: number;
|
||||
currentPage: number;
|
||||
totalItems: number;
|
||||
@@ -149,109 +159,114 @@ interface IPaginationIndexProps {
|
||||
|
||||
const minPagesForCompact = 4;
|
||||
|
||||
export const Pagination: React.FC<IPaginationProps> = ({
|
||||
itemsPerPage,
|
||||
currentPage,
|
||||
totalItems,
|
||||
onChangePage,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const totalPages = useMemo(
|
||||
() => Math.ceil(totalItems / itemsPerPage),
|
||||
[totalItems, itemsPerPage]
|
||||
);
|
||||
export const Pagination: React.FC<IPaginationProps> = PatchComponent(
|
||||
"Pagination",
|
||||
({
|
||||
itemsPerPage,
|
||||
currentPage,
|
||||
totalItems,
|
||||
onChangePage,
|
||||
pagePopupPlacement,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const totalPages = useMemo(
|
||||
() => Math.ceil(totalItems / itemsPerPage),
|
||||
[totalItems, itemsPerPage]
|
||||
);
|
||||
|
||||
const pageButtons = useMemo(() => {
|
||||
if (totalPages >= minPagesForCompact)
|
||||
return (
|
||||
<PageCount
|
||||
totalPages={totalPages}
|
||||
currentPage={currentPage}
|
||||
onChangePage={onChangePage}
|
||||
/>
|
||||
);
|
||||
const pageButtons = useMemo(() => {
|
||||
if (totalPages >= minPagesForCompact)
|
||||
return (
|
||||
<PageCount
|
||||
totalPages={totalPages}
|
||||
currentPage={currentPage}
|
||||
onChangePage={onChangePage}
|
||||
pagePopupPlacement={pagePopupPlacement}
|
||||
/>
|
||||
);
|
||||
|
||||
const pages = [...Array(totalPages).keys()].map((i) => i + 1);
|
||||
const pages = [...Array(totalPages).keys()].map((i) => i + 1);
|
||||
|
||||
return pages.map((page: number) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
key={page}
|
||||
active={currentPage === page}
|
||||
onClick={() => onChangePage(page)}
|
||||
>
|
||||
<FormattedNumber value={page} />
|
||||
</Button>
|
||||
));
|
||||
}, [totalPages, currentPage, onChangePage]);
|
||||
return pages.map((page: number) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
key={page}
|
||||
active={currentPage === page}
|
||||
onClick={() => onChangePage(page)}
|
||||
>
|
||||
<FormattedNumber value={page} />
|
||||
</Button>
|
||||
));
|
||||
}, [totalPages, currentPage, onChangePage, pagePopupPlacement]);
|
||||
|
||||
if (totalPages <= 1) return <div />;
|
||||
if (totalPages <= 1) return <div />;
|
||||
|
||||
return (
|
||||
<ButtonGroup className="pagination">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => onChangePage(1)}
|
||||
title={intl.formatMessage({ id: "pagination.first" })}
|
||||
>
|
||||
<span>«</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => onChangePage(currentPage - 1)}
|
||||
title={intl.formatMessage({ id: "pagination.previous" })}
|
||||
>
|
||||
<
|
||||
</Button>
|
||||
{pageButtons}
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => onChangePage(currentPage + 1)}
|
||||
title={intl.formatMessage({ id: "pagination.next" })}
|
||||
>
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => onChangePage(totalPages)}
|
||||
title={intl.formatMessage({ id: "pagination.last" })}
|
||||
>
|
||||
<span>»</span>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<ButtonGroup className="pagination">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => onChangePage(1)}
|
||||
title={intl.formatMessage({ id: "pagination.first" })}
|
||||
>
|
||||
<span>«</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => onChangePage(currentPage - 1)}
|
||||
title={intl.formatMessage({ id: "pagination.previous" })}
|
||||
>
|
||||
<
|
||||
</Button>
|
||||
{pageButtons}
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => onChangePage(currentPage + 1)}
|
||||
title={intl.formatMessage({ id: "pagination.next" })}
|
||||
>
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => onChangePage(totalPages)}
|
||||
title={intl.formatMessage({ id: "pagination.last" })}
|
||||
>
|
||||
<span>»</span>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const PaginationIndex: React.FC<IPaginationIndexProps> = ({
|
||||
itemsPerPage,
|
||||
currentPage,
|
||||
totalItems,
|
||||
metadataByline,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
export const PaginationIndex: React.FC<IPaginationIndexProps> = PatchComponent(
|
||||
"PaginationIndex",
|
||||
({ loading, itemsPerPage, currentPage, totalItems, metadataByline }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
// Build the pagination index string
|
||||
const firstItemCount: number = Math.min(
|
||||
(currentPage - 1) * itemsPerPage + 1,
|
||||
totalItems
|
||||
);
|
||||
const lastItemCount: number = Math.min(
|
||||
firstItemCount + (itemsPerPage - 1),
|
||||
totalItems
|
||||
);
|
||||
const indexText: string = `${intl.formatNumber(
|
||||
firstItemCount
|
||||
)}-${intl.formatNumber(lastItemCount)} of ${intl.formatNumber(totalItems)}`;
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<span className="filter-container text-muted paginationIndex center-text">
|
||||
{indexText}
|
||||
<br />
|
||||
{metadataByline}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
// Build the pagination index string
|
||||
const firstItemCount: number = Math.min(
|
||||
(currentPage - 1) * itemsPerPage + 1,
|
||||
totalItems
|
||||
);
|
||||
const lastItemCount: number = Math.min(
|
||||
firstItemCount + (itemsPerPage - 1),
|
||||
totalItems
|
||||
);
|
||||
const indexText: string = `${intl.formatNumber(
|
||||
firstItemCount
|
||||
)}-${intl.formatNumber(lastItemCount)} of ${intl.formatNumber(totalItems)}`;
|
||||
|
||||
return (
|
||||
<span className="filter-container text-muted paginationIndex center-text">
|
||||
{indexText}
|
||||
<br />
|
||||
{metadataByline}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { HTMLAttributes, useState } from "react";
|
||||
import React, { HTMLAttributes, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dropdown,
|
||||
Form,
|
||||
FormControl,
|
||||
InputGroup,
|
||||
Modal,
|
||||
@@ -17,12 +18,171 @@ import {
|
||||
} from "src/core/StashService";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { SavedFilterDataFragment } from "src/core/generated-graphql";
|
||||
import {
|
||||
FilterMode,
|
||||
SavedFilterDataFragment,
|
||||
} from "src/core/generated-graphql";
|
||||
import { View } from "./views";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
import { faBookmark, faSave, faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import { AlertModal } from "../Shared/Alert";
|
||||
import cx from "classnames";
|
||||
import { TruncatedInlineText } from "../Shared/TruncatedText";
|
||||
|
||||
const ExistingSavedFilterList: React.FC<{
|
||||
name: string;
|
||||
setName: (name: string) => void;
|
||||
existing: { name: string; id: string }[];
|
||||
}> = ({ name, setName, existing }) => {
|
||||
const filtered = useMemo(() => {
|
||||
if (!name) return existing;
|
||||
|
||||
return existing.filter((f) =>
|
||||
f.name.toLowerCase().includes(name.toLowerCase())
|
||||
);
|
||||
}, [existing, name]);
|
||||
|
||||
return (
|
||||
<ul className="existing-filter-list">
|
||||
{filtered.map((f) => (
|
||||
<li key={f.id}>
|
||||
<Button
|
||||
className="minimal"
|
||||
variant="link"
|
||||
onClick={() => setName(f.name)}
|
||||
>
|
||||
{f.name}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export const SaveFilterDialog: React.FC<{
|
||||
mode: FilterMode;
|
||||
onClose: (name?: string, id?: string) => void;
|
||||
}> = ({ mode, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const [filterName, setFilterName] = useState("");
|
||||
|
||||
const { data } = useFindSavedFilters(mode);
|
||||
|
||||
const overwritingFilter = useMemo(() => {
|
||||
const savedFilters = data?.findSavedFilters ?? [];
|
||||
return savedFilters.find(
|
||||
(f) => f.name.toLowerCase() === filterName.toLowerCase()
|
||||
);
|
||||
}, [data?.findSavedFilters, filterName]);
|
||||
|
||||
return (
|
||||
<Modal show className="save-filter-dialog">
|
||||
<Modal.Body>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<FormattedMessage id="filter_name" />
|
||||
</Form.Label>
|
||||
<FormControl
|
||||
className="bg-secondary text-white border-secondary"
|
||||
placeholder={`${intl.formatMessage({ id: "filter_name" })}…`}
|
||||
value={filterName}
|
||||
onChange={(e) => setFilterName(e.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<ExistingSavedFilterList
|
||||
name={filterName}
|
||||
setName={setFilterName}
|
||||
existing={data?.findSavedFilters ?? []}
|
||||
/>
|
||||
|
||||
{!!overwritingFilter && (
|
||||
<span className="saved-filter-overwrite-warning">
|
||||
<FormattedMessage
|
||||
id="dialogs.overwrite_filter_warning"
|
||||
values={{
|
||||
entityName: overwritingFilter.name,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => onClose()}>
|
||||
{intl.formatMessage({ id: "actions.cancel" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => onClose(filterName, overwritingFilter?.id)}
|
||||
>
|
||||
{intl.formatMessage({ id: "actions.save" })}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteAlert: React.FC<{
|
||||
deletingFilter: SavedFilterDataFragment | undefined;
|
||||
onClose: (confirm?: boolean) => void;
|
||||
}> = ({ deletingFilter, onClose }) => {
|
||||
if (!deletingFilter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal show>
|
||||
<Modal.Body>
|
||||
<FormattedMessage
|
||||
id="dialogs.delete_confirm"
|
||||
values={{
|
||||
entityName: deletingFilter.name,
|
||||
}}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="danger" onClick={() => onClose(true)}>
|
||||
<FormattedMessage id="actions.delete" />
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => onClose()}>
|
||||
<FormattedMessage id="actions.cancel" />
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const OverwriteAlert: React.FC<{
|
||||
overwritingFilter: SavedFilterDataFragment | undefined;
|
||||
onClose: (confirm?: boolean) => void;
|
||||
}> = ({ overwritingFilter, onClose }) => {
|
||||
if (!overwritingFilter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal show>
|
||||
<Modal.Body>
|
||||
<FormattedMessage
|
||||
id="dialogs.overwrite_filter_confirm"
|
||||
values={{
|
||||
entityName: overwritingFilter.name,
|
||||
}}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="primary" onClick={() => onClose(true)}>
|
||||
<FormattedMessage id="actions.overwrite" />
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => onClose()}>
|
||||
<FormattedMessage id="actions.cancel" />
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISavedFilterListProps {
|
||||
filter: ListFilterModel;
|
||||
@@ -49,7 +209,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
||||
SavedFilterDataFragment | undefined
|
||||
>();
|
||||
|
||||
const [saveFilter] = useSaveFilter();
|
||||
const saveFilter = useSaveFilter();
|
||||
const [destroyFilter] = useSavedFilterDestroy();
|
||||
const [saveUISetting] = useConfigureUISetting();
|
||||
|
||||
@@ -60,18 +220,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await saveFilter({
|
||||
variables: {
|
||||
input: {
|
||||
id,
|
||||
mode: filter.mode,
|
||||
name,
|
||||
find_filter: filterCopy.makeFindFilter(),
|
||||
object_filter: filterCopy.makeSavedFilter(),
|
||||
ui_options: filterCopy.makeSavedUIOptions(),
|
||||
},
|
||||
},
|
||||
});
|
||||
await saveFilter(filterCopy, name, id);
|
||||
|
||||
Toast.success(
|
||||
intl.formatMessage(
|
||||
@@ -212,74 +361,6 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
function maybeRenderDeleteAlert() {
|
||||
if (!deletingFilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal show>
|
||||
<Modal.Body>
|
||||
<FormattedMessage
|
||||
id="dialogs.delete_confirm"
|
||||
values={{
|
||||
entityName: deletingFilter.name,
|
||||
}}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => onDeleteFilter(deletingFilter)}
|
||||
>
|
||||
{intl.formatMessage({ id: "actions.delete" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setDeletingFilter(undefined)}
|
||||
>
|
||||
{intl.formatMessage({ id: "actions.cancel" })}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOverwriteAlert() {
|
||||
if (!overwritingFilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal show>
|
||||
<Modal.Body>
|
||||
<FormattedMessage
|
||||
id="dialogs.overwrite_filter_confirm"
|
||||
values={{
|
||||
entityName: overwritingFilter.name,
|
||||
}}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
onSaveFilter(overwritingFilter.name, overwritingFilter.id)
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({ id: "actions.overwrite" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setOverwritingFilter(undefined)}
|
||||
>
|
||||
{intl.formatMessage({ id: "actions.cancel" })}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSavedFilters() {
|
||||
if (error) return <h6 className="text-center">{error.message}</h6>;
|
||||
|
||||
@@ -327,8 +408,24 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{maybeRenderDeleteAlert()}
|
||||
{maybeRenderOverwriteAlert()}
|
||||
<DeleteAlert
|
||||
deletingFilter={deletingFilter}
|
||||
onClose={(confirm) => {
|
||||
if (confirm) {
|
||||
onDeleteFilter(deletingFilter!);
|
||||
}
|
||||
setDeletingFilter(undefined);
|
||||
}}
|
||||
/>
|
||||
<OverwriteAlert
|
||||
overwritingFilter={overwritingFilter}
|
||||
onClose={(confirm) => {
|
||||
if (confirm) {
|
||||
onSaveFilter(overwritingFilter!.name, overwritingFilter!.id);
|
||||
}
|
||||
setOverwritingFilter(undefined);
|
||||
}}
|
||||
/>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
className="bg-secondary text-white border-secondary"
|
||||
@@ -365,6 +462,319 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface ISavedFilterItem {
|
||||
item: SavedFilterDataFragment;
|
||||
onClick: () => void;
|
||||
onDelete: () => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
const SavedFilterItem: React.FC<ISavedFilterItem> = ({
|
||||
item,
|
||||
onClick,
|
||||
onDelete,
|
||||
selected = false,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<li className="saved-filter-item">
|
||||
<a onClick={onClick}>
|
||||
<div className="label-group">
|
||||
<TruncatedInlineText
|
||||
className={cx("no-icon-margin", { selected })}
|
||||
text={item.name}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
className="delete-button"
|
||||
variant="minimal"
|
||||
size="sm"
|
||||
title={intl.formatMessage({ id: "actions.delete" })}
|
||||
onClick={(e) => {
|
||||
onDelete();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Icon fixedWidth icon={faTimes} />
|
||||
</Button>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const SavedFilters: React.FC<{
|
||||
error?: string;
|
||||
loading?: boolean;
|
||||
saving?: boolean;
|
||||
savedFilters: SavedFilterDataFragment[];
|
||||
onFilterClicked: (f: SavedFilterDataFragment) => void;
|
||||
onDeleteClicked: (f: SavedFilterDataFragment) => void;
|
||||
currentFilterID?: string;
|
||||
}> = ({
|
||||
error,
|
||||
loading,
|
||||
saving,
|
||||
savedFilters,
|
||||
onFilterClicked,
|
||||
onDeleteClicked,
|
||||
currentFilterID,
|
||||
}) => {
|
||||
if (error) return <h6 className="text-center">{error}</h6>;
|
||||
|
||||
if (loading || saving) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<LoadingIndicator message="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="saved-filter-list">
|
||||
{savedFilters.map((f) => (
|
||||
<SavedFilterItem
|
||||
key={f.name}
|
||||
item={f}
|
||||
onClick={() => onFilterClicked(f)}
|
||||
onDelete={() => onDeleteClicked(f)}
|
||||
selected={currentFilterID === f.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarSavedFilterList: React.FC<ISavedFilterListProps> = ({
|
||||
filter,
|
||||
onSetFilter,
|
||||
view,
|
||||
}) => {
|
||||
const Toast = useToast();
|
||||
const intl = useIntl();
|
||||
|
||||
const [currentSavedFilter, setCurrentSavedFilter] = useState<{
|
||||
id: string;
|
||||
set: boolean;
|
||||
}>();
|
||||
|
||||
const { data, error, loading, refetch } = useFindSavedFilters(filter.mode);
|
||||
|
||||
const [filterName, setFilterName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingFilter, setDeletingFilter] = useState<
|
||||
SavedFilterDataFragment | undefined
|
||||
>();
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [settingDefault, setSettingDefault] = useState(false);
|
||||
|
||||
const saveFilter = useSaveFilter();
|
||||
const [destroyFilter] = useSavedFilterDestroy();
|
||||
const [saveUISetting] = useConfigureUISetting();
|
||||
|
||||
const filteredFilters = useMemo(() => {
|
||||
const savedFilters = data?.findSavedFilters ?? [];
|
||||
if (!filterName) return savedFilters;
|
||||
|
||||
return savedFilters.filter(
|
||||
(f) =>
|
||||
!filterName || f.name.toLowerCase().includes(filterName.toLowerCase())
|
||||
);
|
||||
}, [data?.findSavedFilters, filterName]);
|
||||
|
||||
// handle when filter is changed to de-select the current filter
|
||||
useEffect(() => {
|
||||
// HACK - first change will be from setting the filter
|
||||
// second change is likely from somewhere else
|
||||
setCurrentSavedFilter((v) => {
|
||||
if (!v) return v;
|
||||
|
||||
if (v.set) {
|
||||
setCurrentSavedFilter({ id: v.id, set: false });
|
||||
} else {
|
||||
setCurrentSavedFilter(undefined);
|
||||
}
|
||||
});
|
||||
}, [filter]);
|
||||
|
||||
async function onSaveFilter(name: string, id?: string) {
|
||||
try {
|
||||
setSaving(true);
|
||||
await saveFilter(filter, name, id);
|
||||
|
||||
Toast.success(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "toast.saved_entity",
|
||||
},
|
||||
{
|
||||
entity: intl.formatMessage({ id: "filter" }).toLocaleLowerCase(),
|
||||
}
|
||||
)
|
||||
);
|
||||
setFilterName("");
|
||||
setShowSaveDialog(false);
|
||||
refetch();
|
||||
} catch (err) {
|
||||
Toast.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteFilter(f: SavedFilterDataFragment) {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
await destroyFilter({
|
||||
variables: {
|
||||
input: {
|
||||
id: f.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Toast.success(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "toast.delete_past_tense",
|
||||
},
|
||||
{
|
||||
count: 1,
|
||||
singularEntity: intl.formatMessage({ id: "filter" }),
|
||||
pluralEntity: intl.formatMessage({ id: "filters" }),
|
||||
}
|
||||
)
|
||||
);
|
||||
refetch();
|
||||
} catch (err) {
|
||||
Toast.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setDeletingFilter(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSetDefaultFilter() {
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filterCopy = filter.clone();
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
await saveUISetting({
|
||||
variables: {
|
||||
key: `defaultFilters.${view.toString()}`,
|
||||
value: {
|
||||
mode: filter.mode,
|
||||
find_filter: filterCopy.makeFindFilter(),
|
||||
object_filter: filterCopy.makeSavedFilter(),
|
||||
ui_options: filterCopy.makeSavedUIOptions(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Toast.success(
|
||||
intl.formatMessage({
|
||||
id: "toast.default_filter_set",
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
Toast.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSettingDefault(false);
|
||||
}
|
||||
}
|
||||
|
||||
function filterClicked(f: SavedFilterDataFragment) {
|
||||
const newFilter = filter.clone();
|
||||
|
||||
newFilter.currentPage = 1;
|
||||
// #1795 - reset search term if not present in saved filter
|
||||
newFilter.searchTerm = "";
|
||||
newFilter.configureFromSavedFilter(f);
|
||||
// #1507 - reset random seed when loaded
|
||||
newFilter.randomSeed = -1;
|
||||
|
||||
setCurrentSavedFilter({ id: f.id, set: true });
|
||||
onSetFilter(newFilter);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sidebar-saved-filter-list-container">
|
||||
<DeleteAlert
|
||||
deletingFilter={deletingFilter}
|
||||
onClose={(confirm) => {
|
||||
if (confirm) {
|
||||
onDeleteFilter(deletingFilter!);
|
||||
}
|
||||
setDeletingFilter(undefined);
|
||||
}}
|
||||
/>
|
||||
{showSaveDialog && (
|
||||
<SaveFilterDialog
|
||||
mode={filter.mode}
|
||||
onClose={(name, id) => {
|
||||
setShowSaveDialog(false);
|
||||
if (name) {
|
||||
onSaveFilter(name, id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AlertModal
|
||||
show={!!settingDefault}
|
||||
text={<FormattedMessage id="dialogs.set_default_filter_confirm" />}
|
||||
confirmVariant="primary"
|
||||
onConfirm={() => onSetDefaultFilter()}
|
||||
onCancel={() => setSettingDefault(false)}
|
||||
/>
|
||||
|
||||
<div className="toolbar">
|
||||
<Button
|
||||
className="minimal save-filter-button"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
>
|
||||
<span>
|
||||
<FormattedMessage id="actions.save_filter" />
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="minimal set-as-default-button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setSettingDefault(true)}
|
||||
>
|
||||
<FormattedMessage id="actions.set_as_default" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
className="bg-secondary text-white border-secondary saved-filter-search-input"
|
||||
placeholder={`${intl.formatMessage({ id: "filter_name" })}…`}
|
||||
value={filterName}
|
||||
onChange={(e) => setFilterName(e.target.value)}
|
||||
/>
|
||||
<SavedFilters
|
||||
error={error?.message}
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
savedFilters={filteredFilters}
|
||||
onFilterClicked={filterClicked}
|
||||
onDeleteClicked={setDeletingFilter}
|
||||
currentFilterID={currentSavedFilter?.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {
|
||||
const SavedFilterDropdownRef = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -377,7 +787,7 @@ export const SavedFilterDropdown: React.FC<ISavedFilterListProps> = (props) => {
|
||||
SavedFilterDropdownRef.displayName = "SavedFilterDropdown";
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<Dropdown as={ButtonGroup}>
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
|
||||
50
ui/v2.5/src/components/List/ZoomSlider.tsx
Normal file
50
ui/v2.5/src/components/List/ZoomSlider.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useEffect } from "react";
|
||||
import Mousetrap from "mousetrap";
|
||||
import { Form } from "react-bootstrap";
|
||||
|
||||
export interface IZoomSelectProps {
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
zoomIndex: number;
|
||||
onChangeZoom: (v: number) => void;
|
||||
}
|
||||
|
||||
export const ZoomSelect: React.FC<IZoomSelectProps> = ({
|
||||
minZoom,
|
||||
maxZoom,
|
||||
zoomIndex,
|
||||
onChangeZoom,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("+", () => {
|
||||
if (zoomIndex !== undefined && zoomIndex < maxZoom) {
|
||||
onChangeZoom(zoomIndex + 1);
|
||||
}
|
||||
});
|
||||
Mousetrap.bind("-", () => {
|
||||
if (zoomIndex !== undefined && zoomIndex > minZoom) {
|
||||
onChangeZoom(zoomIndex - 1);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("+");
|
||||
Mousetrap.unbind("-");
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Form.Control
|
||||
className="zoom-slider"
|
||||
type="range"
|
||||
min={minZoom}
|
||||
max={maxZoom}
|
||||
value={zoomIndex}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChangeZoom(Number.parseInt(e.currentTarget.value, 10));
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -34,12 +34,77 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.display-mode-select {
|
||||
padding-left: 0.375rem;
|
||||
padding-right: 0.375rem;
|
||||
text-wrap: nowrap;
|
||||
|
||||
> svg:first-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.display-mode-menu {
|
||||
.dropdown-item {
|
||||
color: #f5f8fa;
|
||||
font-size: 1rem;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(138, 155, 168, 0.15);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.zoom-slider-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
min-height: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.zoom-slider {
|
||||
&::-webkit-slider-thumb {
|
||||
background-color: $primary;
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
background-color: $body-bg;
|
||||
}
|
||||
|
||||
&:focus::-webkit-slider-runnable-track {
|
||||
background-color: lighten($body-bg, 5%);
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
background-color: $primary;
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
background-color: $body-bg;
|
||||
}
|
||||
|
||||
&:focus::-moz-range-track {
|
||||
background-color: lighten($body-bg, 5%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.display-mode-popover {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
input[type="range"].zoom-slider {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
max-width: 60px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
||||
// width is set to 100% by default, but in a flex container, it gets a very small width
|
||||
width: unset;
|
||||
}
|
||||
|
||||
.query-text-field-group {
|
||||
@@ -103,7 +168,7 @@ input[type="range"].zoom-slider {
|
||||
|
||||
.saved-filter-list {
|
||||
list-style: none;
|
||||
margin-bottom: 0;
|
||||
margin-bottom: 0.25rem;
|
||||
max-height: 230px;
|
||||
overflow-y: auto;
|
||||
padding-left: 0;
|
||||
@@ -113,11 +178,18 @@ input[type="range"].zoom-slider {
|
||||
|
||||
.dropdown-item {
|
||||
align-items: center;
|
||||
color: $text-color;
|
||||
display: inline;
|
||||
overflow-x: hidden;
|
||||
padding-left: 1.25rem;
|
||||
padding-right: 0.25rem;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: #8a9ba826;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
@@ -134,6 +206,87 @@ input[type="range"].zoom-slider {
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-saved-filter-list-container .toolbar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem;
|
||||
|
||||
.btn {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-saved-filter-list-container {
|
||||
.label-group {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.saved-filter-item {
|
||||
cursor: pointer;
|
||||
height: 2em;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
a {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 2em;
|
||||
justify-content: space-between;
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: rgba(138, 155, 168, 0.15);
|
||||
}
|
||||
|
||||
.selected-object-label,
|
||||
.excluded-object-label {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.label-group {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
.selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.fa-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
color: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
.saved-filter-search-input {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.save-filter-dialog {
|
||||
.existing-filter-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.save-filter-button {
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.saved-filter-overwrite-warning {
|
||||
color: $danger;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.edit-filter-dialog .rating-stars {
|
||||
font-size: 1.3em;
|
||||
margin-left: 0.25em;
|
||||
@@ -267,13 +420,16 @@ input[type="range"].zoom-slider {
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
position: relative;
|
||||
|
||||
.fa-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 60%;
|
||||
position: absolute;
|
||||
right: -3px;
|
||||
right: 0;
|
||||
|
||||
// button group has a z-index of 1
|
||||
z-index: 2;
|
||||
@@ -390,6 +546,133 @@ input[type="range"].zoom-slider {
|
||||
}
|
||||
}
|
||||
|
||||
// used to align list text without icons to those that do
|
||||
.sidebar .no-icon-margin {
|
||||
// icon width is 17.5px + 5.6px margin each side
|
||||
margin-left: 28.7px;
|
||||
}
|
||||
|
||||
.sidebar-list-filter .clearable-input-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-list-filter ul {
|
||||
list-style-type: none;
|
||||
margin-bottom: 0.25rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
// to prevent unnecessary vertical scrollbar
|
||||
padding-bottom: 0.15rem;
|
||||
padding-inline-start: 0;
|
||||
|
||||
.modifier-object {
|
||||
font-style: italic;
|
||||
|
||||
.selected-object-label,
|
||||
.unselected-object-label {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.unselected-object {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.selected-object,
|
||||
.excluded-object,
|
||||
.unselected-object {
|
||||
cursor: pointer;
|
||||
height: 2em;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
a {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 2em;
|
||||
justify-content: space-between;
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: rgba(138, 155, 168, 0.15);
|
||||
}
|
||||
|
||||
.selected-object-label,
|
||||
.excluded-object-label {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.include-button {
|
||||
color: $success;
|
||||
|
||||
&.single-value {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.exclude-icon {
|
||||
color: $danger;
|
||||
}
|
||||
|
||||
.exclude-button {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-left: 0.25rem;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
|
||||
.exclude-button-text {
|
||||
color: $danger;
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
&:hover .exclude-button-text,
|
||||
&:focus .exclude-button-text {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.object-count {
|
||||
color: $text-muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-object:hover,
|
||||
.selected-object a:focus-visible,
|
||||
.excluded-object:hover,
|
||||
.excluded-object a:focus-visible {
|
||||
.include-button,
|
||||
.exclude-icon {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-object,
|
||||
.unselected-object {
|
||||
.label-group {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-list-filter > .extra {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.sidebar-list-filter .extra {
|
||||
min-height: 2em;
|
||||
}
|
||||
|
||||
.tilted {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
@@ -582,6 +865,33 @@ input[type="range"].zoom-slider {
|
||||
|
||||
.filtered-list-toolbar {
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
& > .btn-group {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
row-gap: 0.5rem;
|
||||
|
||||
&:first-child {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.btn.display-mode-select {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-pane .filtered-list-toolbar {
|
||||
flex-wrap: nowrap;
|
||||
|
||||
& > .btn-group {
|
||||
align-items: baseline;
|
||||
}
|
||||
}
|
||||
|
||||
.search-term-input {
|
||||
@@ -613,3 +923,51 @@ input[type="range"].zoom-slider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-list-container .sidebar-pane {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
.sidebar-search-container {
|
||||
display: flex;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.search-term-input {
|
||||
flex-grow: 1;
|
||||
margin-right: 0.25rem;
|
||||
|
||||
.clearable-text-field {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
.sidebar .search-term-input {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-footer {
|
||||
background-color: transparent;
|
||||
bottom: $navbar-height;
|
||||
padding: 0.5rem 1rem;
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-bottom: 0;
|
||||
|
||||
.btn:disabled {
|
||||
color: #888;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,4 +32,5 @@ export enum View {
|
||||
|
||||
GroupScenes = "group_scenes",
|
||||
GroupSubGroups = "group_sub_groups",
|
||||
GroupPerformers = "group_performers",
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import { AliasList } from "src/components/Shared/DetailsPage/AliasList";
|
||||
import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
|
||||
import { LightboxLink } from "src/hooks/Lightbox/LightboxLink";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { ILightboxImage } from "src/hooks/Lightbox/types";
|
||||
|
||||
interface IProps {
|
||||
performer: GQL.PerformerDataFragment;
|
||||
@@ -201,6 +202,34 @@ const PerformerTabs: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
interface IPerformerHeaderImageProps {
|
||||
activeImage: string | null | undefined;
|
||||
collapsed: boolean;
|
||||
encodingImage: boolean;
|
||||
lightboxImages: ILightboxImage[];
|
||||
performer: GQL.PerformerDataFragment;
|
||||
}
|
||||
|
||||
const PerformerHeaderImage: React.FC<IPerformerHeaderImageProps> =
|
||||
PatchComponent(
|
||||
"PerformerHeaderImage",
|
||||
({ encodingImage, activeImage, lightboxImages, performer }) => {
|
||||
return (
|
||||
<HeaderImage encodingImage={encodingImage}>
|
||||
{!!activeImage && (
|
||||
<LightboxLink images={lightboxImages}>
|
||||
<DetailImage
|
||||
className="performer"
|
||||
src={activeImage}
|
||||
alt={performer.name}
|
||||
/>
|
||||
</LightboxLink>
|
||||
)}
|
||||
</HeaderImage>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const PerformerPage: React.FC<IProps> = PatchComponent(
|
||||
"PerformerPage",
|
||||
({ performer, tabKey }) => {
|
||||
@@ -298,10 +327,10 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
|
||||
await deletePerformer({ variables: { id: performer.id } });
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// redirect to performers page
|
||||
history.push("/performers");
|
||||
history.goBack();
|
||||
}
|
||||
|
||||
function toggleEditing(value?: boolean) {
|
||||
@@ -364,18 +393,13 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
|
||||
show={enableBackgroundImage && !isEditing}
|
||||
/>
|
||||
<div className="detail-container">
|
||||
<HeaderImage encodingImage={encodingImage}>
|
||||
{!!activeImage && (
|
||||
<LightboxLink images={lightboxImages}>
|
||||
<DetailImage
|
||||
className="performer"
|
||||
src={activeImage}
|
||||
alt={performer.name}
|
||||
/>
|
||||
</LightboxLink>
|
||||
)}
|
||||
</HeaderImage>
|
||||
|
||||
<PerformerHeaderImage
|
||||
activeImage={activeImage}
|
||||
collapsed={collapsed}
|
||||
encodingImage={encodingImage}
|
||||
lightboxImages={lightboxImages}
|
||||
performer={performer}
|
||||
/>
|
||||
<div className="row">
|
||||
<div className="performer-head col">
|
||||
<DetailTitle
|
||||
|
||||
@@ -29,7 +29,7 @@ const PerformerDetailGroup: React.FC<PropsWithChildren<IPerformerDetails>> =
|
||||
|
||||
export const PerformerDetailsPanel: React.FC<IPerformerDetails> =
|
||||
PatchComponent("PerformerDetailsPanel", (props) => {
|
||||
const { performer, fullWidth } = props;
|
||||
const { performer, fullWidth, collapsed } = props;
|
||||
|
||||
// Network state
|
||||
const intl = useIntl();
|
||||
@@ -94,7 +94,11 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> =
|
||||
}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem id="death_date" value={performer.death_date} />
|
||||
<DetailItem
|
||||
id="death_date"
|
||||
value={performer.death_date}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
{performer.country ? (
|
||||
<DetailItem
|
||||
id="country"
|
||||
@@ -177,7 +181,9 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> =
|
||||
value={renderStashIDs()}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
{fullWidth && <CustomFields values={performer.custom_fields} />}
|
||||
{(fullWidth || !collapsed) && (
|
||||
<CustomFields values={performer.custom_fields} />
|
||||
)}
|
||||
</PerformerDetailGroup>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -909,7 +909,7 @@ const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
|
||||
) {
|
||||
loadScene(queueScenes[currentQueueIndex + 1].id);
|
||||
} else {
|
||||
history.push("/scenes");
|
||||
history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,12 @@ export const SceneGalleriesPanel: React.FC<ISceneGalleriesPanelProps> = ({
|
||||
galleries,
|
||||
}) => {
|
||||
const cards = galleries.map((gallery) => (
|
||||
<GalleryCard key={gallery.id} gallery={gallery} selecting={false} />
|
||||
<GalleryCard
|
||||
key={gallery.id}
|
||||
gallery={gallery}
|
||||
selecting={false}
|
||||
zoomIndex={2}
|
||||
/>
|
||||
));
|
||||
|
||||
return <div className="container scene-galleries">{cards}</div>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import { useIntl } from "react-intl";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import Mousetrap from "mousetrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
@@ -9,7 +9,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
import { Tagger } from "../Tagger/scenes/SceneTagger";
|
||||
import { IPlaySceneOptions, SceneQueue } from "src/models/sceneQueue";
|
||||
import { SceneWallPanel } from "../Wall/WallPanel";
|
||||
import { SceneWallPanel } from "./SceneWallPanel";
|
||||
import { SceneListTable } from "./SceneListTable";
|
||||
import { EditScenesDialog } from "./EditScenesDialog";
|
||||
import { DeleteScenesDialog } from "./DeleteScenesDialog";
|
||||
@@ -25,12 +25,30 @@ import { objectTitle } from "src/core/files";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { View } from "../List/views";
|
||||
import { FileSize } from "../Shared/FileSize";
|
||||
import { PagedList } from "../List/PagedList";
|
||||
import { LoadedContent } from "../List/PagedList";
|
||||
import { useCloseEditDelete, useFilterOperations } from "../List/util";
|
||||
import { IListFilterOperation } from "../List/ListOperationButtons";
|
||||
import { FilteredListToolbar } from "../List/FilteredListToolbar";
|
||||
import { useFilteredItemList } from "../List/ItemList";
|
||||
import { FilterTags } from "../List/FilterTags";
|
||||
import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar";
|
||||
import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter";
|
||||
import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter";
|
||||
import { PerformersCriterionOption } from "src/models/list-filter/criteria/performers";
|
||||
import { StudiosCriterionOption } from "src/models/list-filter/criteria/studios";
|
||||
import { TagsCriterionOption } from "src/models/list-filter/criteria/tags";
|
||||
import { SidebarTagsFilter } from "../List/Filters/TagsFilter";
|
||||
import cx from "classnames";
|
||||
import { RatingCriterionOption } from "src/models/list-filter/criteria/rating";
|
||||
import { SidebarRatingFilter } from "../List/Filters/RatingFilter";
|
||||
import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized";
|
||||
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
|
||||
import {
|
||||
FilteredSidebarHeader,
|
||||
useFilteredSidebarKeybinds,
|
||||
} from "../List/Filters/FilterSidebar";
|
||||
import { PatchContainerComponent } from "src/patch";
|
||||
import { Pagination, PaginationIndex } from "../List/Pagination";
|
||||
|
||||
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
|
||||
const duration = result?.data?.findScenes?.duration;
|
||||
@@ -184,6 +202,70 @@ const SceneList: React.FC<{
|
||||
return null;
|
||||
};
|
||||
|
||||
const ScenesFilterSidebarSections = PatchContainerComponent(
|
||||
"FilteredSceneList.SidebarSections"
|
||||
);
|
||||
|
||||
const SidebarContent: React.FC<{
|
||||
filter: ListFilterModel;
|
||||
setFilter: (filter: ListFilterModel) => void;
|
||||
view?: View;
|
||||
sidebarOpen: boolean;
|
||||
onClose?: () => void;
|
||||
showEditFilter: (editingCriterion?: string) => void;
|
||||
}> = ({ filter, setFilter, view, showEditFilter, sidebarOpen, onClose }) => {
|
||||
return (
|
||||
<>
|
||||
<FilteredSidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
onClose={onClose}
|
||||
showEditFilter={showEditFilter}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
view={view}
|
||||
/>
|
||||
|
||||
<ScenesFilterSidebarSections>
|
||||
<SidebarStudiosFilter
|
||||
title={<FormattedMessage id="studios" />}
|
||||
data-type={StudiosCriterionOption.type}
|
||||
option={StudiosCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
<SidebarPerformersFilter
|
||||
title={<FormattedMessage id="performers" />}
|
||||
data-type={PerformersCriterionOption.type}
|
||||
option={PerformersCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
<SidebarTagsFilter
|
||||
title={<FormattedMessage id="tags" />}
|
||||
data-type={TagsCriterionOption.type}
|
||||
option={TagsCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
<SidebarRatingFilter
|
||||
title={<FormattedMessage id="rating" />}
|
||||
data-type={RatingCriterionOption.type}
|
||||
option={RatingCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
<SidebarBooleanFilter
|
||||
title={<FormattedMessage id="organized" />}
|
||||
data-type={OrganizedCriterionOption.type}
|
||||
option={OrganizedCriterionOption}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
</ScenesFilterSidebarSections>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IFilteredScenes {
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
defaultSort?: string;
|
||||
@@ -199,6 +281,12 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props;
|
||||
|
||||
// States
|
||||
const {
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
loading: sidebarStateLoading,
|
||||
} = useSidebarState(view);
|
||||
|
||||
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
|
||||
useFilteredItemList({
|
||||
filterStateProps: {
|
||||
@@ -237,6 +325,10 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
});
|
||||
|
||||
useAddKeybinds(filter, totalCount);
|
||||
useFilteredSidebarKeybinds({
|
||||
showSidebar,
|
||||
setShowSidebar,
|
||||
});
|
||||
|
||||
const onCloseEditDelete = useCloseEditDelete({
|
||||
closeModal,
|
||||
@@ -340,62 +432,95 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
|
||||
];
|
||||
|
||||
// render
|
||||
if (filterLoading) return null;
|
||||
if (filterLoading || sidebarStateLoading) return null;
|
||||
|
||||
return (
|
||||
<TaggerContext>
|
||||
<div className="item-list-container">
|
||||
<div
|
||||
className={cx("item-list-container scene-list", {
|
||||
"hide-sidebar": !showSidebar,
|
||||
})}
|
||||
>
|
||||
{modal}
|
||||
|
||||
<FilteredListToolbar
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
showEditFilter={showEditFilter}
|
||||
view={view}
|
||||
listSelect={listSelect}
|
||||
onEdit={() =>
|
||||
showModal(
|
||||
<EditScenesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onDelete={() => {
|
||||
showModal(
|
||||
<DeleteScenesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
operations={otherOperations}
|
||||
zoomable
|
||||
/>
|
||||
<SidebarPane hideSidebar={!showSidebar}>
|
||||
<Sidebar hide={!showSidebar} onHide={() => setShowSidebar(false)}>
|
||||
<SidebarContent
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
showEditFilter={showEditFilter}
|
||||
view={view}
|
||||
sidebarOpen={showSidebar}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
/>
|
||||
</Sidebar>
|
||||
<div>
|
||||
<FilteredListToolbar
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
showEditFilter={showEditFilter}
|
||||
view={view}
|
||||
listSelect={listSelect}
|
||||
onEdit={() =>
|
||||
showModal(
|
||||
<EditScenesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onDelete={() => {
|
||||
showModal(
|
||||
<DeleteScenesDialog
|
||||
selected={selectedItems}
|
||||
onClose={onCloseEditDelete}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
operations={otherOperations}
|
||||
onToggleSidebar={() => setShowSidebar((v) => !v)}
|
||||
zoomable
|
||||
/>
|
||||
|
||||
<FilterTags
|
||||
criteria={filter.criteria}
|
||||
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
||||
onRemoveCriterion={removeCriterion}
|
||||
onRemoveAll={() => clearAllCriteria()}
|
||||
/>
|
||||
<FilterTags
|
||||
criteria={filter.criteria}
|
||||
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
|
||||
onRemoveCriterion={removeCriterion}
|
||||
onRemoveAll={() => clearAllCriteria()}
|
||||
/>
|
||||
|
||||
<PagedList
|
||||
result={result}
|
||||
cachedResult={cachedResult}
|
||||
filter={filter}
|
||||
totalCount={totalCount}
|
||||
onChangePage={setPage}
|
||||
metadataByline={metadataByline}
|
||||
>
|
||||
<SceneList
|
||||
filter={effectiveFilter}
|
||||
scenes={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
fromGroupId={fromGroupId}
|
||||
/>
|
||||
</PagedList>
|
||||
<PaginationIndex
|
||||
loading={cachedResult.loading}
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
/>
|
||||
|
||||
<LoadedContent loading={result.loading} error={result.error}>
|
||||
<SceneList
|
||||
filter={effectiveFilter}
|
||||
scenes={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelectChange={onSelectChange}
|
||||
fromGroupId={fromGroupId}
|
||||
/>
|
||||
</LoadedContent>
|
||||
|
||||
{totalCount > filter.itemsPerPage && (
|
||||
<div className="pagination-footer">
|
||||
<Pagination
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
metadataByline={metadataByline}
|
||||
onChangePage={setPage}
|
||||
pagePopupPlacement="top"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarPane>
|
||||
</div>
|
||||
</TaggerContext>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import NavUtils from "src/utils/navigation";
|
||||
import { ItemList, ItemListContext } from "../List/ItemList";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
import { MarkerWallPanel } from "../Wall/WallPanel";
|
||||
import { MarkerWallPanel } from "./SceneMarkerWallPanel";
|
||||
import { View } from "../List/views";
|
||||
import { SceneMarkerCardsGrid } from "./SceneMarkerCardsGrid";
|
||||
import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog";
|
||||
|
||||
234
ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx
Normal file
234
ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import Gallery, {
|
||||
GalleryI,
|
||||
PhotoProps,
|
||||
RenderImageProps,
|
||||
} from "react-photo-gallery";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { objectTitle } from "src/core/files";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import { TruncatedText } from "../Shared/TruncatedText";
|
||||
import TextUtils from "src/utils/text";
|
||||
import cx from "classnames";
|
||||
import NavUtils from "src/utils/navigation";
|
||||
import { markerTitle } from "src/core/markers";
|
||||
|
||||
function wallItemTitle(sceneMarker: GQL.SceneMarkerDataFragment) {
|
||||
const newTitle = markerTitle(sceneMarker);
|
||||
const seconds = TextUtils.formatTimestampRange(
|
||||
sceneMarker.seconds,
|
||||
sceneMarker.end_seconds ?? undefined
|
||||
);
|
||||
if (newTitle) {
|
||||
return `${newTitle} - ${seconds}`;
|
||||
} else {
|
||||
return seconds;
|
||||
}
|
||||
}
|
||||
|
||||
interface IMarkerPhoto {
|
||||
marker: GQL.SceneMarkerDataFragment;
|
||||
link: string;
|
||||
onError?: (photo: PhotoProps<IMarkerPhoto>) => void;
|
||||
}
|
||||
|
||||
export const MarkerWallItem: React.FC<RenderImageProps<IMarkerPhoto>> = (
|
||||
props: RenderImageProps<IMarkerPhoto>
|
||||
) => {
|
||||
const { configuration } = useContext(ConfigurationContext);
|
||||
const playSound = configuration?.interface.soundOnPreview ?? false;
|
||||
const showTitle = configuration?.interface.wallShowTitle ?? false;
|
||||
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
type style = Record<string, string | number | undefined>;
|
||||
var divStyle: style = {
|
||||
margin: props.margin,
|
||||
display: "block",
|
||||
};
|
||||
|
||||
if (props.direction === "column") {
|
||||
divStyle.position = "absolute";
|
||||
divStyle.left = props.left;
|
||||
divStyle.top = props.top;
|
||||
}
|
||||
|
||||
var handleClick = function handleClick(event: React.MouseEvent) {
|
||||
if (props.onClick) {
|
||||
props.onClick(event, { index: props.index });
|
||||
}
|
||||
};
|
||||
|
||||
const video = props.photo.src.includes("stream");
|
||||
const ImagePreview = video ? "video" : "img";
|
||||
|
||||
const { marker } = props.photo;
|
||||
const title = wallItemTitle(marker);
|
||||
const tagNames = marker.tags.map((p) => p.name);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx("wall-item", { "show-title": showTitle })}
|
||||
role="button"
|
||||
style={{
|
||||
...divStyle,
|
||||
width: props.photo.width,
|
||||
height: props.photo.height,
|
||||
}}
|
||||
>
|
||||
<ImagePreview
|
||||
loading="lazy"
|
||||
loop={video}
|
||||
muted={!video || !playSound || !active}
|
||||
autoPlay={video}
|
||||
key={props.photo.key}
|
||||
src={props.photo.src}
|
||||
width={props.photo.width}
|
||||
height={props.photo.height}
|
||||
alt={props.photo.alt}
|
||||
onMouseEnter={() => setActive(true)}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
onClick={handleClick}
|
||||
onError={() => {
|
||||
props.photo.onError?.(props.photo);
|
||||
}}
|
||||
/>
|
||||
<div className="lineargradient">
|
||||
<footer className="wall-item-footer">
|
||||
<Link to={props.photo.link} onClick={(e) => e.stopPropagation()}>
|
||||
{title && (
|
||||
<TruncatedText
|
||||
text={title}
|
||||
lineCount={1}
|
||||
className="wall-item-title"
|
||||
/>
|
||||
)}
|
||||
<TruncatedText text={tagNames.join(", ")} />
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IMarkerWallProps {
|
||||
markers: GQL.SceneMarkerDataFragment[];
|
||||
}
|
||||
|
||||
// HACK: typescript doesn't allow Gallery to accept a parameter for some reason
|
||||
const MarkerGallery = Gallery as unknown as GalleryI<IMarkerPhoto>;
|
||||
|
||||
function getFirstValidSrc(srcSet: string[], invalidSrcSet: string[]) {
|
||||
if (!srcSet.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
srcSet.find((src) => !invalidSrcSet.includes(src)) ??
|
||||
([...srcSet].pop() as string)
|
||||
);
|
||||
}
|
||||
|
||||
interface IFile {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function getDimensions(file?: IFile) {
|
||||
const defaults = { width: 1280, height: 720 };
|
||||
|
||||
if (!file) return defaults;
|
||||
|
||||
return {
|
||||
width: file.width || defaults.width,
|
||||
height: file.height || defaults.height,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultTargetRowHeight = 250;
|
||||
|
||||
const MarkerWall: React.FC<IMarkerWallProps> = ({ markers }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const margin = 3;
|
||||
const direction = "row";
|
||||
|
||||
const [erroredImgs, setErroredImgs] = useState<string[]>([]);
|
||||
|
||||
const handleError = useCallback((photo: PhotoProps<IMarkerPhoto>) => {
|
||||
setErroredImgs((prev) => [...prev, photo.src]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setErroredImgs([]);
|
||||
}, [markers]);
|
||||
|
||||
const photos: PhotoProps<IMarkerPhoto>[] = useMemo(() => {
|
||||
return markers.map((m, index) => {
|
||||
const { width = 1280, height = 720 } = getDimensions(m.scene.files[0]);
|
||||
|
||||
return {
|
||||
marker: m,
|
||||
src: getFirstValidSrc([m.stream, m.preview, m.screenshot], erroredImgs),
|
||||
link: NavUtils.makeSceneMarkerUrl(m),
|
||||
width,
|
||||
height,
|
||||
tabIndex: index,
|
||||
key: m.id,
|
||||
loading: "lazy",
|
||||
alt: objectTitle(m),
|
||||
onError: handleError,
|
||||
};
|
||||
});
|
||||
}, [markers, erroredImgs, handleError]);
|
||||
|
||||
const onClick = useCallback(
|
||||
(event, { index }) => {
|
||||
history.push(photos[index].link);
|
||||
},
|
||||
[history, photos]
|
||||
);
|
||||
|
||||
function columns(containerWidth: number) {
|
||||
let preferredSize = 300;
|
||||
let columnCount = containerWidth / preferredSize;
|
||||
return Math.round(columnCount);
|
||||
}
|
||||
|
||||
const renderImage = useCallback((props: RenderImageProps<IMarkerPhoto>) => {
|
||||
return <MarkerWallItem {...props} />;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="marker-wall">
|
||||
{photos.length ? (
|
||||
<MarkerGallery
|
||||
photos={photos}
|
||||
renderImage={renderImage}
|
||||
onClick={onClick}
|
||||
margin={margin}
|
||||
direction={direction}
|
||||
columns={columns}
|
||||
targetRowHeight={defaultTargetRowHeight}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IMarkerWallPanelProps {
|
||||
markers: GQL.SceneMarkerDataFragment[];
|
||||
}
|
||||
|
||||
export const MarkerWallPanel: React.FC<IMarkerWallPanelProps> = ({
|
||||
markers,
|
||||
}) => {
|
||||
return <MarkerWall markers={markers} />;
|
||||
};
|
||||
220
ui/v2.5/src/components/Scenes/SceneWallPanel.tsx
Normal file
220
ui/v2.5/src/components/Scenes/SceneWallPanel.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { SceneQueue } from "src/models/sceneQueue";
|
||||
import Gallery, {
|
||||
GalleryI,
|
||||
PhotoProps,
|
||||
RenderImageProps,
|
||||
} from "react-photo-gallery";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { objectTitle } from "src/core/files";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import { TruncatedText } from "../Shared/TruncatedText";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { useIntl } from "react-intl";
|
||||
import cx from "classnames";
|
||||
|
||||
interface IScenePhoto {
|
||||
scene: GQL.SlimSceneDataFragment;
|
||||
link: string;
|
||||
onError?: (photo: PhotoProps<IScenePhoto>) => void;
|
||||
}
|
||||
|
||||
export const SceneWallItem: React.FC<RenderImageProps<IScenePhoto>> = (
|
||||
props: RenderImageProps<IScenePhoto>
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { configuration } = useContext(ConfigurationContext);
|
||||
const playSound = configuration?.interface.soundOnPreview ?? false;
|
||||
const showTitle = configuration?.interface.wallShowTitle ?? false;
|
||||
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
type style = Record<string, string | number | undefined>;
|
||||
var divStyle: style = {
|
||||
margin: props.margin,
|
||||
display: "block",
|
||||
};
|
||||
|
||||
if (props.direction === "column") {
|
||||
divStyle.position = "absolute";
|
||||
divStyle.left = props.left;
|
||||
divStyle.top = props.top;
|
||||
}
|
||||
|
||||
var handleClick = function handleClick(event: React.MouseEvent) {
|
||||
if (props.onClick) {
|
||||
props.onClick(event, { index: props.index });
|
||||
}
|
||||
};
|
||||
|
||||
const video = props.photo.src.includes("preview");
|
||||
const ImagePreview = video ? "video" : "img";
|
||||
|
||||
const { scene } = props.photo;
|
||||
const title = objectTitle(scene);
|
||||
const performerNames = scene.performers.map((p) => p.name);
|
||||
const performers =
|
||||
performerNames.length >= 2
|
||||
? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")]
|
||||
: performerNames;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx("wall-item", { "show-title": showTitle })}
|
||||
role="button"
|
||||
style={{
|
||||
...divStyle,
|
||||
width: props.photo.width,
|
||||
height: props.photo.height,
|
||||
}}
|
||||
>
|
||||
<ImagePreview
|
||||
loading="lazy"
|
||||
loop={video}
|
||||
muted={!video || !playSound || !active}
|
||||
autoPlay={video}
|
||||
key={props.photo.key}
|
||||
src={props.photo.src}
|
||||
width={props.photo.width}
|
||||
height={props.photo.height}
|
||||
alt={props.photo.alt}
|
||||
onMouseEnter={() => setActive(true)}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
onClick={handleClick}
|
||||
onError={() => {
|
||||
props.photo.onError?.(props.photo);
|
||||
}}
|
||||
/>
|
||||
<div className="lineargradient">
|
||||
<footer className="wall-item-footer">
|
||||
<Link to={props.photo.link} onClick={(e) => e.stopPropagation()}>
|
||||
{title && (
|
||||
<TruncatedText
|
||||
text={title}
|
||||
lineCount={1}
|
||||
className="wall-item-title"
|
||||
/>
|
||||
)}
|
||||
<TruncatedText text={performers.join(", ")} />
|
||||
<div>{scene.date && TextUtils.formatDate(intl, scene.date)}</div>
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getDimensions(s: GQL.SlimSceneDataFragment) {
|
||||
const defaults = { width: 1280, height: 720 };
|
||||
|
||||
if (!s.files.length) return defaults;
|
||||
|
||||
return {
|
||||
width: s.files[0].width || defaults.width,
|
||||
height: s.files[0].height || defaults.height,
|
||||
};
|
||||
}
|
||||
|
||||
interface ISceneWallProps {
|
||||
scenes: GQL.SlimSceneDataFragment[];
|
||||
sceneQueue?: SceneQueue;
|
||||
}
|
||||
|
||||
// HACK: typescript doesn't allow Gallery to accept a parameter for some reason
|
||||
const SceneGallery = Gallery as unknown as GalleryI<IScenePhoto>;
|
||||
|
||||
const defaultTargetRowHeight = 250;
|
||||
|
||||
const SceneWall: React.FC<ISceneWallProps> = ({ scenes, sceneQueue }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const margin = 3;
|
||||
const direction = "row";
|
||||
|
||||
const [erroredImgs, setErroredImgs] = useState<string[]>([]);
|
||||
|
||||
const handleError = useCallback((photo: PhotoProps<IScenePhoto>) => {
|
||||
setErroredImgs((prev) => [...prev, photo.src]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setErroredImgs([]);
|
||||
}, [scenes]);
|
||||
|
||||
const photos: PhotoProps<IScenePhoto>[] = useMemo(() => {
|
||||
return scenes.map((s, index) => {
|
||||
const { width, height } = getDimensions(s);
|
||||
|
||||
return {
|
||||
scene: s,
|
||||
src:
|
||||
s.paths.preview && !erroredImgs.includes(s.paths.preview)
|
||||
? s.paths.preview!
|
||||
: s.paths.screenshot!,
|
||||
link: sceneQueue
|
||||
? sceneQueue.makeLink(s.id, { sceneIndex: index })
|
||||
: `/scenes/${s.id}`,
|
||||
width,
|
||||
height,
|
||||
tabIndex: index,
|
||||
key: s.id,
|
||||
loading: "lazy",
|
||||
alt: objectTitle(s),
|
||||
onError: handleError,
|
||||
};
|
||||
});
|
||||
}, [scenes, sceneQueue, erroredImgs, handleError]);
|
||||
|
||||
const onClick = useCallback(
|
||||
(event, { index }) => {
|
||||
history.push(photos[index].link);
|
||||
},
|
||||
[history, photos]
|
||||
);
|
||||
|
||||
function columns(containerWidth: number) {
|
||||
let preferredSize = 300;
|
||||
let columnCount = containerWidth / preferredSize;
|
||||
return Math.round(columnCount);
|
||||
}
|
||||
|
||||
const renderImage = useCallback((props: RenderImageProps<IScenePhoto>) => {
|
||||
return <SceneWallItem {...props} />;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="scene-wall">
|
||||
{photos.length ? (
|
||||
<SceneGallery
|
||||
photos={photos}
|
||||
renderImage={renderImage}
|
||||
onClick={onClick}
|
||||
margin={margin}
|
||||
direction={direction}
|
||||
columns={columns}
|
||||
targetRowHeight={defaultTargetRowHeight}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISceneWallPanelProps {
|
||||
scenes: GQL.SlimSceneDataFragment[];
|
||||
sceneQueue?: SceneQueue;
|
||||
}
|
||||
|
||||
export const SceneWallPanel: React.FC<ISceneWallPanelProps> = ({
|
||||
scenes,
|
||||
sceneQueue,
|
||||
}) => {
|
||||
return <SceneWall scenes={scenes} sceneQueue={sceneQueue} />;
|
||||
};
|
||||
@@ -561,7 +561,7 @@ input[type="range"].blue-slider {
|
||||
}
|
||||
|
||||
.scene-markers-panel {
|
||||
.wall-item {
|
||||
.wall .wall-item {
|
||||
height: inherit;
|
||||
min-height: 14rem;
|
||||
width: calc(100% - 2rem);
|
||||
@@ -901,3 +901,105 @@ input[type="range"].blue-slider {
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.scene-list .filtered-list-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 1rem;
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
&:first-child {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
&:nth-child(2) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scene-list.hide-sidebar .sidebar-toggle-button {
|
||||
transition-delay: 0.1s;
|
||||
transition-duration: 0;
|
||||
transition-property: opacity;
|
||||
}
|
||||
|
||||
.scene-list:not(.hide-sidebar) .sidebar-toggle-button {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.scene-wall,
|
||||
.marker-wall {
|
||||
.wall-item {
|
||||
position: relative;
|
||||
|
||||
.lineargradient {
|
||||
background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3));
|
||||
bottom: 0;
|
||||
height: 100px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&-footer {
|
||||
bottom: 20px;
|
||||
padding: 0 1rem;
|
||||
position: absolute;
|
||||
text-shadow: 1px 1px 3px black;
|
||||
transition: 0s opacity;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.wall-item-title {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .wall-item-footer {
|
||||
opacity: 1;
|
||||
transition: 1s opacity;
|
||||
transition-delay: 500ms;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.show-title .wall-item-footer {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,6 @@ export const StashBoxModal: React.FC<IStashBoxModal> = ({ value, close }) => {
|
||||
max_requests_per_minute: parseInt(e.currentTarget.value),
|
||||
})
|
||||
}
|
||||
ref={apiKey}
|
||||
/>
|
||||
<div className="sub-heading">
|
||||
<FormattedMessage
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
import React from "react";
|
||||
import { Button, Modal } from "react-bootstrap";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
export interface IAlertModalProps {
|
||||
text: JSX.Element | string;
|
||||
confirmVariant?: string;
|
||||
show?: boolean;
|
||||
confirmButtonText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const AlertModal: React.FC<IAlertModalProps> = ({
|
||||
text,
|
||||
show,
|
||||
confirmButtonText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
return (
|
||||
<Modal show={show}>
|
||||
<Modal.Body>{text}</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="danger" onClick={() => onConfirm()}>
|
||||
{confirmButtonText ?? <FormattedMessage id="actions.confirm" />}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => onCancel()}>
|
||||
<FormattedMessage id="actions.cancel" />
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
export const AlertModal: React.FC<IAlertModalProps> = PatchComponent(
|
||||
"AlertModal",
|
||||
({
|
||||
text,
|
||||
show,
|
||||
confirmVariant = "danger",
|
||||
confirmButtonText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
return (
|
||||
<Modal show={show}>
|
||||
<Modal.Body>{text}</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant={confirmVariant} onClick={() => onConfirm()}>
|
||||
{confirmButtonText ?? <FormattedMessage id="actions.confirm" />}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => onCancel()}>
|
||||
<FormattedMessage id="actions.cancel" />
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -39,6 +39,12 @@ export const ClearableInput: React.FC<IClearableInput> = ({
|
||||
setQueryFocus();
|
||||
}
|
||||
|
||||
function onInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Escape") {
|
||||
queryRef.current?.blur();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx("clearable-input-group", className)}>
|
||||
<FormControl
|
||||
@@ -46,6 +52,7 @@ export const ClearableInput: React.FC<IClearableInput> = ({
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onInput={onChangeQuery}
|
||||
onKeyDown={onInputKeyDown}
|
||||
className="clearable-text-field"
|
||||
/>
|
||||
{queryClearShowing && (
|
||||
|
||||
@@ -4,12 +4,15 @@ import {
|
||||
faChevronUp,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, { useState } from "react";
|
||||
import { Button, Collapse } from "react-bootstrap";
|
||||
import { Button, Collapse, CollapseProps } from "react-bootstrap";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface IProps {
|
||||
className?: string;
|
||||
text: React.ReactNode;
|
||||
collapseProps?: Partial<CollapseProps>;
|
||||
outsideCollapse?: React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (
|
||||
@@ -17,16 +20,27 @@ export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (
|
||||
) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
function toggleOpen() {
|
||||
const nv = !open;
|
||||
setOpen(nv);
|
||||
if (props.onOpen && nv) {
|
||||
props.onOpen();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<Button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="minimal collapse-button"
|
||||
>
|
||||
<Icon icon={open ? faChevronDown : faChevronRight} fixedWidth />
|
||||
<span>{props.text}</span>
|
||||
</Button>
|
||||
<Collapse in={open}>
|
||||
<div className="collapse-header">
|
||||
<Button
|
||||
onClick={() => toggleOpen()}
|
||||
className="minimal collapse-button"
|
||||
>
|
||||
<Icon icon={open ? faChevronDown : faChevronRight} fixedWidth />
|
||||
<span>{props.text}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{props.outsideCollapse}
|
||||
<Collapse in={open} {...props.collapseProps}>
|
||||
<div>{props.children}</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cloneDeep } from "@apollo/client/utilities";
|
||||
import { Icon } from "./Icon";
|
||||
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import cx from "classnames";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
const maxFieldNameLength = 64;
|
||||
|
||||
@@ -53,23 +54,28 @@ const CustomField: React.FC<{ field: string; value: unknown }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomFields: React.FC<ICustomFields> = ({ values }) => {
|
||||
const intl = useIntl();
|
||||
if (Object.keys(values).length === 0) {
|
||||
return null;
|
||||
}
|
||||
export const CustomFields: React.FC<ICustomFields> = PatchComponent(
|
||||
"CustomFields",
|
||||
({ values }) => {
|
||||
const intl = useIntl();
|
||||
if (Object.keys(values).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
// according to linter rule CSS classes shouldn't use underscores
|
||||
<div className="custom-fields">
|
||||
<CollapseButton text={intl.formatMessage({ id: "custom_fields.title" })}>
|
||||
{Object.entries(values).map(([key, value]) => (
|
||||
<CustomField key={key} field={key} value={value} />
|
||||
))}
|
||||
</CollapseButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
// according to linter rule CSS classes shouldn't use underscores
|
||||
<div className="custom-fields">
|
||||
<CollapseButton
|
||||
text={intl.formatMessage({ id: "custom_fields.title" })}
|
||||
>
|
||||
{Object.entries(values).map(([key, value]) => (
|
||||
<CustomField key={key} field={key} value={value} />
|
||||
))}
|
||||
</CollapseButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function isNumeric(v: string) {
|
||||
return /^-?(?:0|(?:[1-9][0-9]*))(?:\.[0-9]+)?$/.test(v);
|
||||
@@ -90,76 +96,85 @@ const CustomFieldInput: React.FC<{
|
||||
onChange: (field: string, value: unknown) => void;
|
||||
isNew?: boolean;
|
||||
error?: string;
|
||||
}> = ({ field, value, onChange, isNew = false, error }) => {
|
||||
const intl = useIntl();
|
||||
const [currentField, setCurrentField] = useState(field);
|
||||
const [currentValue, setCurrentValue] = useState(value as string);
|
||||
}> = PatchComponent(
|
||||
"CustomFieldInput",
|
||||
({ field, value, onChange, isNew = false, error }) => {
|
||||
const intl = useIntl();
|
||||
const [currentField, setCurrentField] = useState(field);
|
||||
const [currentValue, setCurrentValue] = useState(value as string);
|
||||
|
||||
const fieldRef = useRef<HTMLInputElement>(null);
|
||||
const valueRef = useRef<HTMLInputElement>(null);
|
||||
const fieldRef = useRef<HTMLInputElement>(null);
|
||||
const valueRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentField(field);
|
||||
setCurrentValue(value as string);
|
||||
}, [field, value]);
|
||||
useEffect(() => {
|
||||
setCurrentField(field);
|
||||
setCurrentValue(value as string);
|
||||
}, [field, value]);
|
||||
|
||||
function onBlur() {
|
||||
onChange(currentField, convertCustomValue(currentValue));
|
||||
}
|
||||
function onBlur() {
|
||||
onChange(currentField, convertCustomValue(currentValue));
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
onChange("", "");
|
||||
}
|
||||
function onDelete() {
|
||||
onChange("", "");
|
||||
}
|
||||
|
||||
return (
|
||||
<FormGroup>
|
||||
<Row className={cx("custom-fields-row", { "custom-fields-new": isNew })}>
|
||||
<Col sm={3} xl={2} className="custom-fields-field">
|
||||
{isNew ? (
|
||||
<>
|
||||
return (
|
||||
<FormGroup>
|
||||
<Row
|
||||
className={cx("custom-fields-row", { "custom-fields-new": isNew })}
|
||||
>
|
||||
<Col sm={3} xl={2} className="custom-fields-field">
|
||||
{isNew ? (
|
||||
<>
|
||||
<Form.Control
|
||||
ref={fieldRef}
|
||||
className="input-control"
|
||||
type="text"
|
||||
value={currentField ?? ""}
|
||||
placeholder={intl.formatMessage({
|
||||
id: "custom_fields.field",
|
||||
})}
|
||||
onChange={(event) =>
|
||||
setCurrentField(event.currentTarget.value)
|
||||
}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Form.Label title={currentField}>{currentField}</Form.Label>
|
||||
)}
|
||||
</Col>
|
||||
<Col sm={9} xl={7}>
|
||||
<InputGroup>
|
||||
<Form.Control
|
||||
ref={fieldRef}
|
||||
ref={valueRef}
|
||||
className="input-control"
|
||||
type="text"
|
||||
value={currentField ?? ""}
|
||||
placeholder={intl.formatMessage({ id: "custom_fields.field" })}
|
||||
onChange={(event) => setCurrentField(event.currentTarget.value)}
|
||||
value={(currentValue as string) ?? ""}
|
||||
placeholder={currentField}
|
||||
onChange={(event) => setCurrentValue(event.currentTarget.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Form.Label title={currentField}>{currentField}</Form.Label>
|
||||
)}
|
||||
</Col>
|
||||
<Col sm={9} xl={7}>
|
||||
<InputGroup>
|
||||
<Form.Control
|
||||
ref={valueRef}
|
||||
className="input-control"
|
||||
type="text"
|
||||
value={(currentValue as string) ?? ""}
|
||||
placeholder={currentField}
|
||||
onChange={(event) => setCurrentValue(event.currentTarget.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<InputGroup.Append>
|
||||
{!isNew && (
|
||||
<Button
|
||||
className="custom-fields-remove"
|
||||
variant="danger"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
<Icon icon={faMinus} />
|
||||
</Button>
|
||||
)}
|
||||
</InputGroup.Append>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Control.Feedback type="invalid">{error}</Form.Control.Feedback>
|
||||
</FormGroup>
|
||||
);
|
||||
};
|
||||
<InputGroup.Append>
|
||||
{!isNew && (
|
||||
<Button
|
||||
className="custom-fields-remove"
|
||||
variant="danger"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
<Icon icon={faMinus} />
|
||||
</Button>
|
||||
)}
|
||||
</InputGroup.Append>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Control.Feedback type="invalid">{error}</Form.Control.Feedback>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface ICustomField {
|
||||
field: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { remToPx } from "src/utils/units";
|
||||
|
||||
const DEFAULT_WIDTH = Math.round(remToPx(30));
|
||||
@@ -6,34 +7,37 @@ const DEFAULT_WIDTH = Math.round(remToPx(30));
|
||||
// Props used by the <img> element
|
||||
type IDetailImageProps = JSX.IntrinsicElements["img"];
|
||||
|
||||
export const DetailImage = (props: IDetailImageProps) => {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
export const DetailImage = PatchComponent(
|
||||
"DetailImage",
|
||||
(props: IDetailImageProps) => {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
function fixWidth() {
|
||||
const img = imgRef.current;
|
||||
if (!img) return;
|
||||
function fixWidth() {
|
||||
const img = imgRef.current;
|
||||
if (!img) return;
|
||||
|
||||
// prevent SVG's w/o intrinsic size from rendering as 0x0
|
||||
if (img.naturalWidth === 0) {
|
||||
// If the naturalWidth is zero, it means the image either hasn't loaded yet
|
||||
// or we're on Firefox and it is an SVG w/o an intrinsic size.
|
||||
// So set the width to our fallback width.
|
||||
img.setAttribute("width", String(DEFAULT_WIDTH));
|
||||
} else {
|
||||
// If we have a `naturalWidth`, this could either be the actual intrinsic width
|
||||
// of the image, or the image is an SVG w/o an intrinsic size and we're on Chrome or Safari,
|
||||
// which seem to return a size calculated in some browser-specific way.
|
||||
// Worse yet, once rendered, Safari will then return the value of `img.width` as `img.naturalWidth`,
|
||||
// so we need to clone the image to disconnect it from the DOM, and then get the `naturalWidth` of the clone,
|
||||
// in order to always return the same `naturalWidth` for a given src.
|
||||
const i = img.cloneNode() as HTMLImageElement;
|
||||
img.setAttribute("width", String(i.naturalWidth || DEFAULT_WIDTH));
|
||||
// prevent SVG's w/o intrinsic size from rendering as 0x0
|
||||
if (img.naturalWidth === 0) {
|
||||
// If the naturalWidth is zero, it means the image either hasn't loaded yet
|
||||
// or we're on Firefox and it is an SVG w/o an intrinsic size.
|
||||
// So set the width to our fallback width.
|
||||
img.setAttribute("width", String(DEFAULT_WIDTH));
|
||||
} else {
|
||||
// If we have a `naturalWidth`, this could either be the actual intrinsic width
|
||||
// of the image, or the image is an SVG w/o an intrinsic size and we're on Chrome or Safari,
|
||||
// which seem to return a size calculated in some browser-specific way.
|
||||
// Worse yet, once rendered, Safari will then return the value of `img.width` as `img.naturalWidth`,
|
||||
// so we need to clone the image to disconnect it from the DOM, and then get the `naturalWidth` of the clone,
|
||||
// in order to always return the same `naturalWidth` for a given src.
|
||||
const i = img.cloneNode() as HTMLImageElement;
|
||||
img.setAttribute("width", String(i.naturalWidth || DEFAULT_WIDTH));
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
fixWidth();
|
||||
}, [props.src]);
|
||||
|
||||
return <img ref={imgRef} onLoad={() => fixWidth()} {...props} />;
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
fixWidth();
|
||||
}, [props.src]);
|
||||
|
||||
return <img ref={imgRef} onLoad={() => fixWidth()} {...props} />;
|
||||
};
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from "react";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
export const BackgroundImage: React.FC<{
|
||||
imagePath: string | undefined;
|
||||
show: boolean;
|
||||
alt?: string;
|
||||
}> = ({ imagePath, show, alt }) => {
|
||||
}> = PatchComponent("BackgroundImage", ({ imagePath, show, alt }) => {
|
||||
if (imagePath && show) {
|
||||
const imageURL = new URL(imagePath);
|
||||
let isDefaultImage = imageURL.searchParams.get("default");
|
||||
@@ -21,4 +22,4 @@ export const BackgroundImage: React.FC<{
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
import { LoadingIndicator } from "../LoadingIndicator";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
export const HeaderImage: React.FC<
|
||||
PropsWithChildren<{
|
||||
encodingImage: boolean;
|
||||
}>
|
||||
> = ({ encodingImage, children }) => {
|
||||
> = PatchComponent("HeaderImage", ({ encodingImage, children }) => {
|
||||
return (
|
||||
<div className="detail-header-image">
|
||||
{encodingImage ? (
|
||||
@@ -18,4 +19,4 @@ export const HeaderImage: React.FC<
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user