Compare commits

...

43 Commits

Author SHA1 Message Date
DogmaDragon
29d8f0cbc4 Clarify naming convention 2025-06-24 21:29:35 +03:00
DogmaDragon
d8552ec9ca Update captions documentation to clarify file location and naming conventions 2025-06-24 19:19:09 +03:00
WithoutPants
704041d5e0 Add findFiles and findFile graphql queries (#5941)
* Add findFile and findFiles
* Add parent folder and zip file fields to file graphql types
* Add parent_folder, zip_file fields to Folder graphql type
* Add format to ImageFile type
* Add format filter fields to image/video file filters
2025-06-24 13:05:17 +10:00
damontecres
8d78fd682d Include searching by tag sort name (#5963) 2025-06-24 13:02:19 +10:00
WithoutPants
81c3988777 Give bottom pagination bar transparent background (#5958) 2025-06-24 13:01:28 +10:00
WithoutPants
4b5424dd51 Update manual with new patchable components 2025-06-24 08:27:41 +10:00
dogwithakeyboard
e69238307c add missing property to death date item (#5962) 2025-06-24 07:59:27 +10:00
feederbox826
019fe81de9 Update Freeones scraper from CommunityScrapers (#5956)
1b103ad2d5

Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
2025-06-23 14:13:01 +10:00
WithoutPants
5177f71dbd Fix UI crash in performer -> gallery wall lightbox (#5947) 2025-06-23 14:12:07 +10:00
WithoutPants
497146adc5 Support patching Pagination and PaginationIndex (#5957) 2025-06-23 14:11:51 +10:00
WithoutPants
f81f60e76f Show custom fields on compact expanded details (#5946) 2025-06-20 16:04:10 +10:00
WithoutPants
849a368d3d Fix ordering of tags (#5945) 2025-06-20 16:03:56 +10:00
QxxxGit
c09913a614 Add useLightbox and useGalleryLightbox in plugin api (#5936) 2025-06-20 16:03:22 +10:00
WithoutPants
c5fe6748c0 Hide list view options popover on select (#5940) 2025-06-19 16:40:06 +10:00
WithoutPants
fe9a6d87d2 Fix filtered list toolbar overflow on mobile devices (#5933)
Scenes list page is still ugly, but that will be addressed separately.
2025-06-17 12:30:28 +10:00
WithoutPants
7d692232ed Move pagination to a sticky bottom toolbar on scenes page (#5924)
* Adjust main padding to be the same as navbar height
* Add LoadedContent component for loading and error display
* Add option for pagination popup placement
* Show results summary at top only. Add sticky bottom pagination
2025-06-17 11:00:00 +10:00
WithoutPants
a145576f39 Display mode options dropdown (#5923)
* Separate ZoomSlider into own component
* Turn ListViewOptions into dropdown
Also puts zoom slider in the dropdown
* Move ZoomSlider into separate file
* Add title
* Restyle slider
2025-06-13 11:45:10 +10:00
WithoutPants
574fd680c9 Filter performers/tags/studios list by current filter (#5920) 2025-06-13 09:07:11 +10:00
QxxxGit
e95c1bbc76 Patched AlertModal, SweatDrops, TruncatedText, BackgroundImage components (#5913)
* Patched AlertModal, SweatDrops, TruncatedText
* Patch BackgroundImage component
* Inline PatchComponent calls
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2025-06-11 17:32:36 +10:00
Rémi Marseault
155dbc370b fix: Prevent generating invalid link on empty scraper response (#5876) 2025-06-11 17:32:11 +10:00
philMorel
60f1ee2360 feat: Add Performers tab to Group detail page (#5895)
* Feat(#1401): Show all performers from group's scenes on group detail
* Add Groups criterion to performers
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2025-06-11 17:07:09 +10:00
WithoutPants
3d03072da0 Error loading plugins (#5813)
* Improve error messages when unable to contact server
* Improve error message presentation
* Catch errors when configuration can't be loaded
* Use ErrorMessage in PagedList
* Add icon to error message
2025-06-11 16:54:11 +10:00
WithoutPants
ed4d17b8f0 Scene Filter sidebar (#5714)
* Add Sidebar component
* Add PerformerQuickFilter to Scene filter sidebar
* Add other quick filters
* Add confirmVariant field to AlertModal
* Add SidebarSavedFilterList
* Add sidebar toggle button
* Add data-type attr for criterion option
* Refactor LabeledIdFilter
* Move search input into sidebar
* Save sidebar state in local forage
* Add sidebar rating filter
* Add organised filter
* Open sidebar to / key. Focus search input on sidebar open
* Blur clearable input on escape key
2025-06-11 15:55:10 +10:00
smith113-p
a91b9c4d92 Slightly simplify code after PR #5894 (#5917)
The code looks like it does because it initially used string pointers; however, the version that landed used a regular string array, so we can just the = operator.
2025-06-11 11:49:47 +10:00
QxxxGit
709fdb14de Rating system patched components (#5912) 2025-06-11 11:46:05 +10:00
CJ
46b0b8cba4 Patch CustomFields Component (#5914) 2025-06-11 11:45:03 +10:00
WithoutPants
815ce7139c Add handler for /plugin/{}/assets (#5907)
This allows for React applications to be hosted in a plugins asset directory.
2025-06-03 20:35:29 +10:00
feederbox826
358193e25e Add note for macOS restriction (#5906) 2025-06-03 10:59:10 +10:00
dependabot[bot]
4aca81ad9b Bump vite from 4.5.13 to 4.5.14 in /ui/v2.5 (#5902)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.5.13 to 4.5.14.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.5.14/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.5.14/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 4.5.14
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-03 10:21:47 +10:00
CJ
c66ef42480 New patchable performer page components (#5897) 2025-06-03 10:16:57 +10:00
DirtyRacer1337
d9a316d083 add explorer plugin (#5882) 2025-06-03 10:13:14 +10:00
smith113-p
96d2b36a08 Submit all scene URLs to Stashbox (#5894) 2025-06-03 10:05:43 +10:00
WithoutPants
00f5d0d984 Upgrade gqlgenc (#5901)
* Update gqlgenc
* Fix type error
* Fix package names in config
* Remove override and regenerate
* Update compiler and bump version
2025-06-03 08:55:51 +10:00
QxxxGit
044ed2708f Gallery card patched component (#5880)
* Gallery card patched component
* Define in pluginApi.d.ts
2025-06-02 17:20:34 +10:00
WithoutPants
8e697b50eb Revamp scene and marker wall views (#5816)
* Use gallery for scene wall
* Move into separate file
* Remove unnecessary class names
* Apply configuration
* Reuse styling
* Add Scene Marker wall panel
* Adjust target row height
2025-06-02 17:18:36 +10:00
DogmaDragon
5ea4c507b2 docs: Update scraper objects (#5794) 2025-06-02 17:16:42 +10:00
dogwithakeyboard
10d4fcce8d Add zoomIndex to gallery card (#5844) 2025-06-02 17:15:23 +10:00
Rémi Marseault
86848e7d70 feat(onDelete): Redirect to previous page to preserve filters (#5818) 2025-06-02 17:12:17 +10:00
dependabot[bot]
91ac2833f5 Bump vite from 4.5.11 to 4.5.13 in /ui/v2.5 (#5847)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.5.11 to 4.5.13.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.5.13/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.5.13/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 4.5.13
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-02 17:10:50 +10:00
DogmaDragon
8ecbf4f7e4 Update tripwire link to direct to the forum (#5885) 2025-06-02 17:07:55 +10:00
smith113-p
0bd4edd9f4 Use StashIDPill to show stash IDs in the tagger view (#5879)
* Use StashIDPill to show stash IDs in the tagger view

This is visually nicer, but more importantly, lets you see easily which stash-boxes are already associated with this scene.

* Move into separate component. Add key
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2025-06-02 17:07:24 +10:00
Maista
af34829f38 Stash box validation bugfix (#5831)
* Remove accidental copypaste error

The apiKey ref was accidentally associated with the max_requests_per_minute field which made the "Test Credentials" button error out every time

* Fix error messages in stash-box validation

The message from err.Error() can start with any number of errors like NetworkError
so we can check for substrings instead
2025-06-02 15:47:03 +10:00
DogmaDragon
155c4ec72a docs: Add note on Chrome 136 requirements for remote debugging (#5884) 2025-05-23 16:07:15 +03:00
139 changed files with 7409 additions and 1707 deletions

View File

@@ -12,7 +12,7 @@ concurrency:
cancel-in-progress: true
env:
COMPILER_IMAGE: stashapp/compiler:10
COMPILER_IMAGE: stashapp/compiler:11
jobs:
build:

View File

@@ -9,7 +9,7 @@ on:
pull_request:
env:
COMPILER_IMAGE: stashapp/compiler:10
COMPILER_IMAGE: stashapp/compiler:11
jobs:
golangci:

View File

@@ -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.

View File

@@ -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>

View File

@@ -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/

View File

@@ -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/

View File

@@ -1,4 +1,4 @@
FROM golang:1.22.8
FROM golang:1.24.3
LABEL maintainer="https://discord.gg/2TsNFKt"

View File

@@ -1,6 +1,6 @@
user=stashapp
repo=compiler
version=10
version=11
latest:
docker build -t ${user}/${repo}:latest .

36
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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

View File

@@ -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

View File

@@ -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!]!
}

View File

@@ -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

View File

@@ -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!]!
}

View File

@@ -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
View 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
}

View File

@@ -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 {

View 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)
}

View File

@@ -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
}

View File

@@ -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 }

View File

@@ -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)
}

View 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)
}

View File

@@ -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

View File

@@ -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)

View 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
}

View File

@@ -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)

View File

@@ -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())

View File

@@ -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)
}

View File

@@ -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

View 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"`
}

View File

@@ -106,7 +106,7 @@ type ImageQueryOptions struct {
}
type ImageQueryResult struct {
QueryResult
QueryResult[int]
Megapixels float64
TotalSize float64

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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

View File

@@ -5,7 +5,7 @@ type QueryOptions struct {
Count bool
}
type QueryResult struct {
IDs []int
type QueryResult[T comparable] struct {
IDs []T
Count int
}

View File

@@ -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.

View File

@@ -126,7 +126,7 @@ type SceneQueryOptions struct {
}
type SceneQueryResult struct {
QueryResult
QueryResult[int]
TotalDuration float64
TotalSize float64

View File

@@ -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{

View File

@@ -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 {

View File

@@ -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())
}

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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
View 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"),
&timestampCriterionHandler{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),
&timestampCriterionHandler{fileFilter.CreatedAt, "files.created_at", nil},
&timestampCriterionHandler{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")
}

View 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))
}
})
}
}

View File

@@ -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))

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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)
}

View File

@@ -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())
}
}()

View File

@@ -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)
}

View File

@@ -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\""

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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"
}

View File

@@ -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>
);
};

View File

@@ -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}
/>
);
}
);

View File

@@ -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();
}
}

View File

@@ -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) {

View File

@@ -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}
/>
);
};

View File

@@ -156,7 +156,7 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
function onDeleteDialogClosed(deleted: boolean) {
setIsDeleteAlertOpen(false);
if (deleted) {
history.push("/images");
history.goBack();
}
}

View File

@@ -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>
);
};

View File

@@ -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
/>
</>
);
};

View File

@@ -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">

View 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]);
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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>
</>
);
};

View 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 });
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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>
</>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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,

View File

@@ -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" })}
>
&lt;
</Button>
{pageButtons}
<Button
variant="secondary"
disabled={currentPage === totalPages}
onClick={() => onChangePage(currentPage + 1)}
title={intl.formatMessage({ id: "pagination.next" })}
>
&gt;
</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" })}
>
&lt;
</Button>
{pageButtons}
<Button
variant="secondary"
disabled={currentPage === totalPages}
onClick={() => onChangePage(currentPage + 1)}
title={intl.formatMessage({ id: "pagination.next" })}
>
&gt;
</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>
);
}
);

View File

@@ -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={

View 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();
}}
/>
);
};

View File

@@ -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;
}
}
}

View File

@@ -32,4 +32,5 @@ export enum View {
GroupScenes = "group_scenes",
GroupSubGroups = "group_sub_groups",
GroupPerformers = "group_performers",
}

View File

@@ -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

View File

@@ -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>
);
});

View File

@@ -909,7 +909,7 @@ const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
) {
loadScene(queueScenes[currentQueueIndex + 1].id);
} else {
history.push("/scenes");
history.goBack();
}
}

View File

@@ -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>;

View File

@@ -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>
);

View File

@@ -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";

View 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} />;
};

View 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} />;
};

View File

@@ -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;
}
}
}

View File

@@ -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

View File

@@ -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>
);
}
);

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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;

View File

@@ -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} />;
};
);

View File

@@ -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;
};
});

View File

@@ -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