mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Remove v2 UI (#613)
This commit is contained in:
parent
a7ac02fb50
commit
1ca5f357e9
142 changed files with 0 additions and 27537 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -20,7 +20,6 @@
|
|||
|
||||
# GraphQL generated output
|
||||
pkg/models/generated_*.go
|
||||
ui/v2/src/core/generated-*.tsx
|
||||
ui/v2.5/src/core/generated-*.tsx
|
||||
|
||||
# packr generated files
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
BROWSER=none
|
||||
23
ui/v2/.gitignore
vendored
23
ui/v2/.gitignore
vendored
|
|
@ -1,23 +0,0 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
18
ui/v2/.vscode/launch.json
vendored
18
ui/v2/.vscode/launch.json
vendored
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Chrome",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}/src",
|
||||
"sourceMapPathOverrides": {
|
||||
"webpack:///src/*": "${webRoot}/*"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
10
ui/v2/.vscode/settings.json
vendored
10
ui/v2/.vscode/settings.json
vendored
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.tabSize": 2,
|
||||
"editor.renderWhitespace": "boundary",
|
||||
"editor.wordWrap": "bounded",
|
||||
"javascript.preferences.importModuleSpecifier": "relative",
|
||||
"typescript.preferences.importModuleSpecifier": "relative",
|
||||
"editor.wordWrapColumn": 120,
|
||||
"editor.rulers": [120]
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
* Install gulp `yarn global add gulp`
|
||||
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.<br>
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.<br>
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.<br>
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.<br>
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.<br>
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
overwrite: true
|
||||
schema: "../../graphql/schema/**/*.graphql"
|
||||
documents: "../../graphql/documents/**/*.graphql"
|
||||
generates:
|
||||
src/core/generated-graphql.tsx:
|
||||
config:
|
||||
noNamespaces: true
|
||||
optionalType: "undefined"
|
||||
noHOC: true
|
||||
noComponents: true
|
||||
withHooks: true
|
||||
plugins:
|
||||
- add: "/* tslint:disable */"
|
||||
- add: "/* eslint-disable */"
|
||||
- time
|
||||
- "typescript-common"
|
||||
- "typescript-client"
|
||||
- "typescript-react-apollo"
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
{
|
||||
"name": "stash",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "3.15.1",
|
||||
"@blueprintjs/select": "3.8.0",
|
||||
"@types/jest": "24.0.13",
|
||||
"@types/lodash": "4.14.132",
|
||||
"@types/node": "11.13.0",
|
||||
"@types/query-string": "6.3.0",
|
||||
"@types/react": "16.8.18",
|
||||
"@types/react-dom": "16.8.4",
|
||||
"@types/react-router-dom": "4.3.3",
|
||||
"@types/video.js": "^7.2.11",
|
||||
"apollo-boost": "0.4.0",
|
||||
"apollo-link-ws": "^1.0.19",
|
||||
"axios": "0.18.1",
|
||||
"bulma": "0.7.5",
|
||||
"formik": "1.5.7",
|
||||
"graphql": "14.3.1",
|
||||
"localforage": "1.7.3",
|
||||
"lodash": "4.17.13",
|
||||
"node-sass": "4.12.0",
|
||||
"query-string": "6.5.0",
|
||||
"react": "16.8.6",
|
||||
"react-apollo": "2.5.6",
|
||||
"react-apollo-hooks": "0.4.5",
|
||||
"react-dom": "16.8.6",
|
||||
"react-images": "0.5.19",
|
||||
"react-jw-player": "1.19.0",
|
||||
"react-photo-gallery": "7.0.2",
|
||||
"react-router-dom": "5.0.0",
|
||||
"react-scripts": "3.3.0",
|
||||
"react-use": "9.1.2",
|
||||
"subscriptions-transport-ws": "^0.9.16"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"lint": "tslint -c ./tslint.json 'src/**/*.{ts,tsx}'",
|
||||
"lint:fix": "tslint --fix -c ./tslint.json 'src/**/*.{ts,tsx}'",
|
||||
"gqlgen": "gql-gen --config codegen.yml"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all"
|
||||
],
|
||||
"devDependencies": {
|
||||
"graphql-code-generator": "0.18.2",
|
||||
"graphql-codegen-add": "0.18.2",
|
||||
"graphql-codegen-time": "0.18.2",
|
||||
"graphql-codegen-typescript-client": "0.18.2",
|
||||
"graphql-codegen-typescript-common": "0.18.2",
|
||||
"graphql-codegen-typescript-react-apollo": "0.18.2",
|
||||
"tslint": "5.16.0",
|
||||
"tslint-react": "4.0.0",
|
||||
"typescript": "3.4.5"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB |
|
|
@ -1,41 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Stash</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,92 +0,0 @@
|
|||
JW Player version 8.11.5
|
||||
Copyright (c) 2019, JW Player, All Rights Reserved
|
||||
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
|
||||
|
||||
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
|
||||
https://www.jwplayer.com/tos/
|
||||
|
||||
This product includes portions of other software. For the full text of licenses, see below:
|
||||
|
||||
JW Player Third Party Software Notices and/or Additional Terms and Conditions
|
||||
|
||||
**************************************************************************************************
|
||||
The following software is used under Apache License 2.0
|
||||
**************************************************************************************************
|
||||
|
||||
vtt.js v0.13.0
|
||||
Copyright (c) 2019 Mozilla (http://mozilla.org)
|
||||
https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
|
||||
|
||||
* * *
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
|
||||
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
**************************************************************************************************
|
||||
The following software is used under MIT license
|
||||
**************************************************************************************************
|
||||
|
||||
Underscore.js v1.6.0
|
||||
Copyright (c) 2009-2014 Jeremy Ashkenas, DocumentCloud and Investigative
|
||||
https://github.com/jashkenas/underscore/blob/1.6.0/LICENSE
|
||||
|
||||
Backbone backbone.events.js v1.1.2
|
||||
Copyright (c) 2010-2014 Jeremy Ashkenas, DocumentCloud
|
||||
https://github.com/jashkenas/backbone/blob/1.1.2/LICENSE
|
||||
|
||||
Promise Polyfill v7.1.1
|
||||
Copyright (c) 2014 Taylor Hakes and Forbes Lindesay
|
||||
https://github.com/taylorhakes/promise-polyfill/blob/v7.1.1/LICENSE
|
||||
|
||||
can-autoplay.js v3.0.0
|
||||
Copyright (c) 2017 video-dev
|
||||
https://github.com/video-dev/can-autoplay/blob/v3.0.0/LICENSE
|
||||
|
||||
* * *
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
**************************************************************************************************
|
||||
The following software is used under W3C license
|
||||
**************************************************************************************************
|
||||
|
||||
Intersection Observer v0.5.0
|
||||
Copyright (c) 2016 Google Inc. (http://google.com)
|
||||
https://github.com/w3c/IntersectionObserver/blob/v0.5.0/LICENSE.md
|
||||
|
||||
* * *
|
||||
|
||||
W3C SOFTWARE AND DOCUMENT NOTICE AND LICENSE
|
||||
Status: This license takes effect 13 May, 2015.
|
||||
|
||||
This work is being provided by the copyright holders under the following license.
|
||||
|
||||
License
|
||||
By obtaining and/or copying this work, you (the licensee) agree that you have read, understood, and will comply with the following terms and conditions.
|
||||
|
||||
Permission to copy, modify, and distribute this work, with or without modification, for any purpose and without fee or royalty is hereby granted, provided that you include the following on ALL copies of the work or portions thereof, including modifications:
|
||||
|
||||
The full text of this NOTICE in a location viewable to users of the redistributed or derivative work.
|
||||
|
||||
Any pre-existing intellectual property disclaimers, notices, or terms and conditions. If none exist, the W3C Software and Document Short Notice should be included.
|
||||
|
||||
Notice of any changes or modifications, through a copyright statement on the new code or document such as "This software or document includes material copied from or derived from [title and URI of the W3C document]. Copyright © [YEAR] W3C® (MIT, ERCIM, Keio, Beihang)."
|
||||
|
||||
Disclaimers
|
||||
THIS WORK IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE OR DOCUMENT WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
|
||||
|
||||
COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENT.
|
||||
|
||||
The name and trademarks of copyright holders may NOT be used in advertising or publicity pertaining to the work without specific, written prior permission. Title to copyright in this work will at all times remain with copyright holders.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,95 +0,0 @@
|
|||
/*!
|
||||
JW Player version 8.11.5
|
||||
Copyright (c) 2019, JW Player, All Rights Reserved
|
||||
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
|
||||
|
||||
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
|
||||
https://www.jwplayer.com/tos/
|
||||
|
||||
This product includes portions of other software. For the full text of licenses, see below:
|
||||
|
||||
JW Player Third Party Software Notices and/or Additional Terms and Conditions
|
||||
|
||||
**************************************************************************************************
|
||||
The following software is used under Apache License 2.0
|
||||
**************************************************************************************************
|
||||
|
||||
vtt.js v0.13.0
|
||||
Copyright (c) 2019 Mozilla (http://mozilla.org)
|
||||
https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
|
||||
|
||||
* * *
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
|
||||
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
**************************************************************************************************
|
||||
The following software is used under MIT license
|
||||
**************************************************************************************************
|
||||
|
||||
Underscore.js v1.6.0
|
||||
Copyright (c) 2009-2014 Jeremy Ashkenas, DocumentCloud and Investigative
|
||||
https://github.com/jashkenas/underscore/blob/1.6.0/LICENSE
|
||||
|
||||
Backbone backbone.events.js v1.1.2
|
||||
Copyright (c) 2010-2014 Jeremy Ashkenas, DocumentCloud
|
||||
https://github.com/jashkenas/backbone/blob/1.1.2/LICENSE
|
||||
|
||||
Promise Polyfill v7.1.1
|
||||
Copyright (c) 2014 Taylor Hakes and Forbes Lindesay
|
||||
https://github.com/taylorhakes/promise-polyfill/blob/v7.1.1/LICENSE
|
||||
|
||||
can-autoplay.js v3.0.0
|
||||
Copyright (c) 2017 video-dev
|
||||
https://github.com/video-dev/can-autoplay/blob/v3.0.0/LICENSE
|
||||
|
||||
* * *
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
**************************************************************************************************
|
||||
The following software is used under W3C license
|
||||
**************************************************************************************************
|
||||
|
||||
Intersection Observer v0.5.0
|
||||
Copyright (c) 2016 Google Inc. (http://google.com)
|
||||
https://github.com/w3c/IntersectionObserver/blob/v0.5.0/LICENSE.md
|
||||
|
||||
* * *
|
||||
|
||||
W3C SOFTWARE AND DOCUMENT NOTICE AND LICENSE
|
||||
Status: This license takes effect 13 May, 2015.
|
||||
|
||||
This work is being provided by the copyright holders under the following license.
|
||||
|
||||
License
|
||||
By obtaining and/or copying this work, you (the licensee) agree that you have read, understood, and will comply with the following terms and conditions.
|
||||
|
||||
Permission to copy, modify, and distribute this work, with or without modification, for any purpose and without fee or royalty is hereby granted, provided that you include the following on ALL copies of the work or portions thereof, including modifications:
|
||||
|
||||
The full text of this NOTICE in a location viewable to users of the redistributed or derivative work.
|
||||
|
||||
Any pre-existing intellectual property disclaimers, notices, or terms and conditions. If none exist, the W3C Software and Document Short Notice should be included.
|
||||
|
||||
Notice of any changes or modifications, through a copyright statement on the new code or document such as "This software or document includes material copied from or derived from [title and URI of the W3C document]. Copyright © [YEAR] W3C® (MIT, ERCIM, Keio, Beihang)."
|
||||
|
||||
Disclaimers
|
||||
THIS WORK IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE OR DOCUMENT WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
|
||||
|
||||
COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENT.
|
||||
|
||||
The name and trademarks of copyright holders may NOT be used in advertising or publicity pertaining to the work without specific, written prior permission. Title to copyright in this work will at all times remain with copyright holders.
|
||||
*/
|
||||
(window.webpackJsonpjwplayer=window.webpackJsonpjwplayer||[]).push([[10],{97:function(t,e,r){"use strict";r.r(e);var n=r(42),i=r(67),s=/^(\d+):(\d{2})(:\d{2})?\.(\d{3})/,a=/^-?\d+$/,u=/\r\n|\n/,o=/^NOTE($|[ \t])/,c=/^[^\sa-zA-Z-]+/,l=/:/,f=/\s/,h=/^\s+/,g=/-->/,d=/^WEBVTT([ \t].*)?$/,p=function(t,e){this.window=t,this.state="INITIAL",this.buffer="",this.decoder=e||new b,this.regionList=[],this.maxCueBatch=1e3};function b(){return{decode:function(t){if(!t)return"";if("string"!=typeof t)throw new Error("Error - expected string data.");return decodeURIComponent(encodeURIComponent(t))}}}function v(){this.values=Object.create(null)}v.prototype={set:function(t,e){this.get(t)||""===e||(this.values[t]=e)},get:function(t,e,r){return r?this.has(t)?this.values[t]:e[r]:this.has(t)?this.values[t]:e},has:function(t){return t in this.values},alt:function(t,e,r){for(var n=0;n<r.length;++n)if(e===r[n]){this.set(t,e);break}},integer:function(t,e){a.test(e)&&this.set(t,parseInt(e,10))},percent:function(t,e){return(e=parseFloat(e))>=0&&e<=100&&(this.set(t,e),!0)}};var E=new i.a(0,0,0),w="middle"===E.align?"middle":"center";function T(t,e,r){var n=t;function i(){var e=function(t){function e(t,e,r,n){return 3600*(0|t)+60*(0|e)+(0|r)+(0|n)/1e3}var r=t.match(s);return r?r[3]?e(r[1],r[2],r[3].replace(":",""),r[4]):r[1]>59?e(r[1],r[2],0,r[4]):e(0,r[1],r[2],r[4]):null}(t);if(null===e)throw new Error("Malformed timestamp: "+n);return t=t.replace(c,""),e}function a(){t=t.replace(h,"")}if(a(),e.startTime=i(),a(),"--\x3e"!==t.substr(0,3))throw new Error("Malformed time stamp (time stamps must be separated by '--\x3e'): "+n);t=t.substr(3),a(),e.endTime=i(),a(),function(t,e){var n=new v;!function(t,e,r,n){for(var i=n?t.split(n):[t],s=0;s<=i.length;s+=1)if("string"==typeof i[s]){var a=i[s].split(r);if(2===a.length)e(a[0],a[1])}}(t,(function(t,e){switch(t){case"region":for(var i=r.length-1;i>=0;i--)if(r[i].id===e){n.set(t,r[i].region);break}break;case"vertical":n.alt(t,e,["rl","lr"]);break;case"line":var s=e.split(","),a=s[0];n.integer(t,a),n.percent(t,a)&&n.set("snapToLines",!1),n.alt(t,a,["auto"]),2===s.length&&n.alt("lineAlign",s[1],["start",w,"end"]);break;case"position":var u=e.split(",");n.percent(t,u[0]),2===u.length&&n.alt("positionAlign",u[1],["start",w,"end","line-left","line-right","auto"]);break;case"size":n.percent(t,e);break;case"align":n.alt(t,e,["start",w,"end","left","right"])}}),l,f),e.region=n.get("region",null),e.vertical=n.get("vertical","");var i=n.get("line","auto");"auto"===i&&-1===E.line&&(i=-1),e.line=i,e.lineAlign=n.get("lineAlign","start"),e.snapToLines=n.get("snapToLines",!0),e.size=n.get("size",100),e.align=n.get("align",w);var s=n.get("position","auto");"auto"===s&&50===E.position&&(s="start"===e.align||"left"===e.align?0:"end"===e.align||"right"===e.align?100:50),e.position=s}(t,e)}p.prototype={parse:function(t,e){var r,s=this;function a(){for(var t=s.buffer,e=0;e<t.length&&"\r"!==t[e]&&"\n"!==t[e];)++e;var r=t.substr(0,e);return"\r"===t[e]&&++e,"\n"===t[e]&&++e,s.buffer=t.substr(e),r}function c(){"CUETEXT"===s.state&&s.cue&&s.oncue&&s.oncue(s.cue),s.cue=null,s.state="INITIAL"===s.state?"BADWEBVTT":"BADCUE"}t&&(s.buffer+=s.decoder.decode(t,{stream:!0}));try{if("INITIAL"===s.state){if(!u.test(s.buffer))return this;var f=(r=a()).match(d);if(!f||!f[0])throw new Error("Malformed WebVTT signature.");s.state="HEADER"}}catch(t){return c(),this}var h=!1,p=0;!function t(){try{for(;s.buffer&&p<=s.maxCueBatch;){if(!u.test(s.buffer))return s.flush(),this;switch(h?h=!1:r=a(),s.state){case"HEADER":l.test(r)||r||(s.state="ID");break;case"NOTE":r||(s.state="ID");break;case"ID":if(o.test(r)){s.state="NOTE";break}if(!r)break;if(s.cue=new i.a(0,0,""),s.state="CUE",!g.test(r)){s.cue.id=r;break}case"CUE":try{T(r,s.cue,s.regionList)}catch(t){s.cue=null,s.state="BADCUE";break}s.state="CUETEXT";break;case"CUETEXT":var f=g.test(r);if(!r||f&&(h=!0)){s.oncue&&(p+=1,s.oncue(s.cue)),s.cue=null,s.state="ID";break}s.cue.text&&(s.cue.text+="\n"),s.cue.text+=r;break;case"BADCUE":r||(s.state="ID")}}if(p=0,s.buffer)Object(n.b)(t);else if(!e)return s.flush(),this}catch(t){return c(),this}}()},flush:function(){try{if(this.buffer+=this.decoder.decode(),(this.cue||"HEADER"===this.state)&&(this.buffer+="\n\n",this.parse(void 0,!0)),"INITIAL"===this.state)throw new Error("Malformed WebVTT signature.")}catch(t){throw t}return this.onflush&&this.onflush(),this}},e.default=p}}]);
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"short_name": "Stash",
|
||||
"name": "Stash: Porn Organizer",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import React, { FunctionComponent, useState } from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import Galleries from "./components/Galleries/Galleries";
|
||||
import { MainNavbar } from "./components/MainNavbar";
|
||||
import { PageNotFound } from "./components/PageNotFound";
|
||||
import Performers from "./components/performers/performers";
|
||||
import Scenes from "./components/scenes/scenes";
|
||||
import { Settings } from "./components/Settings/Settings";
|
||||
import { Stats } from "./components/Stats";
|
||||
import Studios from "./components/Studios/Studios";
|
||||
import Movies from "./components/Movies/Movies";
|
||||
import Tags from "./components/Tags/Tags";
|
||||
import { SceneFilenameParser } from "./components/scenes/SceneFilenameParser";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { IconName } from "@blueprintjs/core";
|
||||
|
||||
export interface IMenuItem {
|
||||
icon: IconName
|
||||
text: string
|
||||
href: string
|
||||
}
|
||||
|
||||
interface IProps {}
|
||||
|
||||
export const App: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const [menuOpen, setMenuOpen] = useState<boolean>(false);
|
||||
|
||||
function getSidebarClosedClass() {
|
||||
if (!menuOpen) {
|
||||
return " sidebar-closed";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
const menuItems: IMenuItem[] = [
|
||||
{
|
||||
icon: "video",
|
||||
text: "Scenes",
|
||||
href: "/scenes"
|
||||
},
|
||||
{
|
||||
href: "/movies",
|
||||
icon: "film",
|
||||
text: "Movies"
|
||||
},
|
||||
{
|
||||
href: "/scenes/markers",
|
||||
icon: "map-marker",
|
||||
text: "Markers"
|
||||
},
|
||||
{
|
||||
href: "/galleries",
|
||||
icon: "media",
|
||||
text: "Galleries"
|
||||
},
|
||||
{
|
||||
href: "/performers",
|
||||
icon: "person",
|
||||
text: "Performers"
|
||||
},
|
||||
{
|
||||
href: "/studios",
|
||||
icon: "mobile-video",
|
||||
text: "Studios"
|
||||
},
|
||||
{
|
||||
href: "/tags",
|
||||
icon: "tag",
|
||||
text: "Tags"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bp3-dark">
|
||||
<ErrorBoundary>
|
||||
<MainNavbar onMenuToggle={() => setMenuOpen(!menuOpen)} menuItems={menuItems}/>
|
||||
<Sidebar className={getSidebarClosedClass()} menuItems={menuItems}/>
|
||||
<div className={"main" + getSidebarClosedClass()}>
|
||||
<Switch>
|
||||
<Route exact={true} path="/" component={Stats} />
|
||||
<Route path="/scenes" component={Scenes} />
|
||||
{/* <Route path="/scenes/:id" component={Scene} /> */}
|
||||
<Route path="/galleries" component={Galleries} />
|
||||
<Route path="/performers" component={Performers} />
|
||||
<Route path="/tags" component={Tags} />
|
||||
<Route path="/studios" component={Studios} />
|
||||
<Route path="/movies" component={Movies} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/sceneFilenameParser" component={SceneFilenameParser} />
|
||||
<Route component={PageNotFound} />
|
||||
</Switch>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export class ErrorBoundary extends React.Component<any, any> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.state = { error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: any, errorInfo: any) {
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.errorInfo) {
|
||||
// Error path
|
||||
return (
|
||||
<div>
|
||||
<h2>Something went wrong.</h2>
|
||||
<details style={{ whiteSpace: "pre-wrap" }}>
|
||||
{this.state.error && this.state.error.toString()}
|
||||
<br />
|
||||
{this.state.errorInfo.componentStack}
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Normally, just render children
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { Gallery } from "./Gallery";
|
||||
import { GalleryList } from "./GalleryList";
|
||||
|
||||
const Galleries = () => (
|
||||
<Switch>
|
||||
<Route exact={true} path="/galleries" component={GalleryList} />
|
||||
<Route path="/galleries/:id" component={Gallery} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
export default Galleries;
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import {
|
||||
Spinner,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { StashService } from "../../core/StashService";
|
||||
import { IBaseProps } from "../../models";
|
||||
import { GalleryViewer } from "./GalleryViewer";
|
||||
|
||||
interface IProps extends IBaseProps {}
|
||||
|
||||
export const Gallery: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const [gallery, setGallery] = useState<Partial<GQL.GalleryDataFragment>>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { data, error, loading } = StashService.useFindGallery(props.match.params.id);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(loading);
|
||||
if (!data || !data.findGallery || !!error) { return; }
|
||||
setGallery(data.findGallery);
|
||||
}, [data, loading, error]);
|
||||
|
||||
if (!data || !data.findGallery || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
|
||||
if (!!error) { return <>{error.message}</>; }
|
||||
return (
|
||||
<div style={{width: "75vw", margin: "0 auto"}}>
|
||||
<GalleryViewer gallery={gallery as any} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { HTMLTable } from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { QueryHookResult } from "react-apollo-hooks";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FindGalleriesQuery, FindGalleriesVariables } from "../../core/generated-graphql";
|
||||
import { ListHook } from "../../hooks/ListHook";
|
||||
import { IBaseProps } from "../../models/base-props";
|
||||
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
|
||||
|
||||
interface IProps extends IBaseProps {}
|
||||
|
||||
export const GalleryList: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const listData = ListHook.useList({
|
||||
filterMode: FilterMode.Galleries,
|
||||
props,
|
||||
renderContent,
|
||||
});
|
||||
|
||||
function renderContent(result: QueryHookResult<FindGalleriesQuery, FindGalleriesVariables>, filter: ListFilterModel) {
|
||||
if (!result.data || !result.data.findGalleries) { return; }
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return <h1>TODO</h1>;
|
||||
} else if (filter.displayMode === DisplayMode.List) {
|
||||
return (
|
||||
<HTMLTable style={{margin: "0 auto"}}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preview</th>
|
||||
<th>Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.data.findGalleries.galleries.map((gallery) => (
|
||||
<tr key={gallery.id}>
|
||||
<td>
|
||||
<Link to={`/galleries/${gallery.id}`}>
|
||||
{gallery.files.length > 0 ? <img alt={gallery.title} src={`${gallery.files[0].path}?thumb=true`} /> : undefined}
|
||||
</Link>
|
||||
</td>
|
||||
<td><Link to={`/galleries/${gallery.id}`}>{gallery.path} ({gallery.files.length} {gallery.files.length === 1 ? 'image' : 'images'})</Link></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
);
|
||||
} else if (filter.displayMode === DisplayMode.Wall) {
|
||||
return <h1>TODO</h1>;
|
||||
}
|
||||
}
|
||||
|
||||
return listData.template;
|
||||
};
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import React, { FunctionComponent, useState } from "react";
|
||||
import Lightbox from "react-images";
|
||||
import Gallery from "react-photo-gallery";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
|
||||
interface IProps {
|
||||
gallery: GQL.GalleryDataFragment;
|
||||
}
|
||||
|
||||
export const GalleryViewer: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const [currentImage, setCurrentImage] = useState<number>(0);
|
||||
const [lightboxIsOpen, setLightboxIsOpen] = useState<boolean>(false);
|
||||
|
||||
function openLightbox(event: any, obj: any) {
|
||||
setCurrentImage(obj.index);
|
||||
setLightboxIsOpen(true);
|
||||
}
|
||||
function closeLightbox() {
|
||||
setCurrentImage(0);
|
||||
setLightboxIsOpen(false);
|
||||
}
|
||||
function gotoPrevious() {
|
||||
setCurrentImage(currentImage - 1);
|
||||
}
|
||||
function gotoNext() {
|
||||
setCurrentImage(currentImage + 1);
|
||||
}
|
||||
|
||||
const photos = props.gallery.files.map((file) => ({src: file.path || "", caption: file.name}));
|
||||
const thumbs = props.gallery.files.map((file) => ({src: `${file.path}?thumb=true` || "", width: 1, height: 1}));
|
||||
return (
|
||||
<div>
|
||||
<Gallery photos={thumbs} columns={15} onClick={openLightbox} />
|
||||
<Lightbox
|
||||
images={photos}
|
||||
onClose={closeLightbox}
|
||||
onClickPrev={gotoPrevious}
|
||||
onClickNext={gotoNext}
|
||||
currentImage={currentImage}
|
||||
isOpen={lightboxIsOpen}
|
||||
onClickImage={() => window.open(photos[currentImage].src, "_blank")}
|
||||
width={9999}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import {
|
||||
Navbar,
|
||||
NavbarDivider,
|
||||
NavbarGroup,
|
||||
NavbarHeading,
|
||||
Button,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
import useLocation from "react-use/lib/useLocation";
|
||||
import { IMenuItem } from "../App";
|
||||
|
||||
interface IProps {
|
||||
onMenuToggle() : void
|
||||
menuItems: IMenuItem[]
|
||||
}
|
||||
|
||||
export const MainNavbar: FunctionComponent<IProps> = (props) => {
|
||||
const [newButtonPath, setNewButtonPath] = useState<string | undefined>(undefined);
|
||||
const locationState = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
switch (window.location.pathname) {
|
||||
case "/performers": {
|
||||
setNewButtonPath("/performers/new");
|
||||
break;
|
||||
}
|
||||
case "/studios": {
|
||||
setNewButtonPath("/studios/new");
|
||||
break;
|
||||
}
|
||||
case "/movies": {
|
||||
setNewButtonPath("/movies/new");
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
setNewButtonPath(undefined);
|
||||
}
|
||||
}
|
||||
}, [locationState.pathname]);
|
||||
|
||||
function renderNewButton() {
|
||||
if (!newButtonPath) { return; }
|
||||
return (
|
||||
<>
|
||||
<NavLink
|
||||
to={newButtonPath}
|
||||
className="bp3-button bp3-intent-primary"
|
||||
>
|
||||
New
|
||||
</NavLink>
|
||||
<NavbarDivider />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar fixedToTop={true}>
|
||||
<div>
|
||||
<NavbarGroup align="left">
|
||||
<Button className="menu-button" icon="menu" onClick={() => props.onMenuToggle()}/>
|
||||
<NavbarHeading><Link to="/" className="bp3-button bp3-minimal">Stash</Link></NavbarHeading>
|
||||
<NavbarDivider />
|
||||
|
||||
{props.menuItems.map((i) => {
|
||||
return (
|
||||
<NavLink
|
||||
exact={true}
|
||||
to={i.href}
|
||||
className={"bp3-button bp3-minimal collapsible-navlink bp3-icon-" + i.icon}
|
||||
activeClassName="bp3-active"
|
||||
>
|
||||
{i.text}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</NavbarGroup>
|
||||
<NavbarGroup align="right">
|
||||
{renderNewButton()}
|
||||
<NavLink
|
||||
exact={true}
|
||||
to="/settings"
|
||||
className="bp3-button bp3-minimal bp3-icon-cog"
|
||||
activeClassName="bp3-active"
|
||||
/>
|
||||
</NavbarGroup>
|
||||
</div>
|
||||
</Navbar>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import {
|
||||
Card,
|
||||
Elevation,
|
||||
H4,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { ColorUtils } from "../../utils/color";
|
||||
|
||||
interface IProps {
|
||||
movie: GQL.MovieDataFragment;
|
||||
sceneIndex?: string;
|
||||
// scene: GQL.SceneDataFragment;
|
||||
}
|
||||
|
||||
|
||||
export const MovieCard: FunctionComponent<IProps> = (props: IProps) => {
|
||||
function maybeRenderRatingBanner() {
|
||||
if (!props.movie.rating) { return; }
|
||||
return (
|
||||
<div className={`rating-banner ${ColorUtils.classForRating(parseInt(props.movie.rating,10))}`}>
|
||||
RATING: {props.movie.rating}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderSceneNumber() {
|
||||
if (!props.sceneIndex) {
|
||||
return (
|
||||
<div className="card-section">
|
||||
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
|
||||
{props.movie.name}
|
||||
</H4>
|
||||
<span className="bp3-text-muted block">{props.movie.scene_count} scenes.</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="card-section">
|
||||
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
|
||||
{props.movie.name}
|
||||
</H4>
|
||||
<span className="bp3-text-muted block">Scene number: {props.sceneIndex}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="grid-item"
|
||||
elevation={Elevation.ONE}
|
||||
>
|
||||
|
||||
<Link
|
||||
to={`/movies/${props.movie.id}`}
|
||||
className="movie previewable image"
|
||||
style={{backgroundImage: `url(${props.movie.front_image_path})`}}
|
||||
>
|
||||
{maybeRenderRatingBanner()}
|
||||
</Link>
|
||||
{maybeRenderSceneNumber()}
|
||||
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
import {
|
||||
EditableText,
|
||||
HTMLTable,
|
||||
Spinner,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { StashService } from "../../../core/StashService";
|
||||
import { IBaseProps } from "../../../models";
|
||||
import { ErrorUtils } from "../../../utils/errors";
|
||||
import { TableUtils } from "../../../utils/table";
|
||||
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
|
||||
import { ImageUtils } from "../../../utils/image";
|
||||
|
||||
interface IProps extends IBaseProps {}
|
||||
|
||||
export const Movie: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const isNew = props.match.params.id === "new";
|
||||
|
||||
// Editing state
|
||||
const [isEditing, setIsEditing] = useState<boolean>(isNew);
|
||||
|
||||
// Editing movie state
|
||||
const [front_image, setFrontImage] = useState<string | undefined>(undefined);
|
||||
const [back_image, setBackImage] = useState<string | undefined>(undefined);
|
||||
const [name, setName] = useState<string | undefined>(undefined);
|
||||
const [aliases, setAliases] = useState<string | undefined>(undefined);
|
||||
const [duration, setDuration] = useState<string | undefined>(undefined);
|
||||
const [date, setDate] = useState<string | undefined>(undefined);
|
||||
const [rating, setRating] = useState<string | undefined>(undefined);
|
||||
const [director, setDirector] = useState<string | undefined>(undefined);
|
||||
const [synopsis, setSynopsis] = useState<string | undefined>(undefined);
|
||||
const [url, setUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
// Movie state
|
||||
const [movie, setMovie] = useState<Partial<GQL.MovieDataFragment>>({});
|
||||
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined);
|
||||
const [backimagePreview, setBackImagePreview] = useState<string | undefined>(undefined);
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { data, error, loading } = StashService.useFindMovie(props.match.params.id);
|
||||
const updateMovie = StashService.useMovieUpdate(getMovieInput() as GQL.MovieUpdateInput);
|
||||
const createMovie = StashService.useMovieCreate(getMovieInput() as GQL.MovieCreateInput);
|
||||
const deleteMovie = StashService.useMovieDestroy(getMovieInput() as GQL.MovieDestroyInput);
|
||||
|
||||
function updateMovieEditState(state: Partial<GQL.MovieDataFragment>) {
|
||||
setName(state.name);
|
||||
setAliases(state.aliases);
|
||||
setDuration(state.duration);
|
||||
setDate(state.date);
|
||||
setRating(state.rating);
|
||||
setDirector(state.director);
|
||||
setSynopsis(state.synopsis);
|
||||
setUrl(state.url);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(loading);
|
||||
if (!data || !data.findMovie || !!error) { return; }
|
||||
setMovie(data.findMovie);
|
||||
}, [data, loading, error]);
|
||||
|
||||
useEffect(() => {
|
||||
setImagePreview(movie.front_image_path);
|
||||
setBackImagePreview(movie.back_image_path);
|
||||
setFrontImage(undefined);
|
||||
setBackImage(undefined);
|
||||
updateMovieEditState(movie);
|
||||
if (!isNew) {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [movie, isNew]);
|
||||
|
||||
function onImageLoad(this: FileReader) {
|
||||
setImagePreview(this.result as string);
|
||||
setFrontImage(this.result as string);
|
||||
|
||||
}
|
||||
|
||||
function onBackImageLoad(this: FileReader) {
|
||||
setBackImagePreview(this.result as string);
|
||||
setBackImage(this.result as string);
|
||||
}
|
||||
|
||||
|
||||
ImageUtils.addPasteImageHook(onImageLoad);
|
||||
ImageUtils.addPasteImageHook(onBackImageLoad);
|
||||
|
||||
if (!isNew && !isEditing) {
|
||||
if (!data || !data.findMovie || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
|
||||
if (!!error) { return <>error...</>; }
|
||||
}
|
||||
|
||||
function getMovieInput() {
|
||||
const input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
|
||||
name,
|
||||
aliases,
|
||||
duration,
|
||||
date,
|
||||
rating,
|
||||
director,
|
||||
synopsis,
|
||||
url,
|
||||
front_image,
|
||||
back_image
|
||||
|
||||
};
|
||||
|
||||
if (!isNew) {
|
||||
(input as GQL.MovieUpdateInput).id = props.match.params.id;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (!isNew) {
|
||||
const result = await updateMovie();
|
||||
setMovie(result.data.movieUpdate);
|
||||
} else {
|
||||
const result = await createMovie();
|
||||
setMovie(result.data.movieCreate);
|
||||
props.history.push(`/movies/${result.data.movieCreate.id}`);
|
||||
}
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await deleteMovie();
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
// redirect to movies page
|
||||
props.history.push(`/movies`);
|
||||
}
|
||||
|
||||
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||
ImageUtils.onImageChange(event, onImageLoad);
|
||||
}
|
||||
|
||||
function onBackImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||
ImageUtils.onImageChange(event, onBackImageLoad);
|
||||
}
|
||||
|
||||
// TODO: CSS class
|
||||
return (
|
||||
<>
|
||||
<div className="columns is-multiline no-spacing">
|
||||
<div className="column is-half details-image-container">
|
||||
<img alt={name} className="movie" src={imagePreview} />
|
||||
<img alt={name} className="movie" src={backimagePreview} />
|
||||
</div>
|
||||
<div className="column is-half details-detail-container">
|
||||
<DetailsEditNavbar
|
||||
movie={movie}
|
||||
isNew={isNew}
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={() => { setIsEditing(!isEditing); updateMovieEditState(movie); }}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
onImageChange={onImageChange}
|
||||
onBackImageChange={onBackImageChange}
|
||||
/>
|
||||
<h1 className="bp3-heading">
|
||||
<EditableText
|
||||
disabled={!isEditing}
|
||||
value={name}
|
||||
placeholder="Name"
|
||||
onChange={(value) => setName(value)}
|
||||
/>
|
||||
</h1>
|
||||
|
||||
<HTMLTable style={{width: "100%"}}>
|
||||
<tbody>
|
||||
{TableUtils.renderInputGroup({title: "Aliases", value: aliases, isEditing, onChange: setAliases})}
|
||||
{TableUtils.renderInputGroup({title: "Duration", value: duration, isEditing, onChange: setDuration})}
|
||||
{TableUtils.renderInputGroup({title: "Date (YYYY-MM-DD)", value: date, isEditing, onChange: setDate})}
|
||||
{TableUtils.renderInputGroup({title: "Director", value: director, isEditing, onChange: setDirector})}
|
||||
{TableUtils.renderHtmlSelect({
|
||||
title: "Rating",
|
||||
value: rating,
|
||||
isEditing,
|
||||
onChange: (value: string) => setRating(value),
|
||||
selectOptions: ["","1","2","3","4","5"]
|
||||
})}
|
||||
{TableUtils.renderInputGroup({title: "URL", value: url, isEditing, onChange: setUrl})}
|
||||
{TableUtils.renderTextArea({title: "Synopsis", value: synopsis, isEditing, onChange: setSynopsis})}
|
||||
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { QueryHookResult } from "react-apollo-hooks";
|
||||
import { FindMoviesQuery, FindMoviesVariables } from "../../core/generated-graphql";
|
||||
import { ListHook } from "../../hooks/ListHook";
|
||||
import { IBaseProps } from "../../models/base-props";
|
||||
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
|
||||
import { MovieCard } from "./MovieCard";
|
||||
|
||||
interface IProps extends IBaseProps {}
|
||||
|
||||
export const MovieList: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const listData = ListHook.useList({
|
||||
filterMode: FilterMode.Movies,
|
||||
props,
|
||||
renderContent,
|
||||
});
|
||||
|
||||
function renderContent(result: QueryHookResult<FindMoviesQuery, FindMoviesVariables>, filter: ListFilterModel) {
|
||||
if (!result.data || !result.data.findMovies) { return; }
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<div className="grid">
|
||||
{result.data.findMovies.movies.map((movie) => (<MovieCard key={movie.id} movie={movie}/>))}
|
||||
</div>
|
||||
);
|
||||
} else if (filter.displayMode === DisplayMode.List) {
|
||||
return <h1>TODO</h1>;
|
||||
}
|
||||
}
|
||||
|
||||
return listData.template;
|
||||
};
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { Movie } from "./MovieDetails/Movie";
|
||||
import { MovieList } from "./MovieList";
|
||||
|
||||
const Movies = () => (
|
||||
<Switch>
|
||||
<Route exact={true} path="/movies" component={MovieList} />
|
||||
<Route path="/movies/:id" component={Movie} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
export default Movies;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
|
||||
export const PageNotFound: FunctionComponent = () => {
|
||||
return (
|
||||
<h1>Page not found.</h1>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import {
|
||||
Card,
|
||||
Tab,
|
||||
Tabs,
|
||||
} from "@blueprintjs/core";
|
||||
import queryString from "query-string";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import { IBaseProps } from "../../models";
|
||||
import { SettingsAboutPanel } from "./SettingsAboutPanel";
|
||||
import { SettingsConfigurationPanel } from "./SettingsConfigurationPanel";
|
||||
import { SettingsInterfacePanel } from "./SettingsInterfacePanel";
|
||||
import { SettingsLogsPanel } from "./SettingsLogsPanel";
|
||||
import { SettingsTasksPanel } from "./SettingsTasksPanel/SettingsTasksPanel";
|
||||
|
||||
interface IProps extends IBaseProps {}
|
||||
|
||||
type TabId = "configuration" | "tasks" | "logs" | "about";
|
||||
|
||||
export const Settings: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const [tabId, setTabId] = useState<TabId>(getTabId());
|
||||
|
||||
useEffect(() => {
|
||||
const location = Object.assign({}, props.history.location);
|
||||
location.search = queryString.stringify({tab: tabId}, {encode: false});
|
||||
props.history.replace(location);
|
||||
}, [tabId, props.history]);
|
||||
|
||||
function getTabId(): TabId {
|
||||
const queryParams = queryString.parse(props.location.search);
|
||||
if (!queryParams.tab || typeof queryParams.tab !== "string") { return "tasks"; }
|
||||
return queryParams.tab as TabId;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card id="details-container">
|
||||
<Tabs
|
||||
renderActiveTabPanelOnly={true}
|
||||
vertical={true}
|
||||
onChange={(newId) => setTabId(newId as TabId)}
|
||||
defaultSelectedTabId={getTabId()}
|
||||
>
|
||||
<Tab id="configuration" title="Configuration" panel={<SettingsConfigurationPanel />} />
|
||||
<Tab id="interface" title="Interface Configuration" panel={<SettingsInterfacePanel />} />
|
||||
<Tab id="tasks" title="Tasks" panel={<SettingsTasksPanel />} />
|
||||
<Tab id="logs" title="Logs" panel={<SettingsLogsPanel />} />
|
||||
<Tab id="about" title="About" panel={<SettingsAboutPanel />} />
|
||||
</Tabs>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
H4,
|
||||
HTMLTable,
|
||||
Spinner,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { StashService } from "../../core/StashService";
|
||||
|
||||
interface IProps { }
|
||||
|
||||
export const SettingsAboutPanel: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const { data, error, loading } = StashService.useVersion();
|
||||
const { data: dataLatest, error: errorLatest, loading: loadingLatest, refetch, networkStatus } = StashService.useLatestVersion();
|
||||
|
||||
function maybeRenderTag() {
|
||||
if (!data || !data.version || !data.version.version) { return; }
|
||||
return (
|
||||
<tr>
|
||||
<td>Version:</td>
|
||||
<td>{data.version.version}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderLatestVersion() {
|
||||
if (!dataLatest || !dataLatest.latestversion || !dataLatest.latestversion.shorthash || !dataLatest.latestversion.url) { return; }
|
||||
if (!data || !data.version || !data.version.hash) {
|
||||
return (
|
||||
<>{dataLatest.latestversion.shorthash}</>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.version.hash !== dataLatest.latestversion.shorthash) {
|
||||
return (
|
||||
<>
|
||||
<strong>{dataLatest.latestversion.shorthash} [NEW] </strong><a href={dataLatest.latestversion.url}>Download</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>{dataLatest.latestversion.shorthash}</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderLatestVersion() {
|
||||
if (!data || !data.version || !data.version.version) { return; } //if there is no "version" latest version check is obviously not supported
|
||||
return (
|
||||
<HTMLTable>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Latest Version Build Hash: </td>
|
||||
<td>{maybeRenderLatestVersion()} </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><Button onClick={() => refetch()} text="Check for new version" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
);
|
||||
}
|
||||
|
||||
function renderVersion() {
|
||||
if (!data || !data.version) { return; }
|
||||
return (
|
||||
<>
|
||||
<HTMLTable>
|
||||
<tbody>
|
||||
{maybeRenderTag()}
|
||||
<tr>
|
||||
<td>Build hash:</td>
|
||||
<td>{data.version.hash}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Build time:</td>
|
||||
<td>{data.version.build_time}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<H4>About</H4>
|
||||
<HTMLTable>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Stash home at <a href="https://github.com/stashapp/stash" target="_blank">Github</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stash <a href="https://github.com/stashapp/stash/wiki" target="_blank">Wiki</a> page</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Join our <a href="https://discord.gg/2TsNFKt" target="_blank">Discord</a> channel</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Support us through <a href="https://opencollective.com/stashapp" target="_blank">Open Collective</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
{!data || loading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
||||
{!!error ? <span>{error.message}</span> : undefined}
|
||||
{!!errorLatest ? <span>{errorLatest.message}</span> : undefined}
|
||||
{renderVersion()}
|
||||
{!dataLatest || loadingLatest || networkStatus === 4 ? <Spinner size={Spinner.SIZE_SMALL} /> : <>{renderLatestVersion()}</>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
import {
|
||||
AnchorButton,
|
||||
Button,
|
||||
Divider,
|
||||
FormGroup,
|
||||
H4,
|
||||
InputGroup,
|
||||
Spinner,
|
||||
Checkbox,
|
||||
HTMLSelect,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { StashService } from "../../core/StashService";
|
||||
import { ErrorUtils } from "../../utils/errors";
|
||||
import { ToastUtils } from "../../utils/toasts";
|
||||
import { FolderSelect } from "../Shared/FolderSelect/FolderSelect";
|
||||
|
||||
interface IProps { }
|
||||
|
||||
export const SettingsConfigurationPanel: FunctionComponent<IProps> = (props: IProps) => {
|
||||
// Editing config state
|
||||
const [stashes, setStashes] = useState<string[]>([]);
|
||||
const [databasePath, setDatabasePath] = useState<string | undefined>(undefined);
|
||||
const [generatedPath, setGeneratedPath] = useState<string | undefined>(undefined);
|
||||
const [maxTranscodeSize, setMaxTranscodeSize] = useState<GQL.StreamingResolutionEnum | undefined>(undefined);
|
||||
const [maxStreamingTranscodeSize, setMaxStreamingTranscodeSize] = useState<GQL.StreamingResolutionEnum | undefined>(undefined);
|
||||
const [forceMkv, setForceMkv] = useState<boolean>(false);
|
||||
const [forceHevc, setForceHevc] = useState<boolean>(false);
|
||||
const [username, setUsername] = useState<string | undefined>(undefined);
|
||||
const [password, setPassword] = useState<string | undefined>(undefined);
|
||||
const [logFile, setLogFile] = useState<string | undefined>();
|
||||
const [logOut, setLogOut] = useState<boolean>(true);
|
||||
const [logLevel, setLogLevel] = useState<string>("Info");
|
||||
const [logAccess, setLogAccess] = useState<boolean>(true);
|
||||
const [excludes, setExcludes] = useState<(string)[]>([]);
|
||||
const [scraperUserAgent, setScraperUserAgent] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data, error, loading } = StashService.useConfiguration();
|
||||
|
||||
const updateGeneralConfig = StashService.useConfigureGeneral({
|
||||
stashes,
|
||||
databasePath,
|
||||
generatedPath,
|
||||
maxTranscodeSize,
|
||||
maxStreamingTranscodeSize,
|
||||
forceMkv,
|
||||
forceHevc,
|
||||
username,
|
||||
password,
|
||||
logFile,
|
||||
logOut,
|
||||
logLevel,
|
||||
logAccess,
|
||||
excludes,
|
||||
scraperUserAgent,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !data.configuration || !!error) { return; }
|
||||
const conf = StashService.nullToUndefined(data.configuration) as GQL.ConfigDataFragment;
|
||||
if (!!conf.general) {
|
||||
setStashes(conf.general.stashes || []);
|
||||
setDatabasePath(conf.general.databasePath);
|
||||
setGeneratedPath(conf.general.generatedPath);
|
||||
setMaxTranscodeSize(conf.general.maxTranscodeSize);
|
||||
setMaxStreamingTranscodeSize(conf.general.maxStreamingTranscodeSize);
|
||||
setForceMkv(conf.general.forceMkv);
|
||||
setForceHevc(conf.general.forceHevc);
|
||||
setUsername(conf.general.username);
|
||||
setPassword(conf.general.password);
|
||||
setLogFile(conf.general.logFile);
|
||||
setLogOut(conf.general.logOut);
|
||||
setLogLevel(conf.general.logLevel);
|
||||
setLogAccess(conf.general.logAccess);
|
||||
setExcludes(conf.general.excludes);
|
||||
setScraperUserAgent(conf.general.scraperUserAgent);
|
||||
}
|
||||
}, [data, error]);
|
||||
|
||||
function onStashesChanged(directories: string[]) {
|
||||
setStashes(directories);
|
||||
}
|
||||
|
||||
function excludeRegexChanged(idx: number, value: string) {
|
||||
const newExcludes = excludes.map((regex, i) => {
|
||||
const ret = (idx !== i) ? regex : value;
|
||||
return ret
|
||||
})
|
||||
setExcludes(newExcludes);
|
||||
}
|
||||
|
||||
function excludeRemoveRegex(idx: number) {
|
||||
const newExcludes = excludes.filter((regex, i) => i !== idx);
|
||||
|
||||
setExcludes(newExcludes);
|
||||
}
|
||||
|
||||
function excludeAddRegex() {
|
||||
const demo = "sample\\.mp4$"
|
||||
const newExcludes = excludes.concat(demo);
|
||||
|
||||
setExcludes(newExcludes);
|
||||
}
|
||||
|
||||
|
||||
async function onSave() {
|
||||
try {
|
||||
const result = await updateGeneralConfig();
|
||||
console.log(result);
|
||||
ToastUtils.success("Updated config");
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
}
|
||||
|
||||
const transcodeQualities = [
|
||||
GQL.StreamingResolutionEnum.Low,
|
||||
GQL.StreamingResolutionEnum.Standard,
|
||||
GQL.StreamingResolutionEnum.StandardHd,
|
||||
GQL.StreamingResolutionEnum.FullHd,
|
||||
GQL.StreamingResolutionEnum.FourK,
|
||||
GQL.StreamingResolutionEnum.Original
|
||||
].map(resolutionToString);
|
||||
|
||||
function resolutionToString(r: GQL.StreamingResolutionEnum | undefined) {
|
||||
switch (r) {
|
||||
case GQL.StreamingResolutionEnum.Low: return "240p";
|
||||
case GQL.StreamingResolutionEnum.Standard: return "480p";
|
||||
case GQL.StreamingResolutionEnum.StandardHd: return "720p";
|
||||
case GQL.StreamingResolutionEnum.FullHd: return "1080p";
|
||||
case GQL.StreamingResolutionEnum.FourK: return "4k";
|
||||
case GQL.StreamingResolutionEnum.Original: return "Original";
|
||||
}
|
||||
|
||||
return "Original";
|
||||
}
|
||||
|
||||
function translateQuality(quality: string) {
|
||||
switch (quality) {
|
||||
case "240p": return GQL.StreamingResolutionEnum.Low;
|
||||
case "480p": return GQL.StreamingResolutionEnum.Standard;
|
||||
case "720p": return GQL.StreamingResolutionEnum.StandardHd;
|
||||
case "1080p": return GQL.StreamingResolutionEnum.FullHd;
|
||||
case "4k": return GQL.StreamingResolutionEnum.FourK;
|
||||
case "Original": return GQL.StreamingResolutionEnum.Original;
|
||||
}
|
||||
|
||||
return GQL.StreamingResolutionEnum.Original;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!error ? <h1>{error.message}</h1> : undefined}
|
||||
{(!data || !data.configuration || loading) ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
||||
<H4>Library</H4>
|
||||
<FormGroup>
|
||||
<FormGroup>
|
||||
<FormGroup
|
||||
label="Stashes"
|
||||
helperText="Directory locations to your content"
|
||||
>
|
||||
<FolderSelect
|
||||
directories={stashes}
|
||||
onDirectoriesChanged={onStashesChanged}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Database Path"
|
||||
helperText="File location for the SQLite database (requires restart)"
|
||||
>
|
||||
<InputGroup value={databasePath} onChange={(e: any) => setDatabasePath(e.target.value)} />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Generated Path"
|
||||
helperText="Directory location for the generated files (scene markers, scene previews, sprites, etc)"
|
||||
>
|
||||
<InputGroup value={generatedPath} onChange={(e: any) => setGeneratedPath(e.target.value)} />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Excluded Patterns"
|
||||
>
|
||||
|
||||
{(excludes) ? excludes.map((regexp, i) => {
|
||||
return (
|
||||
<InputGroup
|
||||
value={regexp}
|
||||
onChange={(e: any) => excludeRegexChanged(i, e.target.value)}
|
||||
rightElement={<Button icon="minus" minimal={true} intent="danger" onClick={(e: any) => excludeRemoveRegex(i)} />}
|
||||
/>
|
||||
);
|
||||
}) : null
|
||||
}
|
||||
|
||||
<Button icon="plus" minimal={true} onClick={(e: any) => excludeAddRegex()} />
|
||||
<div>
|
||||
<p>
|
||||
<AnchorButton
|
||||
href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration"
|
||||
rightIcon="help"
|
||||
text="Regexps of files/paths to exclude from Scan and add to Clean"
|
||||
minimal={true}
|
||||
target="_blank"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</FormGroup>
|
||||
</FormGroup>
|
||||
|
||||
<Divider />
|
||||
<FormGroup>
|
||||
<H4>Video</H4>
|
||||
<FormGroup
|
||||
label="Maximum transcode size"
|
||||
helperText="Maximum size for generated transcodes"
|
||||
>
|
||||
<HTMLSelect
|
||||
options={transcodeQualities}
|
||||
onChange={(event) => setMaxTranscodeSize(translateQuality(event.target.value))}
|
||||
value={resolutionToString(maxTranscodeSize)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label="Maximum streaming transcode size"
|
||||
helperText="Maximum size for transcoded streams"
|
||||
>
|
||||
<HTMLSelect
|
||||
options={transcodeQualities}
|
||||
onChange={(event) => setMaxStreamingTranscodeSize(translateQuality(event.target.value))}
|
||||
value={resolutionToString(maxStreamingTranscodeSize)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
helperText="Treat Matroska (MKV) as a supported container. Recommended for Chromium based browsers"
|
||||
>
|
||||
<Checkbox
|
||||
checked={forceMkv}
|
||||
label="Force Matroska as supported"
|
||||
onChange={() => setForceMkv(!forceMkv)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
helperText="Treat HEVC as a supported codec. Recommended for Safari or some Android based browsers"
|
||||
>
|
||||
<Checkbox
|
||||
checked={forceHevc}
|
||||
label="Force HEVC as supported"
|
||||
onChange={() => setForceHevc(!forceHevc)}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormGroup>
|
||||
<Divider />
|
||||
|
||||
<FormGroup>
|
||||
<H4>Scraping</H4>
|
||||
<FormGroup
|
||||
label="Scraper User-Agent string"
|
||||
helperText="User-Agent string used during scrape http requests"
|
||||
>
|
||||
<InputGroup value={scraperUserAgent} onChange={(e: any) => setScraperUserAgent(e.target.value)} />
|
||||
</FormGroup>
|
||||
</FormGroup>
|
||||
|
||||
<Divider />
|
||||
|
||||
<FormGroup>
|
||||
<H4>Authentication</H4>
|
||||
<FormGroup
|
||||
label="Username"
|
||||
helperText="Username to access Stash. Leave blank to disable user authentication"
|
||||
>
|
||||
<InputGroup value={username} onChange={(e: any) => setUsername(e.target.value)} />
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label="Password"
|
||||
helperText="Password to access Stash. Leave blank to disable user authentication"
|
||||
>
|
||||
<InputGroup type="password" value={password} onChange={(e: any) => setPassword(e.target.value)} />
|
||||
</FormGroup>
|
||||
</FormGroup>
|
||||
|
||||
<Divider />
|
||||
<H4>Logging</H4>
|
||||
<FormGroup
|
||||
label="Log file"
|
||||
helperText="Path to the file to output logging to. Blank to disable file logging. Requires restart."
|
||||
>
|
||||
<InputGroup value={logFile} onChange={(e: any) => setLogFile(e.target.value)} />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
helperText="Logs to the terminal in addition to a file. Always true if file logging is disabled. Requires restart."
|
||||
>
|
||||
<Checkbox
|
||||
checked={logOut}
|
||||
label="Log to terminal"
|
||||
onChange={() => setLogOut(!logOut)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup inline={true} label="Log Level">
|
||||
<HTMLSelect
|
||||
options={["Debug", "Info", "Warning", "Error"]}
|
||||
onChange={(event) => setLogLevel(event.target.value)}
|
||||
value={logLevel}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
helperText="Logs http access to the terminal. Requires restart."
|
||||
>
|
||||
<Checkbox
|
||||
checked={logAccess}
|
||||
label="Log http access"
|
||||
onChange={() => setLogAccess(!logAccess)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<Divider />
|
||||
<Button intent="primary" onClick={() => onSave()}>Save</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FormGroup,
|
||||
H4,
|
||||
Spinner,
|
||||
TextArea,
|
||||
NumericInput
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import { StashService } from "../../core/StashService";
|
||||
import { ErrorUtils } from "../../utils/errors";
|
||||
import { ToastUtils } from "../../utils/toasts";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
|
||||
const config = StashService.useConfiguration();
|
||||
const [soundOnPreview, setSoundOnPreview] = useState<boolean>();
|
||||
const [wallShowTitle, setWallShowTitle] = useState<boolean>();
|
||||
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
|
||||
const [autostartVideo, setAutostartVideo] = useState<boolean>();
|
||||
const [showStudioAsText, setShowStudioAsText] = useState<boolean>();
|
||||
const [css, setCSS] = useState<string>();
|
||||
const [cssEnabled, setCSSEnabled] = useState<boolean>();
|
||||
|
||||
const updateInterfaceConfig = StashService.useConfigureInterface({
|
||||
soundOnPreview,
|
||||
wallShowTitle,
|
||||
maximumLoopDuration,
|
||||
autostartVideo,
|
||||
showStudioAsText,
|
||||
css,
|
||||
cssEnabled
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.data || !config.data.configuration || !!config.error) { return; }
|
||||
if (!!config.data.configuration.interface) {
|
||||
let iCfg = config.data.configuration.interface;
|
||||
setSoundOnPreview(iCfg.soundOnPreview !== undefined ? iCfg.soundOnPreview : true);
|
||||
setWallShowTitle(iCfg.wallShowTitle !== undefined ? iCfg.wallShowTitle : true);
|
||||
setMaximumLoopDuration(iCfg.maximumLoopDuration || 0);
|
||||
setAutostartVideo(iCfg.autostartVideo !== undefined ? iCfg.autostartVideo : false);
|
||||
setShowStudioAsText(iCfg.showStudioAsText !== undefined ? iCfg.showStudioAsText : false);
|
||||
setCSS(config.data.configuration.interface.css || "");
|
||||
setCSSEnabled(config.data.configuration.interface.cssEnabled || false);
|
||||
}
|
||||
}, [config.data, config.error]);
|
||||
|
||||
async function onSave() {
|
||||
try {
|
||||
const result = await updateInterfaceConfig();
|
||||
console.log(result);
|
||||
ToastUtils.success("Updated config");
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!config.error ? <h1>{config.error.message}</h1> : undefined}
|
||||
{(!config.data || !config.data.configuration || config.loading) ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
||||
<H4>User Interface</H4>
|
||||
<FormGroup
|
||||
label="Scene / Marker Wall"
|
||||
helperText="Configuration for wall items"
|
||||
>
|
||||
<Checkbox
|
||||
checked={wallShowTitle}
|
||||
label="Display title and tags"
|
||||
onChange={() => setWallShowTitle(!wallShowTitle)}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={soundOnPreview}
|
||||
label="Enable sound"
|
||||
onChange={() => setSoundOnPreview(!soundOnPreview)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Scene List"
|
||||
>
|
||||
<Checkbox
|
||||
checked={showStudioAsText}
|
||||
label="Show Studios as text"
|
||||
onChange={() => {
|
||||
setShowStudioAsText(!showStudioAsText)
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Scene Player"
|
||||
>
|
||||
<Checkbox
|
||||
checked={autostartVideo}
|
||||
label="Auto-start video"
|
||||
onChange={() => {
|
||||
setAutostartVideo(!autostartVideo)
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormGroup
|
||||
label="Maximum loop duration"
|
||||
helperText="Maximum scene duration - in seconds - where scene player will loop the video - 0 to disable"
|
||||
>
|
||||
<NumericInput
|
||||
value={maximumLoopDuration}
|
||||
type="number"
|
||||
onValueChange={(value: number) => setMaximumLoopDuration(value)}
|
||||
min={0}
|
||||
minorStepSize={1}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
label="Custom CSS"
|
||||
helperText="Page must be reloaded for changes to take effect."
|
||||
>
|
||||
<Checkbox
|
||||
checked={cssEnabled}
|
||||
label="Custom CSS enabled"
|
||||
onChange={() => {
|
||||
setCSSEnabled(!cssEnabled)
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
value={css}
|
||||
onChange={(e: any) => setCSS(e.target.value)}
|
||||
fill={true}
|
||||
rows={16}>
|
||||
</TextArea>
|
||||
</FormGroup>
|
||||
|
||||
<Divider />
|
||||
<Button intent="primary" onClick={() => onSave()}>Save</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
import {
|
||||
H4, FormGroup, HTMLSelect,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useState, useEffect, useRef } from "react";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { StashService } from "../../core/StashService";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
function convertTime(logEntry : GQL.LogEntryDataFragment) {
|
||||
function pad(val : number) {
|
||||
var ret = val.toString();
|
||||
if (val <= 9) {
|
||||
ret = "0" + ret;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
var date = new Date(logEntry.time);
|
||||
var month = date.getMonth() + 1;
|
||||
var day = date.getDate();
|
||||
var dateStr = date.getFullYear() + "-" + pad(month) + "-" + pad(day);
|
||||
dateStr += " " + pad(date.getHours()) + ":" + pad(date.getMinutes()) + ":" + pad(date.getSeconds());
|
||||
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
class LogEntry {
|
||||
public time: string;
|
||||
public level: string;
|
||||
public message: string;
|
||||
public id: string;
|
||||
|
||||
private static nextId: number = 0;
|
||||
|
||||
public constructor(logEntry: GQL.LogEntryDataFragment) {
|
||||
this.time = convertTime(logEntry);
|
||||
this.level = logEntry.level;
|
||||
this.message = logEntry.message;
|
||||
|
||||
var id = LogEntry.nextId++;
|
||||
this.id = id.toString();
|
||||
}
|
||||
}
|
||||
|
||||
export const SettingsLogsPanel: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const { data, error } = StashService.useLoggingSubscribe();
|
||||
const { data: existingData } = StashService.useLogs();
|
||||
|
||||
const logEntries = useRef<LogEntry[]>([]);
|
||||
const [logLevel, setLogLevel] = useState<string>("Info");
|
||||
const [filteredLogEntries, setFilteredLogEntries] = useState<LogEntry[]>([]);
|
||||
const lastUpdate = useRef<number>(0);
|
||||
const updateTimeout = useRef<NodeJS.Timeout>();
|
||||
|
||||
// maximum number of log entries to display. Subsequent entries will truncate
|
||||
// the list, dropping off the oldest entries first.
|
||||
const MAX_LOG_ENTRIES = 200;
|
||||
|
||||
function truncateLogEntries(entries : LogEntry[]) {
|
||||
entries.length = Math.min(entries.length, MAX_LOG_ENTRIES);
|
||||
}
|
||||
|
||||
function prependLogEntries(toPrepend : LogEntry[]) {
|
||||
var newLogEntries = toPrepend.concat(logEntries.current);
|
||||
truncateLogEntries(newLogEntries);
|
||||
logEntries.current = newLogEntries;
|
||||
}
|
||||
|
||||
function appendLogEntries(toAppend : LogEntry[]) {
|
||||
var newLogEntries = logEntries.current.concat(toAppend);
|
||||
truncateLogEntries(newLogEntries);
|
||||
logEntries.current = newLogEntries;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) { return; }
|
||||
|
||||
// append data to the logEntries
|
||||
var convertedData = data.loggingSubscribe.map(convertLogEntry);
|
||||
|
||||
// filter subscribed data as it comes in, otherwise we'll end up
|
||||
// truncating stuff that wasn't filtered out
|
||||
convertedData = convertedData.filter(filterByLogLevel)
|
||||
|
||||
// put newest entries at the top
|
||||
convertedData.reverse();
|
||||
prependLogEntries(convertedData);
|
||||
|
||||
updateFilteredEntries();
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!existingData || !existingData.logs) { return; }
|
||||
|
||||
var convertedData = existingData.logs.map(convertLogEntry);
|
||||
appendLogEntries(convertedData);
|
||||
|
||||
updateFilteredEntries();
|
||||
}, [existingData]);
|
||||
|
||||
function updateFilteredEntries() {
|
||||
if (!updateTimeout.current) {
|
||||
console.log("Updating after timeout");
|
||||
}
|
||||
updateTimeout.current = undefined;
|
||||
|
||||
var filteredEntries = logEntries.current.filter(filterByLogLevel);
|
||||
setFilteredLogEntries(filteredEntries);
|
||||
|
||||
lastUpdate.current = new Date().getTime();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateFilteredEntries();
|
||||
}, [logLevel]);
|
||||
|
||||
function convertLogEntry(logEntry : GQL.LogEntryDataFragment) {
|
||||
return new LogEntry(logEntry);
|
||||
}
|
||||
|
||||
function levelClass(level : string) {
|
||||
return level.toLowerCase().trim();
|
||||
}
|
||||
|
||||
interface ILogElementProps {
|
||||
logEntry : LogEntry
|
||||
}
|
||||
|
||||
function LogElement(props : ILogElementProps) {
|
||||
// pad to maximum length of level enum
|
||||
var level = props.logEntry.level.padEnd(GQL.LogLevel.Progress.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span>{props.logEntry.time}</span>
|
||||
<span className={levelClass(props.logEntry.level)}>{level}</span>
|
||||
<span>{props.logEntry.message}</span>
|
||||
<br/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderError() {
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<span className={"error"}>Error connecting to log server: {error.message}</span><br/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const logLevels = ["Debug", "Info", "Warning", "Error"];
|
||||
|
||||
function filterByLogLevel(logEntry : LogEntry) {
|
||||
if (logLevel === "Debug") {
|
||||
return true;
|
||||
}
|
||||
|
||||
var logLevelIndex = logLevels.indexOf(logLevel);
|
||||
var levelIndex = logLevels.indexOf(logEntry.level);
|
||||
|
||||
return levelIndex >= logLevelIndex;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<H4>Logs</H4>
|
||||
<div>
|
||||
<FormGroup inline={true} label="Log Level">
|
||||
<HTMLSelect
|
||||
options={logLevels}
|
||||
onChange={(event) => setLogLevel(event.target.value)}
|
||||
value={logLevel}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
<div className="logs">
|
||||
{maybeRenderError()}
|
||||
{filteredLogEntries.map((logEntry) =>
|
||||
<LogElement logEntry={logEntry} key={logEntry.id}/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormGroup,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useState } from "react";
|
||||
import { StashService } from "../../../core/StashService";
|
||||
import { ErrorUtils } from "../../../utils/errors";
|
||||
import { ToastUtils } from "../../../utils/toasts";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
export const GenerateButton: FunctionComponent<IProps> = () => {
|
||||
const [sprites, setSprites] = useState<boolean>(true);
|
||||
const [previews, setPreviews] = useState<boolean>(true);
|
||||
const [markers, setMarkers] = useState<boolean>(true);
|
||||
const [transcodes, setTranscodes] = useState<boolean>(true);
|
||||
|
||||
async function onGenerate() {
|
||||
try {
|
||||
await StashService.mutateMetadataGenerate({sprites, previews, markers, transcodes});
|
||||
ToastUtils.success("Started generating");
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
helperText="Generate supporting image, sprite, video, vtt and other files."
|
||||
labelFor="generate"
|
||||
inline={true}
|
||||
>
|
||||
<Checkbox checked={sprites} label="Sprites (for the scene scrubber)" onChange={() => setSprites(!sprites)} />
|
||||
<Checkbox
|
||||
checked={previews}
|
||||
label="Previews (video previews which play when hovering over a scene)"
|
||||
onChange={() => setPreviews(!previews)}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={markers}
|
||||
label="Markers (20 second videos which begin at the given timecode)"
|
||||
onChange={() => setMarkers(!markers)}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={transcodes}
|
||||
label="Transcodes (MP4 conversions of unsupported video formats)"
|
||||
onChange={() => setTranscodes(!transcodes)}
|
||||
/>
|
||||
<Button id="generate" text="Generate" onClick={() => onGenerate()} />
|
||||
</FormGroup>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FormGroup,
|
||||
H4,
|
||||
ProgressBar,
|
||||
H5,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useState, useEffect } from "react";
|
||||
import { StashService } from "../../../core/StashService";
|
||||
import { ErrorUtils } from "../../../utils/errors";
|
||||
import { ToastUtils } from "../../../utils/toasts";
|
||||
import { GenerateButton } from "./GenerateButton";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
export const SettingsTasksPanel: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const [isImportAlertOpen, setIsImportAlertOpen] = useState<boolean>(false);
|
||||
const [isCleanAlertOpen, setIsCleanAlertOpen] = useState<boolean>(false);
|
||||
const [useFileMetadata, setUseFileMetadata] = useState<boolean>(false);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [progress, setProgress] = useState<number | undefined>(undefined);
|
||||
|
||||
const [autoTagPerformers, setAutoTagPerformers] = useState<boolean>(true);
|
||||
const [autoTagStudios, setAutoTagStudios] = useState<boolean>(true);
|
||||
const [autoTagTags, setAutoTagTags] = useState<boolean>(true);
|
||||
|
||||
const jobStatus = StashService.useJobStatus();
|
||||
const metadataUpdate = StashService.useMetadataUpdate();
|
||||
|
||||
function statusToText(status : string) {
|
||||
switch(status) {
|
||||
case "Idle":
|
||||
return "Idle";
|
||||
case "Scan":
|
||||
return "Scanning for new content";
|
||||
case "Generate":
|
||||
return "Generating supporting files";
|
||||
case "Clean":
|
||||
return "Cleaning the database";
|
||||
case "Export":
|
||||
return "Exporting to JSON";
|
||||
case "Import":
|
||||
return "Importing from JSON";
|
||||
case "Auto Tag":
|
||||
return "Auto tagging scenes";
|
||||
}
|
||||
|
||||
return "Idle";
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!!jobStatus.data && !!jobStatus.data.jobStatus) {
|
||||
setStatus(statusToText(jobStatus.data.jobStatus.status));
|
||||
var newProgress = jobStatus.data.jobStatus.progress;
|
||||
if (newProgress < 0) {
|
||||
setProgress(undefined);
|
||||
} else {
|
||||
setProgress(newProgress);
|
||||
}
|
||||
}
|
||||
}, [jobStatus.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!!metadataUpdate.data && !!metadataUpdate.data.metadataUpdate) {
|
||||
setStatus(statusToText(metadataUpdate.data.metadataUpdate.status));
|
||||
var newProgress = metadataUpdate.data.metadataUpdate.progress;
|
||||
if (newProgress < 0) {
|
||||
setProgress(undefined);
|
||||
} else {
|
||||
setProgress(newProgress);
|
||||
}
|
||||
}
|
||||
}, [metadataUpdate.data]);
|
||||
|
||||
function onImport() {
|
||||
setIsImportAlertOpen(false);
|
||||
StashService.mutateMetadataImport().then(() => { jobStatus.refetch()});
|
||||
}
|
||||
|
||||
function renderImportAlert() {
|
||||
return (
|
||||
<Alert
|
||||
cancelButtonText="Cancel"
|
||||
confirmButtonText="Import"
|
||||
icon="trash"
|
||||
intent="danger"
|
||||
isOpen={isImportAlertOpen}
|
||||
onCancel={() => setIsImportAlertOpen(false)}
|
||||
onConfirm={() => onImport()}
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to import? This will delete the database and re-import from
|
||||
your exported metadata.
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function onClean() {
|
||||
setIsCleanAlertOpen(false);
|
||||
StashService.mutateMetadataClean().then(() => { jobStatus.refetch()});
|
||||
}
|
||||
|
||||
function renderCleanAlert() {
|
||||
return (
|
||||
<Alert
|
||||
cancelButtonText="Cancel"
|
||||
confirmButtonText="Clean"
|
||||
icon="trash"
|
||||
intent="danger"
|
||||
isOpen={isCleanAlertOpen}
|
||||
onCancel={() => setIsCleanAlertOpen(false)}
|
||||
onConfirm={() => onClean()}
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to Clean?
|
||||
This will delete db information and generated content
|
||||
for all scenes that are no longer found in the filesystem.
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
async function onScan() {
|
||||
try {
|
||||
await StashService.mutateMetadataScan({useFileMetadata: useFileMetadata});
|
||||
ToastUtils.success("Started scan");
|
||||
jobStatus.refetch();
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
}
|
||||
|
||||
function getAutoTagInput() {
|
||||
var wildcard = ["*"];
|
||||
return {
|
||||
performers: autoTagPerformers ? wildcard : [],
|
||||
studios: autoTagStudios ? wildcard : [],
|
||||
tags: autoTagTags ? wildcard : []
|
||||
}
|
||||
}
|
||||
|
||||
async function onAutoTag() {
|
||||
try {
|
||||
await StashService.mutateMetadataAutoTag(getAutoTagInput());
|
||||
ToastUtils.success("Started auto tagging");
|
||||
jobStatus.refetch();
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderStop() {
|
||||
if (!status || status === "Idle") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormGroup>
|
||||
<Button id="stop" text="Stop" intent="danger" onClick={() => StashService.mutateStopJob().then(() => jobStatus.refetch())} />
|
||||
</FormGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderJobStatus() {
|
||||
return (
|
||||
<>
|
||||
<FormGroup>
|
||||
<H5>Status: {status}</H5>
|
||||
{!!status && status !== "Idle" ? <ProgressBar value={progress}/> : undefined}
|
||||
</FormGroup>
|
||||
{maybeRenderStop()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderImportAlert()}
|
||||
{renderCleanAlert()}
|
||||
|
||||
<H4>Running Jobs</H4>
|
||||
|
||||
{renderJobStatus()}
|
||||
|
||||
<Divider/>
|
||||
|
||||
<H4>Library</H4>
|
||||
<FormGroup
|
||||
helperText="Scan for new content and add it to the database."
|
||||
labelFor="scan"
|
||||
inline={true}
|
||||
>
|
||||
<Checkbox
|
||||
checked={useFileMetadata}
|
||||
label="Set name, date, details from metadata (if present)"
|
||||
onChange={() => setUseFileMetadata(!useFileMetadata)}
|
||||
/>
|
||||
<Button id="scan" text="Scan" onClick={() => onScan()} />
|
||||
</FormGroup>
|
||||
|
||||
<Divider />
|
||||
|
||||
<H4>Auto Tagging</H4>
|
||||
|
||||
<FormGroup
|
||||
helperText="Auto-tag content based on filenames."
|
||||
labelFor="autoTag"
|
||||
inline={true}
|
||||
>
|
||||
<Checkbox
|
||||
checked={autoTagPerformers}
|
||||
label="Performers"
|
||||
onChange={() => setAutoTagPerformers(!autoTagPerformers)}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={autoTagStudios}
|
||||
label="Studios"
|
||||
onChange={() => setAutoTagStudios(!autoTagStudios)}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={autoTagTags}
|
||||
label="Tags"
|
||||
onChange={() => setAutoTagTags(!autoTagTags)}
|
||||
/>
|
||||
<Button id="autoTag" text="Auto Tag" onClick={() => onAutoTag()} />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<Link className="bp3-button" to={"/sceneFilenameParser"}>
|
||||
Scene Filename Parser
|
||||
</Link>
|
||||
</FormGroup>
|
||||
<Divider />
|
||||
|
||||
<H4>Generated Content</H4>
|
||||
<GenerateButton />
|
||||
<FormGroup
|
||||
helperText="Check for missing files and remove them from the database. This is a destructive action."
|
||||
labelFor="clean"
|
||||
inline={true}
|
||||
>
|
||||
<Button id="clean" text="Clean" intent="danger" onClick={() => setIsCleanAlertOpen(true)} />
|
||||
</FormGroup>
|
||||
<Divider />
|
||||
|
||||
<H4>Metadata</H4>
|
||||
<FormGroup
|
||||
helperText="Export the database content into JSON format"
|
||||
labelFor="export"
|
||||
inline={true}
|
||||
>
|
||||
<Button id="export" text="Export" onClick={() => StashService.mutateMetadataExport().then(() => { jobStatus.refetch()})} />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
helperText="Import from exported JSON. This is a destructive action."
|
||||
labelFor="import"
|
||||
inline={true}
|
||||
>
|
||||
<Button id="import" text="Import" intent="danger" onClick={() => setIsImportAlertOpen(true)} />
|
||||
</FormGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
import {
|
||||
Alert,
|
||||
Button,
|
||||
FileInput,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Navbar,
|
||||
NavbarDivider,
|
||||
Popover,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { NavigationUtils } from "../../utils/navigation";
|
||||
|
||||
interface IProps {
|
||||
performer?: Partial<GQL.PerformerDataFragment>;
|
||||
studio?: Partial<GQL.StudioDataFragment>;
|
||||
movie?: Partial<GQL.MovieDataFragment>;
|
||||
isNew: boolean;
|
||||
isEditing: boolean;
|
||||
onToggleEdit: () => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
onAutoTag?: () => void;
|
||||
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
|
||||
onBackImageChange?: (event: React.FormEvent<HTMLInputElement>) => void;
|
||||
|
||||
// TODO: only for performers. make generic
|
||||
scrapers?: GQL.ListPerformerScrapersListPerformerScrapers[];
|
||||
onDisplayScraperDialog?: (scraper: GQL.ListPerformerScrapersListPerformerScrapers) => void;
|
||||
}
|
||||
|
||||
export const DetailsEditNavbar: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
|
||||
function renderEditButton() {
|
||||
if (props.isNew) { return; }
|
||||
return (
|
||||
<Button
|
||||
intent="primary"
|
||||
text={props.isEditing ? "Cancel" : "Edit"}
|
||||
onClick={() => props.onToggleEdit()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSaveButton() {
|
||||
if (!props.isEditing) { return; }
|
||||
return <Button intent="success" text="Save" onClick={() => props.onSave()} />;
|
||||
}
|
||||
|
||||
function renderDeleteButton() {
|
||||
if (props.isNew || props.isEditing) { return; }
|
||||
return <Button intent="danger" text="Delete" onClick={() => setIsDeleteAlertOpen(true)} />;
|
||||
}
|
||||
|
||||
function renderImageInput() {
|
||||
if (!props.isEditing) { return; }
|
||||
return <FileInput text="Choose image..." onInputChange={props.onImageChange} inputProps={{accept: ".jpg,.jpeg"}} />;
|
||||
}
|
||||
|
||||
function renderBackImageInput() {
|
||||
if (!props.movie) { return; }
|
||||
if (!props.isEditing) { return; }
|
||||
return <FileInput text="Choose back image..." onInputChange={props.onBackImageChange} inputProps={{accept: ".jpg,.jpeg"}} />;
|
||||
}
|
||||
|
||||
function renderScraperMenuItem(scraper : GQL.ListPerformerScrapersListPerformerScrapers) {
|
||||
return (
|
||||
<MenuItem
|
||||
text={scraper.name}
|
||||
onClick={() => { if (props.onDisplayScraperDialog) { props.onDisplayScraperDialog(scraper); }}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderScraperMenu() {
|
||||
if (!props.performer) { return; }
|
||||
if (!props.isEditing) { return; }
|
||||
const scraperMenu = (
|
||||
<Menu>
|
||||
{props.scrapers ? props.scrapers.map((s) => renderScraperMenuItem(s)) : undefined}
|
||||
</Menu>
|
||||
);
|
||||
return (
|
||||
<Popover content={scraperMenu} position="bottom">
|
||||
<Button text="Scrape with..."/>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAutoTagButton() {
|
||||
if (props.isNew || props.isEditing) { return; }
|
||||
if (!!props.onAutoTag) {
|
||||
return (<Button text="Auto Tag" onClick={() => {
|
||||
if (props.onAutoTag) { props.onAutoTag() }
|
||||
}}></Button>)
|
||||
}
|
||||
}
|
||||
|
||||
function renderScenesButton() {
|
||||
if (props.isEditing) { return; }
|
||||
let linkSrc: string = "#";
|
||||
if (!!props.performer) {
|
||||
linkSrc = NavigationUtils.makePerformerScenesUrl(props.performer);
|
||||
} else if (!!props.studio) {
|
||||
linkSrc = NavigationUtils.makeStudioScenesUrl(props.studio);
|
||||
} else if (!!props.movie) {
|
||||
linkSrc = NavigationUtils.makeMovieScenesUrl(props.movie);
|
||||
}
|
||||
return (
|
||||
<Link className="bp3-button" to={linkSrc}>
|
||||
Scenes
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
var name;
|
||||
|
||||
if (props.performer) {
|
||||
name = props.performer.name;
|
||||
}
|
||||
if (props.studio) {
|
||||
name = props.studio.name;
|
||||
}
|
||||
if (props.movie) {
|
||||
name = props.movie.name;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
cancelButtonText="Cancel"
|
||||
confirmButtonText="Delete"
|
||||
icon="trash"
|
||||
intent="danger"
|
||||
isOpen={isDeleteAlertOpen}
|
||||
onCancel={() => setIsDeleteAlertOpen(false)}
|
||||
onConfirm={() => props.onDelete()}
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to delete {name}?
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderDeleteAlert()}
|
||||
<Navbar>
|
||||
<Navbar.Group>
|
||||
{renderEditButton()}
|
||||
{props.isEditing && !props.isNew ? <NavbarDivider /> : undefined}
|
||||
{renderScraperMenu()}
|
||||
{renderImageInput()}
|
||||
{renderBackImageInput()}
|
||||
{renderSaveButton()}
|
||||
|
||||
{renderAutoTagButton()}
|
||||
{renderScenesButton()}
|
||||
{renderDeleteButton()}
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import React, { FunctionComponent, useState, useEffect } from "react";
|
||||
import { InputGroup, ButtonGroup, Button, HTMLInputProps, ControlGroup } from "@blueprintjs/core";
|
||||
import { DurationUtils } from "../../utils/duration";
|
||||
import { FIXED, NUMERIC_INPUT } from "@blueprintjs/core/lib/esm/common/classes";
|
||||
|
||||
interface IProps {
|
||||
disabled?: boolean
|
||||
numericValue: number
|
||||
onValueChange(valueAsNumber: number): void
|
||||
onReset?(): void
|
||||
}
|
||||
|
||||
export const DurationInput: FunctionComponent<HTMLInputProps & IProps> = (props: IProps) => {
|
||||
const [value, setValue] = useState<string>(DurationUtils.secondsToString(props.numericValue));
|
||||
|
||||
useEffect(() => {
|
||||
setValue(DurationUtils.secondsToString(props.numericValue));
|
||||
}, [props.numericValue]);
|
||||
|
||||
function increment() {
|
||||
let seconds = DurationUtils.stringToSeconds(value);
|
||||
seconds += 1;
|
||||
props.onValueChange(seconds);
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
let seconds = DurationUtils.stringToSeconds(value);
|
||||
seconds -= 1;
|
||||
props.onValueChange(seconds);
|
||||
}
|
||||
|
||||
function renderButtons() {
|
||||
return (
|
||||
<ButtonGroup
|
||||
vertical={true}
|
||||
className={FIXED}
|
||||
>
|
||||
<Button
|
||||
icon="chevron-up"
|
||||
disabled={props.disabled}
|
||||
onClick={() => increment()}
|
||||
/>
|
||||
<Button
|
||||
icon="chevron-down"
|
||||
disabled={props.disabled}
|
||||
onClick={() => decrement()}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
if (props.onReset) {
|
||||
props.onReset();
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderReset() {
|
||||
if (props.onReset) {
|
||||
return (
|
||||
<Button
|
||||
icon="time"
|
||||
onClick={() => onReset()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlGroup className={NUMERIC_INPUT}>
|
||||
<InputGroup
|
||||
disabled={props.disabled}
|
||||
value={value}
|
||||
onChange={(e : any) => setValue(e.target.value)}
|
||||
onBlur={() => props.onValueChange(DurationUtils.stringToSeconds(value))}
|
||||
placeholder="hh:mm:ss"
|
||||
rightElement={maybeRenderReset()}
|
||||
/>
|
||||
{renderButtons()}
|
||||
</ControlGroup>
|
||||
)
|
||||
};
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
Classes,
|
||||
Dialog,
|
||||
InputGroup,
|
||||
Spinner,
|
||||
FormGroup,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import { StashService } from "../../../core/StashService";
|
||||
|
||||
interface IProps {
|
||||
directories: string[];
|
||||
onDirectoriesChanged: (directories: string[]) => void;
|
||||
}
|
||||
|
||||
export const FolderSelect: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const [currentDirectory, setCurrentDirectory] = useState<string>("");
|
||||
const [isDisplayingDialog, setIsDisplayingDialog] = useState<boolean>(false);
|
||||
const [selectableDirectories, setSelectableDirectories] = useState<string[]>([]);
|
||||
const [selectedDirectories, setSelectedDirectories] = useState<string[]>([]);
|
||||
const { data, error, loading } = StashService.useDirectories(currentDirectory);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedDirectories(props.directories);
|
||||
}, [props.directories]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !data.directories || !!error) { return; }
|
||||
setSelectableDirectories(StashService.nullToUndefined(data.directories));
|
||||
}, [data, error]);
|
||||
|
||||
function onSelectDirectory() {
|
||||
selectedDirectories.push(currentDirectory);
|
||||
setSelectedDirectories(selectedDirectories);
|
||||
setCurrentDirectory("");
|
||||
setIsDisplayingDialog(false);
|
||||
props.onDirectoriesChanged(selectedDirectories);
|
||||
}
|
||||
|
||||
function onRemoveDirectory(directory: string) {
|
||||
const newSelectedDirectories = selectedDirectories.filter((dir) => dir !== directory);
|
||||
setSelectedDirectories(newSelectedDirectories);
|
||||
props.onDirectoriesChanged(newSelectedDirectories);
|
||||
}
|
||||
|
||||
function renderDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isDisplayingDialog}
|
||||
onClose={() => setIsDisplayingDialog(false)}
|
||||
title="Select Directory"
|
||||
>
|
||||
<div className="dialog-content">
|
||||
<InputGroup
|
||||
large={true}
|
||||
placeholder="File path"
|
||||
onChange={(e: any) => setCurrentDirectory(e.target.value)}
|
||||
value={currentDirectory}
|
||||
rightElement={(!data || !data.directories || loading) ? <Spinner size={Spinner.SIZE_SMALL} /> : undefined}
|
||||
/>
|
||||
{selectableDirectories.map((path) => {
|
||||
return <div key={path} onClick={() => setCurrentDirectory(path)}>{path}</div>;
|
||||
})}
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Button onClick={() => onSelectDirectory()}>Add</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!error ? <h1>{error.message}</h1> : undefined}
|
||||
{renderDialog()}
|
||||
<FormGroup>
|
||||
{selectedDirectories.map((path) => {
|
||||
return <div key={path}>{path} <button className="button-link" onClick={() => onRemoveDirectory(path)}>Remove</button></div>;
|
||||
})}
|
||||
</FormGroup>
|
||||
|
||||
<Button small={true} onClick={() => setIsDisplayingDialog(true)}>Add Directory</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import {
|
||||
ITagProps,
|
||||
Tag,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { MovieDataFragment,PerformerDataFragment, SceneMarkerDataFragment, TagDataFragment } from "../../core/generated-graphql";
|
||||
import { NavigationUtils } from "../../utils/navigation";
|
||||
import { TextUtils } from "../../utils/text";
|
||||
|
||||
interface IProps extends ITagProps {
|
||||
tag?: Partial<TagDataFragment>;
|
||||
performer?: Partial<PerformerDataFragment>;
|
||||
movie?: Partial<MovieDataFragment>;
|
||||
marker?: Partial<SceneMarkerDataFragment>;
|
||||
}
|
||||
|
||||
export const TagLink: FunctionComponent<IProps> = (props: IProps) => {
|
||||
let link: string = "#";
|
||||
let title: string = "";
|
||||
if (!!props.tag) {
|
||||
link = NavigationUtils.makeTagScenesUrl(props.tag);
|
||||
title = props.tag.name || "";
|
||||
} else if (!!props.performer) {
|
||||
link = NavigationUtils.makePerformerScenesUrl(props.performer);
|
||||
title = props.performer.name || "";
|
||||
} else if (!!props.movie) {
|
||||
link = NavigationUtils.makeMovieScenesUrl(props.movie);
|
||||
title = props.movie.name || "";
|
||||
} else if (!!props.marker) {
|
||||
link = NavigationUtils.makeSceneMarkerUrl(props.marker);
|
||||
title = `${props.marker.title} - ${TextUtils.secondsToTimestamp(props.marker.seconds || 0)}`;
|
||||
}
|
||||
return (
|
||||
<Tag
|
||||
className="tag-item"
|
||||
interactive={true}
|
||||
>
|
||||
<Link to={link}>{title}</Link>
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import {
|
||||
MenuItem,
|
||||
Menu,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { IMenuItem } from "../App";
|
||||
|
||||
interface IProps {
|
||||
className: string
|
||||
menuItems: IMenuItem[]
|
||||
}
|
||||
|
||||
export const Sidebar: FunctionComponent<IProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<div className={"sidebar" + props.className}>
|
||||
<Menu large={true}>
|
||||
{props.menuItems.map((i) => {
|
||||
return (
|
||||
<MenuItem
|
||||
icon={i.icon}
|
||||
text={i.text}
|
||||
href={i.href}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Menu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { Spinner } from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { StashService } from "../core/StashService";
|
||||
|
||||
export const Stats: FunctionComponent = () => {
|
||||
const { data, error, loading } = StashService.useStats();
|
||||
|
||||
function renderStats() {
|
||||
if (!data || !data.stats) { return; }
|
||||
return (
|
||||
<nav id="details-container" className="level stats">
|
||||
<div className="level-item has-text-centered">
|
||||
<div>
|
||||
<p className="title">{data.stats.scene_size_count}</p>
|
||||
<p className="heading">Library size</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="level-item has-text-centered">
|
||||
<div>
|
||||
<p className="title">{data.stats.scene_count}</p>
|
||||
<p className="heading">Scenes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="level-item has-text-centered">
|
||||
<div>
|
||||
<p className="title">{data.stats.movie_count}</p>
|
||||
<p className="heading">Movies</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="level-item has-text-centered">
|
||||
<div>
|
||||
<p className="title">{data.stats.gallery_count}</p>
|
||||
<p className="heading">Galleries</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="level-item has-text-centered">
|
||||
<div>
|
||||
<p className="title">{data.stats.performer_count}</p>
|
||||
<p className="heading">Performers</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="level-item has-text-centered">
|
||||
<div>
|
||||
<p className="title">{data.stats.studio_count}</p>
|
||||
<p className="heading">Studios</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="level-item has-text-centered">
|
||||
<div>
|
||||
<p className="title">{data.stats.tag_count}</p>
|
||||
<p className="heading">Tags</p>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="details-container">
|
||||
{!data || loading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
||||
{!!error ? <span>error.message</span> : undefined}
|
||||
{renderStats()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import {
|
||||
Card,
|
||||
Elevation,
|
||||
H4,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
|
||||
interface IProps {
|
||||
studio: GQL.StudioDataFragment;
|
||||
}
|
||||
|
||||
export const StudioCard: FunctionComponent<IProps> = (props: IProps) => {
|
||||
return (
|
||||
<Card
|
||||
className="grid-item"
|
||||
elevation={Elevation.ONE}
|
||||
>
|
||||
<Link
|
||||
to={`/studios/${props.studio.id}`}
|
||||
className="studio previewable image"
|
||||
style={{backgroundImage: `url(${props.studio.image_path})`}}
|
||||
/>
|
||||
<div className="card-section">
|
||||
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
|
||||
{props.studio.name}
|
||||
</H4>
|
||||
<span className="bp3-text-muted block">{props.studio.scene_count} scenes.</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
import {
|
||||
EditableText,
|
||||
HTMLTable,
|
||||
Spinner,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { StashService } from "../../../core/StashService";
|
||||
import { IBaseProps } from "../../../models";
|
||||
import { ErrorUtils } from "../../../utils/errors";
|
||||
import { TableUtils } from "../../../utils/table";
|
||||
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
|
||||
import { ToastUtils } from "../../../utils/toasts";
|
||||
import { ImageUtils } from "../../../utils/image";
|
||||
|
||||
interface IProps extends IBaseProps {}
|
||||
|
||||
export const Studio: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const isNew = props.match.params.id === "new";
|
||||
|
||||
// Editing state
|
||||
const [isEditing, setIsEditing] = useState<boolean>(isNew);
|
||||
|
||||
// Editing studio state
|
||||
const [image, setImage] = useState<string | undefined>(undefined);
|
||||
const [name, setName] = useState<string | undefined>(undefined);
|
||||
const [url, setUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
// Studio state
|
||||
const [studio, setStudio] = useState<Partial<GQL.StudioDataFragment>>({});
|
||||
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined);
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { data, error, loading } = StashService.useFindStudio(props.match.params.id);
|
||||
const updateStudio = StashService.useStudioUpdate(getStudioInput() as GQL.StudioUpdateInput);
|
||||
const createStudio = StashService.useStudioCreate(getStudioInput() as GQL.StudioCreateInput);
|
||||
const deleteStudio = StashService.useStudioDestroy(getStudioInput() as GQL.StudioDestroyInput);
|
||||
|
||||
function updateStudioEditState(state: Partial<GQL.StudioDataFragment>) {
|
||||
setName(state.name);
|
||||
setUrl(state.url);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(loading);
|
||||
if (!data || !data.findStudio || !!error) { return; }
|
||||
setStudio(data.findStudio);
|
||||
}, [data, loading, error]);
|
||||
|
||||
useEffect(() => {
|
||||
setImagePreview(studio.image_path);
|
||||
setImage(undefined);
|
||||
updateStudioEditState(studio);
|
||||
if (!isNew) {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [studio, isNew]);
|
||||
|
||||
function onImageLoad(this: FileReader) {
|
||||
setImagePreview(this.result as string);
|
||||
setImage(this.result as string);
|
||||
}
|
||||
|
||||
ImageUtils.addPasteImageHook(onImageLoad);
|
||||
|
||||
if (!isNew && !isEditing) {
|
||||
if (!data || !data.findStudio || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
|
||||
if (!!error) { return <>error...</>; }
|
||||
}
|
||||
|
||||
function getStudioInput() {
|
||||
const input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput> = {
|
||||
name,
|
||||
url,
|
||||
image,
|
||||
};
|
||||
|
||||
if (!isNew) {
|
||||
(input as GQL.StudioUpdateInput).id = props.match.params.id;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (!isNew) {
|
||||
const result = await updateStudio();
|
||||
if (image) {
|
||||
// Refetch image to bust browser cache
|
||||
await fetch(`/studio/${result.data.studioUpdate.id}/image`, { cache: "reload" });
|
||||
}
|
||||
setStudio(result.data.studioUpdate);
|
||||
} else {
|
||||
const result = await createStudio();
|
||||
setStudio(result.data.studioCreate);
|
||||
props.history.push(`/studios/${result.data.studioCreate.id}`);
|
||||
}
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function onAutoTag() {
|
||||
if (!studio || !studio.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await StashService.mutateMetadataAutoTag({ studios: [studio.id]});
|
||||
ToastUtils.success("Started auto tagging");
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await deleteStudio();
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
// redirect to studios page
|
||||
props.history.push(`/studios`);
|
||||
}
|
||||
|
||||
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||
ImageUtils.onImageChange(event, onImageLoad);
|
||||
}
|
||||
|
||||
// TODO: CSS class
|
||||
return (
|
||||
<>
|
||||
<div className="columns is-multiline no-spacing">
|
||||
<div className="column is-half details-image-container">
|
||||
<img alt={name} className="studio" src={imagePreview} />
|
||||
</div>
|
||||
<div className="column is-half details-detail-container">
|
||||
<DetailsEditNavbar
|
||||
studio={studio}
|
||||
isNew={isNew}
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={() => { setIsEditing(!isEditing); updateStudioEditState(studio); }}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
onAutoTag={onAutoTag}
|
||||
onImageChange={onImageChange}
|
||||
/>
|
||||
<h1 className="bp3-heading">
|
||||
<EditableText
|
||||
disabled={!isEditing}
|
||||
value={name}
|
||||
placeholder="Name"
|
||||
onChange={(value) => setName(value)}
|
||||
/>
|
||||
</h1>
|
||||
|
||||
<HTMLTable style={{width: "100%"}}>
|
||||
<tbody>
|
||||
{TableUtils.renderInputGroup({title: "URL", value: url, isEditing, onChange: setUrl})}
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { QueryHookResult } from "react-apollo-hooks";
|
||||
import { FindStudiosQuery, FindStudiosVariables } from "../../core/generated-graphql";
|
||||
import { ListHook } from "../../hooks/ListHook";
|
||||
import { IBaseProps } from "../../models/base-props";
|
||||
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
|
||||
import { StudioCard } from "./StudioCard";
|
||||
|
||||
interface IProps extends IBaseProps {}
|
||||
|
||||
export const StudioList: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const listData = ListHook.useList({
|
||||
filterMode: FilterMode.Studios,
|
||||
props,
|
||||
renderContent,
|
||||
});
|
||||
|
||||
function renderContent(result: QueryHookResult<FindStudiosQuery, FindStudiosVariables>, filter: ListFilterModel) {
|
||||
if (!result.data || !result.data.findStudios) { return; }
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<div className="grid">
|
||||
{result.data.findStudios.studios.map((studio) => (<StudioCard key={studio.id} studio={studio} />))}
|
||||
</div>
|
||||
);
|
||||
} else if (filter.displayMode === DisplayMode.List) {
|
||||
return <h1>TODO</h1>;
|
||||
} else if (filter.displayMode === DisplayMode.Wall) {
|
||||
return <h1>TODO</h1>;
|
||||
}
|
||||
}
|
||||
|
||||
return listData.template;
|
||||
};
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { Studio } from "./StudioDetails/Studio";
|
||||
import { StudioList } from "./StudioList";
|
||||
|
||||
const Studios = () => (
|
||||
<Switch>
|
||||
<Route exact={true} path="/studios" component={StudioList} />
|
||||
<Route path="/studios/:id" component={Studio} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
export default Studios;
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
import { Alert, Button, Classes, Dialog, FormGroup, InputGroup, Spinner } from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { StashService } from "../../core/StashService";
|
||||
import { IBaseProps } from "../../models/base-props";
|
||||
import { ErrorUtils } from "../../utils/errors";
|
||||
import { NavigationUtils } from "../../utils/navigation";
|
||||
import { ToastUtils } from "../../utils/toasts";
|
||||
|
||||
interface IProps extends IBaseProps {}
|
||||
|
||||
export const TagList: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const [tags, setTags] = useState<GQL.AllTagsAllTags[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Editing / New state
|
||||
const [editingTag, setEditingTag] = useState<Partial<GQL.TagDataFragment> | undefined>(undefined);
|
||||
const [deletingTag, setDeletingTag] = useState<Partial<GQL.TagDataFragment> | undefined>(undefined);
|
||||
const [name, setName] = useState<string>("");
|
||||
|
||||
const { data, error, loading } = StashService.useAllTags();
|
||||
const updateTag = StashService.useTagUpdate(getTagInput() as GQL.TagUpdateInput);
|
||||
const createTag = StashService.useTagCreate(getTagInput() as GQL.TagCreateInput);
|
||||
const deleteTag = StashService.useTagDestroy(getDeleteTagInput() as GQL.TagDestroyInput);
|
||||
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(loading);
|
||||
if (!data || !data.allTags || !!error) { return; }
|
||||
setTags(data.allTags);
|
||||
}, [data, loading, error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!!editingTag) {
|
||||
setName(editingTag.name || "");
|
||||
} else {
|
||||
setName("");
|
||||
}
|
||||
}, [editingTag]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDeleteAlertOpen(!!deletingTag);
|
||||
}, [deletingTag]);
|
||||
|
||||
function getTagInput() {
|
||||
const tagInput: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { name };
|
||||
if (!!editingTag) { (tagInput as Partial<GQL.TagUpdateInput>).id = editingTag.id; }
|
||||
return tagInput;
|
||||
}
|
||||
|
||||
function getDeleteTagInput() {
|
||||
const tagInput: Partial<GQL.TagDestroyInput> = {};
|
||||
if (!!deletingTag) { tagInput.id = deletingTag.id; }
|
||||
return tagInput;
|
||||
}
|
||||
|
||||
async function onEdit() {
|
||||
try {
|
||||
if (!!editingTag && !!editingTag.id) {
|
||||
await updateTag();
|
||||
ToastUtils.success("Updated tag");
|
||||
} else {
|
||||
await createTag();
|
||||
ToastUtils.success("Created tag");
|
||||
}
|
||||
setEditingTag(undefined);
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function onAutoTag(tag : GQL.TagDataFragment) {
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await StashService.mutateMetadataAutoTag({ tags: [tag.id]});
|
||||
ToastUtils.success("Started auto tagging");
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
try {
|
||||
await deleteTag();
|
||||
ToastUtils.success("Deleted tag");
|
||||
setDeletingTag(undefined);
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
return (
|
||||
<Alert
|
||||
cancelButtonText="Cancel"
|
||||
confirmButtonText="Delete"
|
||||
icon="trash"
|
||||
intent="danger"
|
||||
isOpen={isDeleteAlertOpen}
|
||||
onCancel={() => setDeletingTag(undefined)}
|
||||
onConfirm={() => onDelete()}
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to delete {deletingTag && deletingTag.name}?
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !data.allTags || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
|
||||
if (!!error) { return <>{error.message}</>; }
|
||||
|
||||
const tagElements = tags.map((tag) => {
|
||||
return (
|
||||
<>
|
||||
{renderDeleteAlert()}
|
||||
<div key={tag.id} className="tag-list-row">
|
||||
<span onClick={() => setEditingTag(tag)}>{tag.name}</span>
|
||||
<div style={{float: "right"}}>
|
||||
<Button text="Auto Tag" onClick={() => onAutoTag(tag)}></Button>
|
||||
<Link className="bp3-button" to={NavigationUtils.makeTagScenesUrl(tag)}>Scenes: {tag.scene_count}</Link>
|
||||
<Link className="bp3-button" to={NavigationUtils.makeTagSceneMarkersUrl(tag)}>
|
||||
Markers: {tag.scene_marker_count}
|
||||
</Link>
|
||||
<span>Total: {(tag.scene_count || 0) + (tag.scene_marker_count || 0)}</span>
|
||||
<Button intent="danger" icon="trash" onClick={() => setDeletingTag(tag)}></Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div id="tag-list-container">
|
||||
<Button intent="primary" style={{marginTop: "20px"}} onClick={() => setEditingTag({})}>New Tag</Button>
|
||||
<Dialog
|
||||
isOpen={!!editingTag}
|
||||
onClose={() => setEditingTag(undefined)}
|
||||
title={!!editingTag && !!editingTag.id ? "Edit Tag" : "New Tag"}
|
||||
>
|
||||
<div className="dialog-content">
|
||||
<FormGroup label="Name">
|
||||
<InputGroup
|
||||
onChange={(newValue: any) => setName(newValue.target.value)}
|
||||
value={name}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Button onClick={() => onEdit()}>{!!editingTag && !!editingTag.id ? "Update" : "Create"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{tagElements}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { TagList } from "./TagList";
|
||||
|
||||
const Tags = () => (
|
||||
<Switch>
|
||||
<Route exact={true} path="/tags" component={TagList} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
export default Tags;
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
.wall-overlay {
|
||||
background-color: rgba(0,0,0,.8);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
transition: transform .5s ease-in-out;
|
||||
}
|
||||
.visible {
|
||||
opacity: 1;
|
||||
transition: opacity .5s ease-in-out;
|
||||
}
|
||||
.hidden {
|
||||
opacity: 0;
|
||||
transition: opacity .5s ease-in-out;
|
||||
}
|
||||
.visible-unanimated {
|
||||
opacity: 1;
|
||||
}
|
||||
.hidden-unanimated {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
.double-scale {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
transform: scale(2);
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.double-scale img {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.scene-wall-item-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
// align-items: center;
|
||||
// overflow: hidden; // Commented out since it shows gaps in the wall
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform .5s;
|
||||
max-height: 253px;
|
||||
}
|
||||
|
||||
.scene-wall-item-container video {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.scene-wall-item-text-container {
|
||||
position: absolute;
|
||||
font-weight: 700;
|
||||
color: #444;
|
||||
padding: 5px;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
background: linear-gradient(rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0.65));
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
|
||||
& span {
|
||||
line-height: 1;
|
||||
font-weight: 400;
|
||||
font-size: 10px;
|
||||
margin: 0 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.scene-wall-item-blur {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
/*background-color: rgba(255, 255, 255, 0.75);*/
|
||||
/*backdrop-filter: blur(5px);*/
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.wall.grid-item video, .wall.grid-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.wall.grid-item {
|
||||
padding: 0 !important;
|
||||
line-height: 0;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import _ from "lodash";
|
||||
import React, { FunctionComponent, useRef, useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { VideoHoverHook } from "../../hooks/VideoHover";
|
||||
import { TextUtils } from "../../utils/text";
|
||||
import { NavigationUtils } from "../../utils/navigation";
|
||||
import { StashService } from "../../core/StashService";
|
||||
|
||||
interface IWallItemProps {
|
||||
scene?: GQL.SlimSceneDataFragment;
|
||||
sceneMarker?: GQL.SceneMarkerDataFragment;
|
||||
origin?: string;
|
||||
onOverlay: (show: boolean) => void;
|
||||
clickHandler?: (item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment) => void;
|
||||
}
|
||||
|
||||
export const WallItem: FunctionComponent<IWallItemProps> = (props: IWallItemProps) => {
|
||||
const [videoPath, setVideoPath] = useState<string | undefined>(undefined);
|
||||
const [previewPath, setPreviewPath] = useState<string>("");
|
||||
const [screenshotPath, setScreenshotPath] = useState<string>("");
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [tags, setTags] = useState<JSX.Element[]>([]);
|
||||
const config = StashService.useConfiguration();
|
||||
const videoHoverHook = VideoHoverHook.useVideoHover({resetOnMouseLeave: true});
|
||||
const showTextContainer = !!config.data && !!config.data.configuration ? config.data.configuration.interface.wallShowTitle : true;
|
||||
|
||||
function onMouseEnter() {
|
||||
VideoHoverHook.onMouseEnter(videoHoverHook);
|
||||
if (!videoPath || videoPath === "") {
|
||||
if (!!props.sceneMarker) {
|
||||
setVideoPath(props.sceneMarker.stream || "");
|
||||
} else if (!!props.scene) {
|
||||
setVideoPath(props.scene.paths.preview || "");
|
||||
}
|
||||
}
|
||||
props.onOverlay(true);
|
||||
}
|
||||
const debouncedOnMouseEnter = useRef(_.debounce(onMouseEnter, 500));
|
||||
|
||||
function onMouseLeave() {
|
||||
VideoHoverHook.onMouseLeave(videoHoverHook);
|
||||
setVideoPath("");
|
||||
debouncedOnMouseEnter.current.cancel();
|
||||
props.onOverlay(false);
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
if (props.clickHandler === undefined) { return; }
|
||||
if (props.scene !== undefined) {
|
||||
props.clickHandler(props.scene);
|
||||
} else if (props.sceneMarker !== undefined) {
|
||||
props.clickHandler(props.sceneMarker);
|
||||
}
|
||||
}
|
||||
|
||||
let linkSrc: string = "#";
|
||||
if (props.clickHandler === undefined) {
|
||||
if (props.scene !== undefined) {
|
||||
linkSrc = `/scenes/${props.scene.id}`;
|
||||
} else if (props.sceneMarker !== undefined) {
|
||||
linkSrc = NavigationUtils.makeSceneMarkerUrl(props.sceneMarker);
|
||||
}
|
||||
}
|
||||
|
||||
function onTransitionEnd(event: React.TransitionEvent<HTMLDivElement>) {
|
||||
const target = (event.target as any);
|
||||
if (target.classList.contains("double-scale")) {
|
||||
target.parentElement.style.zIndex = 10;
|
||||
} else {
|
||||
target.parentElement.style.zIndex = null;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!!props.sceneMarker) {
|
||||
setPreviewPath(props.sceneMarker.preview);
|
||||
setTitle(`${props.sceneMarker!.title} - ${TextUtils.secondsToTimestamp(props.sceneMarker.seconds)}`);
|
||||
const thisTags = props.sceneMarker.tags.map((tag) => (<span key={tag.id}>{tag.name}</span>));
|
||||
thisTags.unshift(<span key={props.sceneMarker.primary_tag.id}>{props.sceneMarker.primary_tag.name}</span>);
|
||||
setTags(thisTags);
|
||||
} else if (!!props.scene) {
|
||||
setPreviewPath(props.scene.paths.webp || "");
|
||||
setScreenshotPath(props.scene.paths.screenshot || "");
|
||||
setTitle(props.scene.title || "");
|
||||
// tags = props.scene.tags.map((tag) => (<span key={tag.id}>{tag.name}</span>));
|
||||
}
|
||||
}, [props.sceneMarker, props.scene]);
|
||||
|
||||
function previewNotFound() {
|
||||
if (previewPath !== screenshotPath) {
|
||||
setPreviewPath(screenshotPath);
|
||||
}
|
||||
}
|
||||
|
||||
const className = ["scene-wall-item-container"];
|
||||
if (videoHoverHook.isHovering.current) { className.push("double-scale"); }
|
||||
const style: React.CSSProperties = {};
|
||||
if (!!props.origin) { style.transformOrigin = props.origin; }
|
||||
return (
|
||||
<div className="wall grid-item">
|
||||
<div
|
||||
className={className.join(" ")}
|
||||
style={style}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
onMouseEnter={() => debouncedOnMouseEnter.current()}
|
||||
onMouseMove={() => debouncedOnMouseEnter.current()}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<Link onClick={() => onClick()} to={linkSrc}>
|
||||
<video
|
||||
src={videoPath}
|
||||
poster={screenshotPath}
|
||||
style={videoHoverHook.isHovering.current ? {} : {display: "none"}}
|
||||
autoPlay={true}
|
||||
loop={true}
|
||||
ref={videoHoverHook.videoEl}
|
||||
/>
|
||||
<img alt={title} src={previewPath || screenshotPath} onError={() => previewNotFound()} />
|
||||
{showTextContainer ?
|
||||
<div className="scene-wall-item-text-container">
|
||||
<div style={{lineHeight: 1}}>
|
||||
{title}
|
||||
</div>
|
||||
{tags}
|
||||
</div> : undefined
|
||||
}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import React, { FunctionComponent, useState } from "react";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import "./Wall.scss";
|
||||
import { WallItem } from "./WallItem";
|
||||
|
||||
interface IWallPanelProps {
|
||||
scenes?: GQL.SlimSceneDataFragment[];
|
||||
sceneMarkers?: GQL.SceneMarkerDataFragment[];
|
||||
clickHandler?: (item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment) => void;
|
||||
}
|
||||
|
||||
export const WallPanel: FunctionComponent<IWallPanelProps> = (props: IWallPanelProps) => {
|
||||
const [showOverlay, setShowOverlay] = useState<boolean>(false);
|
||||
|
||||
function onOverlay(show: boolean) {
|
||||
setShowOverlay(show);
|
||||
}
|
||||
|
||||
function getOrigin(index: number, rowSize: number, total: number): string {
|
||||
const isAtStart = index % rowSize === 0;
|
||||
const isAtEnd = index % rowSize === rowSize - 1;
|
||||
const endRemaining = total % rowSize;
|
||||
|
||||
// First row
|
||||
if (total === 1) { return "top"; }
|
||||
if (index === 0) { return "top left"; }
|
||||
if (index === rowSize - 1 || (total < rowSize && index === total - 1)) { return "top right"; }
|
||||
if (index < rowSize) { return "top"; }
|
||||
|
||||
// Bottom row
|
||||
if (isAtEnd && index === total - 1) { return "bottom right"; }
|
||||
if (isAtStart && index === total - rowSize) { return "bottom left"; }
|
||||
if (endRemaining !== 0 && index >= total - endRemaining) { return "bottom"; }
|
||||
if (endRemaining === 0 && index >= total - rowSize) { return "bottom"; }
|
||||
|
||||
// Everything else
|
||||
if (isAtStart) { return "center left"; }
|
||||
if (isAtEnd) { return "center right"; }
|
||||
return "center";
|
||||
}
|
||||
|
||||
function maybeRenderScenes() {
|
||||
if (props.scenes === undefined) { return; }
|
||||
return props.scenes.map((scene, index) => {
|
||||
const origin = getOrigin(index, 5, props.scenes!.length);
|
||||
return (
|
||||
<WallItem
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
onOverlay={onOverlay}
|
||||
clickHandler={props.clickHandler}
|
||||
origin={origin}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function maybeRenderSceneMarkers() {
|
||||
if (props.sceneMarkers === undefined) { return; }
|
||||
return props.sceneMarkers.map((marker, index) => {
|
||||
const origin = getOrigin(index, 5, props.sceneMarkers!.length);
|
||||
return (
|
||||
<WallItem
|
||||
key={marker.id}
|
||||
sceneMarker={marker}
|
||||
onOverlay={onOverlay}
|
||||
clickHandler={props.clickHandler}
|
||||
origin={origin}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
const overlayClassName = showOverlay ? "visible" : "hidden";
|
||||
return (
|
||||
<>
|
||||
<div className={`wall-overlay ${overlayClassName}`} />
|
||||
<div className="wall grid">
|
||||
{maybeRenderScenes()}
|
||||
{maybeRenderSceneMarkers()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return render();
|
||||
};
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
Classes,
|
||||
Dialog,
|
||||
FormGroup,
|
||||
HTMLSelect,
|
||||
InputGroup,
|
||||
Tooltip,
|
||||
} from "@blueprintjs/core";
|
||||
import _ from "lodash";
|
||||
import React, { FunctionComponent, useEffect, useRef, useState } from "react";
|
||||
import { isArray } from "util";
|
||||
import { CriterionModifier } from "../../core/generated-graphql";
|
||||
import { Criterion, CriterionType, DurationCriterion } from "../../models/list-filter/criteria/criterion";
|
||||
import { NoneCriterion } from "../../models/list-filter/criteria/none";
|
||||
import { PerformersCriterion } from "../../models/list-filter/criteria/performers";
|
||||
import { StudiosCriterion } from "../../models/list-filter/criteria/studios";
|
||||
import { MoviesCriterion } from "../../models/list-filter/criteria/movies";
|
||||
import { TagsCriterion } from "../../models/list-filter/criteria/tags";
|
||||
import { makeCriteria } from "../../models/list-filter/criteria/utils";
|
||||
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||
import { FilterMultiSelect } from "../select/FilterMultiSelect";
|
||||
import { DurationInput } from "../Shared/DurationInput";
|
||||
|
||||
interface IAddFilterProps {
|
||||
onAddCriterion: (criterion: Criterion, oldId?: string) => void;
|
||||
onCancel: () => void;
|
||||
filter: ListFilterModel;
|
||||
editingCriterion?: Criterion;
|
||||
}
|
||||
|
||||
export const AddFilter: FunctionComponent<IAddFilterProps> = (props: IAddFilterProps) => {
|
||||
const singleValueSelect = useRef<HTMLSelect>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [criterion, setCriterion] = useState<Criterion<any, any>>(new NoneCriterion());
|
||||
|
||||
const valueStage = useRef<any>(criterion.value);
|
||||
|
||||
// Configure if we are editing an existing criterion
|
||||
useEffect(() => {
|
||||
if (!props.editingCriterion) { return; }
|
||||
setIsOpen(true);
|
||||
setCriterion(props.editingCriterion);
|
||||
}, [props.editingCriterion]);
|
||||
|
||||
function onChangedCriteriaType(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const newCriterionType = event.target.value as CriterionType;
|
||||
const newCriterion = makeCriteria(newCriterionType);
|
||||
setCriterion(newCriterion);
|
||||
}
|
||||
|
||||
function onChangedModifierSelect(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const newCriterion = _.cloneDeep(criterion);
|
||||
newCriterion.modifier = event.target.value as any;
|
||||
setCriterion(newCriterion);
|
||||
}
|
||||
|
||||
function onChangedSingleSelect(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const newCriterion = _.cloneDeep(criterion);
|
||||
newCriterion.value = event.target.value;
|
||||
setCriterion(newCriterion);
|
||||
}
|
||||
|
||||
function onChangedInput(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
valueStage.current = event.target.value;
|
||||
}
|
||||
|
||||
function onChangedDuration(valueAsNumber: number) {
|
||||
valueStage.current = valueAsNumber;
|
||||
onBlurInput();
|
||||
}
|
||||
|
||||
function onBlurInput() {
|
||||
const newCriterion = _.cloneDeep(criterion);
|
||||
newCriterion.value = valueStage.current;
|
||||
setCriterion(newCriterion);
|
||||
}
|
||||
|
||||
function onAddFilter() {
|
||||
if (!isArray(criterion.value) && !!singleValueSelect.current) {
|
||||
const value = singleValueSelect.current.props.defaultValue;
|
||||
if (criterion.options && (value === undefined || value === "" || typeof value === "number")) {
|
||||
criterion.value = criterion.options[0];
|
||||
} else if (typeof value === "number" && value === undefined) {
|
||||
criterion.value = 0;
|
||||
} else if (value === undefined) {
|
||||
criterion.value = "";
|
||||
}
|
||||
}
|
||||
const oldId = !!props.editingCriterion ? props.editingCriterion.getId() : undefined;
|
||||
props.onAddCriterion(criterion, oldId);
|
||||
onToggle();
|
||||
}
|
||||
|
||||
function onToggle() {
|
||||
if (isOpen) {
|
||||
props.onCancel();
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
setCriterion(makeCriteria());
|
||||
}
|
||||
|
||||
const maybeRenderFilterPopoverContents = () => {
|
||||
if (criterion.type === "none") { return; }
|
||||
|
||||
function renderModifier() {
|
||||
if (criterion.modifierOptions.length === 0) { return; }
|
||||
return (
|
||||
<div>
|
||||
<HTMLSelect
|
||||
options={criterion.modifierOptions}
|
||||
onChange={onChangedModifierSelect}
|
||||
defaultValue={criterion.modifier}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSelect() {
|
||||
// Hide the value select if the modifier is "IsNull" or "NotNull"
|
||||
if (criterion.modifier === CriterionModifier.IsNull || criterion.modifier === CriterionModifier.NotNull) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isArray(criterion.value)) {
|
||||
let type: "performers" | "studios" | "movies" | "tags" | "" = "";
|
||||
if (criterion instanceof PerformersCriterion) {
|
||||
type = "performers";
|
||||
} else if (criterion instanceof StudiosCriterion) {
|
||||
type = "studios";
|
||||
} else if (criterion instanceof MoviesCriterion) {
|
||||
type = "movies";
|
||||
} else if (criterion instanceof TagsCriterion) {
|
||||
type = "tags";
|
||||
}
|
||||
|
||||
if (type === "") {
|
||||
return (<>todo</>);
|
||||
} else {
|
||||
return (
|
||||
<FilterMultiSelect
|
||||
type={type}
|
||||
onUpdate={(items) => criterion.value = items.map((i) => ({id: i.id, label: i.name!}))}
|
||||
openOnKeyDown={true}
|
||||
initialIds={criterion.value.map((labeled: any) => labeled.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (criterion.options) {
|
||||
return (
|
||||
<HTMLSelect
|
||||
ref={singleValueSelect}
|
||||
options={criterion.options}
|
||||
onChange={onChangedSingleSelect}
|
||||
defaultValue={criterion.value}
|
||||
/>
|
||||
);
|
||||
} else if (criterion instanceof DurationCriterion) {
|
||||
// render duration control
|
||||
return (
|
||||
<DurationInput
|
||||
numericValue={criterion.value ? criterion.value : 0}
|
||||
onValueChange={onChangedDuration}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<InputGroup
|
||||
type={criterion.inputType}
|
||||
onChange={onChangedInput}
|
||||
onBlur={onBlurInput}
|
||||
defaultValue={criterion.value ? criterion.value : ""}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FormGroup>
|
||||
{renderModifier()}
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
{renderSelect()}
|
||||
</FormGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function maybeRenderFilterSelect() {
|
||||
if (!!props.editingCriterion) { return; }
|
||||
return (
|
||||
<FormGroup label="Filter">
|
||||
<HTMLSelect
|
||||
style={{flexBasis: "min-content"}}
|
||||
options={props.filter.criterionOptions}
|
||||
onChange={onChangedCriteriaType}
|
||||
defaultValue={criterion.type}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const title = !props.editingCriterion ? "Add Filter" : "Update Filter";
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
hoverOpenDelay={200}
|
||||
content="Filter"
|
||||
>
|
||||
<Button
|
||||
icon="filter"
|
||||
onClick={() => onToggle()}
|
||||
active={isOpen}
|
||||
large={true}
|
||||
>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Dialog isOpen={isOpen} onClose={() => onToggle()} title={title}>
|
||||
<div className="dialog-content">
|
||||
{maybeRenderFilterSelect()}
|
||||
{maybeRenderFilterPopoverContents()}
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Button onClick={onAddFilter} disabled={criterion.type === "none"}>{title}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
HTMLSelect,
|
||||
InputGroup,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Popover,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Slider,
|
||||
} from "@blueprintjs/core";
|
||||
import { debounce } from "lodash";
|
||||
import React, { FunctionComponent, SyntheticEvent, useState } from "react";
|
||||
import { Criterion } from "../../models/list-filter/criteria/criterion";
|
||||
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||
import { DisplayMode } from "../../models/list-filter/types";
|
||||
import { AddFilter } from "./AddFilter";
|
||||
|
||||
interface IListFilterOperation {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface IListFilterProps {
|
||||
onChangePageSize: (pageSize: number) => void;
|
||||
onChangeQuery: (query: string) => void;
|
||||
onChangeSortDirection: (sortDirection: "asc" | "desc") => void;
|
||||
onChangeSortBy: (sortBy: string) => void;
|
||||
onChangeDisplayMode: (displayMode: DisplayMode) => void;
|
||||
onAddCriterion: (criterion: Criterion, oldId?: string) => void;
|
||||
onRemoveCriterion: (criterion: Criterion) => void;
|
||||
zoomIndex?: number;
|
||||
onChangeZoom?: (zoomIndex: number) => void;
|
||||
onSelectAll?: () => void;
|
||||
onSelectNone?: () => void;
|
||||
otherOperations?: IListFilterOperation[];
|
||||
filter: ListFilterModel;
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120"];
|
||||
|
||||
export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilterProps) => {
|
||||
const [editingCriterion, setEditingCriterion] = useState<Criterion | undefined>(undefined);
|
||||
|
||||
function onChangePageSize(event: SyntheticEvent<HTMLSelectElement>) {
|
||||
const val = event!.currentTarget!.value;
|
||||
props.onChangePageSize(parseInt(val, 10));
|
||||
}
|
||||
|
||||
function onChangeQuery(event: SyntheticEvent<HTMLInputElement>) {
|
||||
let searchCallback = debounce((event: any) => {
|
||||
props.onChangeQuery(event.target.value);
|
||||
}, 500);
|
||||
|
||||
event.persist();
|
||||
searchCallback(event);
|
||||
}
|
||||
|
||||
function onChangeSortDirection(_: any) {
|
||||
if (props.filter.sortDirection === "asc") {
|
||||
props.onChangeSortDirection("desc");
|
||||
} else {
|
||||
props.onChangeSortDirection("asc");
|
||||
}
|
||||
}
|
||||
|
||||
function onChangeSortBy(event: React.MouseEvent<any>) {
|
||||
props.onChangeSortBy(event.currentTarget.text);
|
||||
}
|
||||
|
||||
function onChangeDisplayMode(displayMode: DisplayMode) {
|
||||
props.onChangeDisplayMode(displayMode);
|
||||
}
|
||||
|
||||
function onAddCriterion(criterion: Criterion, oldId?: string) {
|
||||
props.onAddCriterion(criterion, oldId);
|
||||
}
|
||||
|
||||
function onCancelAddCriterion() {
|
||||
setEditingCriterion(undefined);
|
||||
}
|
||||
|
||||
let removedCriterionId = "";
|
||||
function onRemoveCriterionTag(criterion?: Criterion) {
|
||||
if (!criterion) { return; }
|
||||
setEditingCriterion(undefined);
|
||||
removedCriterionId = criterion.getId();
|
||||
props.onRemoveCriterion(criterion);
|
||||
}
|
||||
function onClickCriterionTag(criterion?: Criterion) {
|
||||
if (!criterion || removedCriterionId !== "") { return; }
|
||||
setEditingCriterion(criterion);
|
||||
}
|
||||
|
||||
function renderSortByOptions() {
|
||||
return props.filter.sortByOptions.map((option) => (
|
||||
<MenuItem onClick={onChangeSortBy} text={option} key={option} />
|
||||
));
|
||||
}
|
||||
|
||||
function renderDisplayModeOptions() {
|
||||
function getIcon(option: DisplayMode) {
|
||||
switch (option) {
|
||||
case DisplayMode.Grid: return "grid-view";
|
||||
case DisplayMode.List: return "list";
|
||||
case DisplayMode.Wall: return "symbol-square";
|
||||
}
|
||||
}
|
||||
function getLabel(option: DisplayMode) {
|
||||
switch (option) {
|
||||
case DisplayMode.Grid: return "Grid";
|
||||
case DisplayMode.List: return "List";
|
||||
case DisplayMode.Wall: return "Wall";
|
||||
}
|
||||
}
|
||||
return props.filter.displayModeOptions.map((option) => (
|
||||
<Tooltip content={getLabel(option)} hoverOpenDelay={200}>
|
||||
<Button
|
||||
key={option}
|
||||
active={props.filter.displayMode === option}
|
||||
onClick={() => onChangeDisplayMode(option)}
|
||||
icon={getIcon(option)}
|
||||
/>
|
||||
</Tooltip>
|
||||
));
|
||||
}
|
||||
|
||||
function renderFilterTags() {
|
||||
return props.filter.criteria.map((criterion) => (
|
||||
<Tag
|
||||
key={criterion.getId()}
|
||||
className="tag-item"
|
||||
itemID={criterion.getId()}
|
||||
interactive={true}
|
||||
onRemove={() => onRemoveCriterionTag(criterion)}
|
||||
onClick={() => onClickCriterionTag(criterion)}
|
||||
>
|
||||
{criterion.getLabel()}
|
||||
</Tag>
|
||||
));
|
||||
}
|
||||
|
||||
function onSelectAll() {
|
||||
if (props.onSelectAll) {
|
||||
props.onSelectAll();
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectNone() {
|
||||
if (props.onSelectNone) {
|
||||
props.onSelectNone();
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelectAll() {
|
||||
if (props.onSelectAll) {
|
||||
return <MenuItem onClick={() => onSelectAll()} text="Select All" />;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelectNone() {
|
||||
if (props.onSelectNone) {
|
||||
return <MenuItem onClick={() => onSelectNone()} text="Select None" />;
|
||||
}
|
||||
}
|
||||
|
||||
function renderMore() {
|
||||
let options = [];
|
||||
options.push(renderSelectAll());
|
||||
options.push(renderSelectNone());
|
||||
|
||||
if (props.otherOperations) {
|
||||
props.otherOperations.forEach((o) => {
|
||||
options.push(<MenuItem onClick={o.onClick} text={o.text} />);
|
||||
});
|
||||
}
|
||||
|
||||
options = options.filter((o) => !!o);
|
||||
|
||||
let menuItems = options as JSX.Element[];
|
||||
|
||||
function renderMoreOptions() {
|
||||
return (
|
||||
<>
|
||||
{menuItems}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (menuItems.length > 0) {
|
||||
return (
|
||||
<Popover position="bottom">
|
||||
<Button icon="more"/>
|
||||
<Menu>{renderMoreOptions()}</Menu>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onChangeZoom(v : number) {
|
||||
if (props.onChangeZoom) {
|
||||
props.onChangeZoom(v);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderZoom() {
|
||||
if (props.onChangeZoom) {
|
||||
return (
|
||||
<span className="zoom-slider">
|
||||
<Slider
|
||||
min={0}
|
||||
value={props.zoomIndex}
|
||||
initialValue={props.zoomIndex}
|
||||
max={3}
|
||||
labelRenderer={false}
|
||||
onChange={(v) => onChangeZoom(v)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
return (
|
||||
<>
|
||||
<div className="filter-container">
|
||||
<InputGroup
|
||||
large={true}
|
||||
placeholder="Search..."
|
||||
defaultValue={props.filter.searchTerm}
|
||||
onChange={onChangeQuery}
|
||||
className="filter-item"
|
||||
/>
|
||||
<HTMLSelect
|
||||
large={true}
|
||||
style={{flexBasis: "min-content"}}
|
||||
options={PAGE_SIZE_OPTIONS}
|
||||
onChange={onChangePageSize}
|
||||
value={props.filter.itemsPerPage}
|
||||
className="filter-item"
|
||||
/>
|
||||
<ButtonGroup className="filter-item">
|
||||
<Popover position="bottom">
|
||||
<Button large={true}>{props.filter.sortBy}</Button>
|
||||
<Menu>{renderSortByOptions()}</Menu>
|
||||
</Popover>
|
||||
|
||||
<Tooltip
|
||||
content={props.filter.sortDirection === "asc" ? "Ascending" : "Descending"}
|
||||
hoverOpenDelay={200}
|
||||
>
|
||||
<Button
|
||||
rightIcon={props.filter.sortDirection === "asc" ? "caret-up" : "caret-down"}
|
||||
onClick={onChangeSortDirection}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
</ButtonGroup>
|
||||
|
||||
<AddFilter
|
||||
filter={props.filter}
|
||||
onAddCriterion={onAddCriterion}
|
||||
onCancel={onCancelAddCriterion}
|
||||
editingCriterion={editingCriterion}
|
||||
/>
|
||||
|
||||
<ButtonGroup className="filter-item">
|
||||
{renderDisplayModeOptions()}
|
||||
</ButtonGroup>
|
||||
|
||||
{maybeRenderZoom()}
|
||||
|
||||
<ButtonGroup className="filter-item">
|
||||
{renderMore()}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div style={{display: "flex", justifyContent: "center", margin: "10px auto"}}>
|
||||
{renderFilterTags()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return render();
|
||||
};
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import { Button, ButtonGroup } from "@blueprintjs/core";
|
||||
import React from "react";
|
||||
|
||||
interface IPaginationProps {
|
||||
itemsPerPage: number;
|
||||
currentPage: number;
|
||||
totalItems: number;
|
||||
onChangePage: (page: number) => void;
|
||||
}
|
||||
|
||||
interface IPaginationState {
|
||||
pages: number[];
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export class Pagination extends React.Component<IPaginationProps, IPaginationState> {
|
||||
constructor(props: IPaginationProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
pages: [],
|
||||
totalPages: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
}
|
||||
|
||||
public componentWillMount() {
|
||||
this.setPage(this.props.currentPage, false);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IPaginationProps) {
|
||||
if (this.props.totalItems !== prevProps.totalItems || this.props.itemsPerPage !== prevProps.itemsPerPage) {
|
||||
this.setPage(this.props.currentPage);
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (!this.state || !this.state.pages || this.state.pages.length <= 1) { return null; }
|
||||
|
||||
return (
|
||||
<ButtonGroup large={true} className="filter-container">
|
||||
<Button
|
||||
text="First"
|
||||
disabled={this.props.currentPage === 1}
|
||||
onClick={() => this.setPage(1)}
|
||||
/>
|
||||
<Button
|
||||
text="Previous"
|
||||
disabled={this.props.currentPage === 1}
|
||||
onClick={() => this.setPage(this.props.currentPage - 1)}
|
||||
/>
|
||||
{this.renderPageButtons()}
|
||||
<Button
|
||||
text="Next"
|
||||
disabled={this.props.currentPage === this.state.totalPages}
|
||||
onClick={() => this.setPage(this.props.currentPage + 1)}
|
||||
/>
|
||||
<Button
|
||||
text="Last"
|
||||
disabled={this.props.currentPage === this.state.totalPages}
|
||||
onClick={() => this.setPage(this.state.totalPages)}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
private renderPageButtons() {
|
||||
return this.state.pages.map((page: number, index: number) => (
|
||||
<Button
|
||||
key={index}
|
||||
text={page}
|
||||
active={this.props.currentPage === page}
|
||||
onClick={() => this.setPage(page)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
private setPage(page?: number, propagate: boolean = true) {
|
||||
if (page === undefined) { return; }
|
||||
|
||||
const pagerState = this.getPagerState(this.props.totalItems, page, this.props.itemsPerPage);
|
||||
|
||||
// rearranged this so that the minimum page number is 1, not 0
|
||||
if (page > pagerState.totalPages) { page = pagerState.totalPages; }
|
||||
if (page < 1) { page = 1; }
|
||||
|
||||
this.setState(pagerState);
|
||||
if (propagate) { this.props.onChangePage(page); }
|
||||
}
|
||||
|
||||
private getPagerState(totalItems: number, currentPage: number, pageSize: number) {
|
||||
const totalPages = Math.ceil(totalItems / pageSize);
|
||||
|
||||
let startPage: number;
|
||||
let endPage: number;
|
||||
if (totalPages <= 10) {
|
||||
// less than 10 total pages so show all
|
||||
startPage = 1;
|
||||
endPage = totalPages;
|
||||
} else {
|
||||
// more than 10 total pages so calculate start and end pages
|
||||
if (currentPage <= 6) {
|
||||
startPage = 1;
|
||||
endPage = 10;
|
||||
} else if (currentPage + 4 >= totalPages) {
|
||||
startPage = totalPages - 9;
|
||||
endPage = totalPages;
|
||||
} else {
|
||||
startPage = currentPage - 5;
|
||||
endPage = currentPage + 4;
|
||||
}
|
||||
}
|
||||
|
||||
// create an array of pages numbers
|
||||
const pages = [...Array((endPage + 1) - startPage).keys()].map((i) => startPage + i);
|
||||
|
||||
return {
|
||||
pages,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import {
|
||||
Card,
|
||||
Elevation,
|
||||
H4,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { TextUtils } from "../../utils/text";
|
||||
import { NavigationUtils } from "../../utils/navigation";
|
||||
|
||||
interface IPerformerCardProps {
|
||||
performer: GQL.PerformerDataFragment;
|
||||
ageFromDate?: string;
|
||||
}
|
||||
|
||||
export const PerformerCard: FunctionComponent<IPerformerCardProps> = (props: IPerformerCardProps) => {
|
||||
const age = TextUtils.age(props.performer.birthdate, props.ageFromDate);
|
||||
const ageString = `${age} years old${!!props.ageFromDate ? " in this scene." : "."}`;
|
||||
|
||||
function maybeRenderFavoriteBanner() {
|
||||
if (props.performer.favorite === false) { return; }
|
||||
return (
|
||||
<div className={`rating-banner rating-5`}>
|
||||
FAVORITE
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="grid-item"
|
||||
elevation={Elevation.ONE}
|
||||
>
|
||||
<Link
|
||||
to={`/performers/${props.performer.id}`}
|
||||
className="performer previewable image"
|
||||
style={{backgroundImage: `url(${props.performer.image_path})`}}
|
||||
>
|
||||
{maybeRenderFavoriteBanner()}
|
||||
</Link>
|
||||
<div className="card-section">
|
||||
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
|
||||
{props.performer.name}
|
||||
</H4>
|
||||
{age !== 0 ? <span className="bp3-text-muted block">{ageString}</span> : undefined}
|
||||
<span className="bp3-text-muted block">Stars in {props.performer.scene_count} <Link to={NavigationUtils.makePerformerScenesUrl(props.performer)}>scenes</Link>.
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
import {
|
||||
Spinner,
|
||||
Tabs,
|
||||
Tab,
|
||||
Button,
|
||||
AnchorButton,
|
||||
IconName,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { StashService } from "../../../core/StashService";
|
||||
import { IBaseProps } from "../../../models";
|
||||
import { ErrorUtils } from "../../../utils/errors";
|
||||
import { PerformerDetailsPanel } from "./PerformerDetailsPanel";
|
||||
import { PerformerOperationsPanel } from "./PerformerOperationsPanel";
|
||||
import { PerformerScenesPanel } from "./PerformerScenesPanel";
|
||||
import { TextUtils } from "../../../utils/text";
|
||||
import Lightbox from "react-images";
|
||||
|
||||
interface IPerformerProps extends IBaseProps {}
|
||||
|
||||
export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerProps) => {
|
||||
const isNew = props.match.params.id === "new";
|
||||
|
||||
// Performer state
|
||||
const [performer, setPerformer] = useState<Partial<GQL.PerformerDataFragment>>({});
|
||||
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined);
|
||||
const [lightboxIsOpen, setLightboxIsOpen] = useState<boolean>(false);
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { data, error, loading } = StashService.useFindPerformer(props.match.params.id);
|
||||
const updatePerformer = StashService.usePerformerUpdate();
|
||||
const createPerformer = StashService.usePerformerCreate();
|
||||
const deletePerformer = StashService.usePerformerDestroy();
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(loading);
|
||||
if (!data || !data.findPerformer || !!error) { return; }
|
||||
setPerformer(data.findPerformer);
|
||||
}, [data, error, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
setImagePreview(performer.image_path);
|
||||
}, [performer]);
|
||||
|
||||
function onImageChange(image: string) {
|
||||
setImagePreview(image);
|
||||
}
|
||||
|
||||
if ((!isNew && (!data || !data.findPerformer)) || isLoading) {
|
||||
return <Spinner size={Spinner.SIZE_LARGE} />;
|
||||
}
|
||||
if (!!error) { return <>error...</>; }
|
||||
|
||||
async function onSave(performer : Partial<GQL.PerformerCreateInput> | Partial<GQL.PerformerUpdateInput>) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (!isNew) {
|
||||
const result = await updatePerformer({variables: performer as GQL.PerformerUpdateInput});
|
||||
if (performer.image) {
|
||||
// Refetch image to bust browser cache
|
||||
await fetch(`/performer/${result.data.performerUpdate.id}/image`, { cache: "reload" });
|
||||
}
|
||||
setPerformer(result.data.performerUpdate);
|
||||
} else {
|
||||
const result = await createPerformer({variables: performer as GQL.PerformerCreateInput});
|
||||
setPerformer(result.data.performerCreate);
|
||||
props.history.push(`/performers/${result.data.performerCreate.id}`);
|
||||
}
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await deletePerformer({variables: {id: props.match.params.id}});
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
// redirect to performers page
|
||||
props.history.push(`/performers`);
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
function renderEditPanel() {
|
||||
return (
|
||||
<PerformerDetailsPanel
|
||||
performer={performer}
|
||||
isEditing={true}
|
||||
isNew={isNew}
|
||||
onDelete={onDelete}
|
||||
onSave={onSave}
|
||||
onImageChange={onImageChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// render tabs if not new
|
||||
if (!isNew) {
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
renderActiveTabPanelOnly={true}
|
||||
large={true}
|
||||
>
|
||||
<Tab id="performer-details-panel" title="Details" panel={<PerformerDetailsPanel performer={performer} isEditing={false}/>} />
|
||||
<Tab id="performer-scenes-panel" title="Scenes" panel={<PerformerScenesPanel performer={performer} base={props} />} />
|
||||
<Tab id="performer-edit-panel" title="Edit" panel={renderEditPanel()} />
|
||||
<Tab id="performer-operations-panel" title="Operations" panel={<PerformerOperationsPanel performer={performer} />} />
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return renderEditPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderAge() {
|
||||
if (performer && performer.birthdate) {
|
||||
// calculate the age from birthdate. In future, this should probably be
|
||||
// provided by the server
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<span className="age">{TextUtils.age(performer.birthdate)}</span>
|
||||
<span className="age-tail"> years old</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderAliases() {
|
||||
if (performer && performer.aliases) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<span className="alias-head">Also known as </span>
|
||||
<span className="alias">{performer.aliases}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setFavorite(v : boolean) {
|
||||
performer.favorite = v;
|
||||
onSave(performer);
|
||||
}
|
||||
|
||||
function renderIcons() {
|
||||
function maybeRenderURL(url?: string, icon?: IconName) {
|
||||
if (performer.url) {
|
||||
if (!icon) {
|
||||
icon = "link";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnchorButton
|
||||
icon={icon}
|
||||
href={performer.url}
|
||||
minimal={true}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="name-icons">
|
||||
<Button
|
||||
icon="heart"
|
||||
className={performer.favorite ? "favorite" : "not-favorite"}
|
||||
onClick={() => setFavorite(!performer.favorite)}
|
||||
minimal={true}
|
||||
/>
|
||||
{maybeRenderURL(performer.url)}
|
||||
{/* TODO - render instagram and twitter links with icons */}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderNewView() {
|
||||
return (
|
||||
<div className="columns is-multiline no-spacing">
|
||||
<div className="column is-half details-image-container">
|
||||
{!imagePreview ? undefined : <img alt="Performer" className="performer" src={imagePreview} />}
|
||||
</div>
|
||||
<div className="column is-half details-detail-container">
|
||||
{renderTabs()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const photos = [{src: imagePreview || "", caption: "Image"}];
|
||||
|
||||
function openLightbox() {
|
||||
setLightboxIsOpen(true);
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
setLightboxIsOpen(false);
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
return renderNewView();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="performer-page">
|
||||
<div className="details-image-container">
|
||||
<img alt={performer.name} className="performer" src={imagePreview} onClick={openLightbox} />
|
||||
</div>
|
||||
<div className="performer-head">
|
||||
<h1 className="bp3-heading">
|
||||
{performer.name}
|
||||
{renderIcons()}
|
||||
</h1>
|
||||
{maybeRenderAliases()}
|
||||
{maybeRenderAge()}
|
||||
</div>
|
||||
|
||||
<div className="performer-body">
|
||||
<div className="details-detail-container">
|
||||
{renderTabs()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Lightbox
|
||||
images={photos}
|
||||
onClose={closeLightbox}
|
||||
currentImage={0}
|
||||
isOpen={lightboxIsOpen}
|
||||
onClickImage={() => window.open(imagePreview, "_blank")}
|
||||
width={9999}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,433 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
Classes,
|
||||
Dialog,
|
||||
HTMLTable,
|
||||
Spinner,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Popover,
|
||||
Alert,
|
||||
FileInput,
|
||||
} from "@blueprintjs/core";
|
||||
import _ from "lodash";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { StashService } from "../../../core/StashService";
|
||||
import { ErrorUtils } from "../../../utils/errors";
|
||||
import { TableUtils } from "../../../utils/table";
|
||||
import { ScrapePerformerSuggest } from "../../select/ScrapePerformerSuggest";
|
||||
import { EditableTextUtils } from "../../../utils/editabletext";
|
||||
import { ImageUtils } from "../../../utils/image";
|
||||
|
||||
interface IPerformerDetailsProps {
|
||||
performer: Partial<GQL.PerformerDataFragment>
|
||||
isNew?: boolean
|
||||
isEditing?: boolean
|
||||
onSave? : (performer : Partial<GQL.PerformerCreateInput> | Partial<GQL.PerformerUpdateInput>) => void
|
||||
onDelete? : () => void
|
||||
onImageChange? : (image: string) => void
|
||||
}
|
||||
|
||||
export const PerformerDetailsPanel: FunctionComponent<IPerformerDetailsProps> = (props: IPerformerDetailsProps) => {
|
||||
|
||||
// Editing state
|
||||
const [isDisplayingScraperDialog, setIsDisplayingScraperDialog] = useState<GQL.ListPerformerScrapersListPerformerScrapers | undefined>(undefined);
|
||||
const [scrapePerformerDetails, setScrapePerformerDetails] = useState<GQL.ScrapePerformerListScrapePerformerList | undefined>(undefined);
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
|
||||
// Editing performer state
|
||||
const [image, setImage] = useState<string | undefined>(undefined);
|
||||
const [name, setName] = useState<string | undefined>(undefined);
|
||||
const [aliases, setAliases] = useState<string | undefined>(undefined);
|
||||
const [favorite, setFavorite] = useState<boolean | undefined>(undefined);
|
||||
const [birthdate, setBirthdate] = useState<string | undefined>(undefined);
|
||||
const [ethnicity, setEthnicity] = useState<string | undefined>(undefined);
|
||||
const [country, setCountry] = useState<string | undefined>(undefined);
|
||||
const [eyeColor, setEyeColor] = useState<string | undefined>(undefined);
|
||||
const [height, setHeight] = useState<string | undefined>(undefined);
|
||||
const [measurements, setMeasurements] = useState<string | undefined>(undefined);
|
||||
const [fakeTits, setFakeTits] = useState<string | undefined>(undefined);
|
||||
const [careerLength, setCareerLength] = useState<string | undefined>(undefined);
|
||||
const [tattoos, setTattoos] = useState<string | undefined>(undefined);
|
||||
const [piercings, setPiercings] = useState<string | undefined>(undefined);
|
||||
const [url, setUrl] = useState<string | undefined>(undefined);
|
||||
const [twitter, setTwitter] = useState<string | undefined>(undefined);
|
||||
const [instagram, setInstagram] = useState<string | undefined>(undefined);
|
||||
const [gender, setGender] = useState<string | undefined>(undefined);
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const Scrapers = StashService.useListPerformerScrapers();
|
||||
const [queryableScrapers, setQueryableScrapers] = useState<GQL.ListPerformerScrapersListPerformerScrapers[]>([]);
|
||||
|
||||
function updatePerformerEditState(state: Partial<GQL.PerformerDataFragment | GQL.ScrapedPerformerDataFragment | GQL.ScrapeFreeonesScrapeFreeones>) {
|
||||
if ((state as GQL.PerformerDataFragment).favorite !== undefined) {
|
||||
setFavorite((state as GQL.PerformerDataFragment).favorite);
|
||||
}
|
||||
setName(state.name);
|
||||
setAliases(state.aliases);
|
||||
setBirthdate(state.birthdate);
|
||||
setEthnicity(state.ethnicity);
|
||||
setCountry(state.country);
|
||||
setEyeColor(state.eye_color);
|
||||
setHeight(state.height);
|
||||
setMeasurements(state.measurements);
|
||||
setFakeTits(state.fake_tits);
|
||||
setCareerLength(state.career_length);
|
||||
setTattoos(state.tattoos);
|
||||
setPiercings(state.piercings);
|
||||
setUrl(state.url);
|
||||
setTwitter(state.twitter);
|
||||
setInstagram(state.instagram);
|
||||
setGender(StashService.genderToString((state as GQL.PerformerDataFragment).gender));
|
||||
}
|
||||
|
||||
function updatePerformerEditStateFromScraper(state: Partial<GQL.ScrapedPerformerDataFragment | GQL.ScrapeFreeonesScrapeFreeones>) {
|
||||
updatePerformerEditState(state);
|
||||
|
||||
// image is a base64 string
|
||||
if ((state as GQL.ScrapedPerformerDataFragment).image !== undefined) {
|
||||
let imageStr = (state as GQL.ScrapedPerformerDataFragment).image;
|
||||
setImage(imageStr);
|
||||
if (props.onImageChange) {
|
||||
props.onImageChange(imageStr!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setImage(undefined);
|
||||
updatePerformerEditState(props.performer);
|
||||
}, [props.performer]);
|
||||
|
||||
function onImageLoad(this: FileReader) {
|
||||
setImage(this.result as string);
|
||||
if (props.onImageChange) {
|
||||
props.onImageChange(this.result as string);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.isEditing) {
|
||||
ImageUtils.addPasteImageHook(onImageLoad);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
var newQueryableScrapers : GQL.ListPerformerScrapersListPerformerScrapers[] = [];
|
||||
|
||||
if (!!Scrapers.data && Scrapers.data.listPerformerScrapers) {
|
||||
newQueryableScrapers = Scrapers.data.listPerformerScrapers.filter((s) => {
|
||||
return s.performer && s.performer.supported_scrapes.includes(GQL.ScrapeType.Name);
|
||||
});
|
||||
}
|
||||
|
||||
setQueryableScrapers(newQueryableScrapers);
|
||||
|
||||
}, [Scrapers.data]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner size={Spinner.SIZE_LARGE} />;
|
||||
}
|
||||
|
||||
function getPerformerInput() {
|
||||
const performerInput: Partial<GQL.PerformerCreateInput | GQL.PerformerUpdateInput> = {
|
||||
name,
|
||||
aliases,
|
||||
favorite,
|
||||
birthdate,
|
||||
ethnicity,
|
||||
country,
|
||||
eye_color: eyeColor,
|
||||
height,
|
||||
measurements,
|
||||
fake_tits: fakeTits,
|
||||
career_length: careerLength,
|
||||
tattoos,
|
||||
piercings,
|
||||
url,
|
||||
twitter,
|
||||
instagram,
|
||||
image,
|
||||
gender: StashService.stringToGender(gender)
|
||||
};
|
||||
|
||||
if (!props.isNew) {
|
||||
(performerInput as GQL.PerformerUpdateInput).id = props.performer.id!;
|
||||
}
|
||||
return performerInput;
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
if (props.onSave) {
|
||||
props.onSave(getPerformerInput());
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (props.onDelete) {
|
||||
props.onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||
ImageUtils.onImageChange(event, onImageLoad);
|
||||
}
|
||||
|
||||
function onDisplayFreeOnesDialog(scraper: GQL.ListPerformerScrapersListPerformerScrapers) {
|
||||
setIsDisplayingScraperDialog(scraper);
|
||||
}
|
||||
|
||||
function getQueryScraperPerformerInput() {
|
||||
if (!scrapePerformerDetails) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let ret = _.clone(scrapePerformerDetails);
|
||||
delete ret.__typename;
|
||||
|
||||
// image is not supported
|
||||
delete ret.image;
|
||||
|
||||
return ret as GQL.ScrapedPerformerInput;
|
||||
}
|
||||
|
||||
async function onScrapePerformer() {
|
||||
setIsDisplayingScraperDialog(undefined);
|
||||
try {
|
||||
if (!scrapePerformerDetails || !isDisplayingScraperDialog) { return; }
|
||||
setIsLoading(true);
|
||||
const result = await StashService.queryScrapePerformer(isDisplayingScraperDialog.id, getQueryScraperPerformerInput());
|
||||
if (!result.data || !result.data.scrapePerformer) { return; }
|
||||
updatePerformerEditStateFromScraper(result.data.scrapePerformer);
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onScrapePerformerURL() {
|
||||
if (!url) { return; }
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await StashService.queryScrapePerformerURL(url);
|
||||
if (!result.data || !result.data.scrapePerformerURL) { return; }
|
||||
|
||||
// leave URL as is if not set explicitly
|
||||
if (!result.data.scrapePerformerURL.url) {
|
||||
result.data.scrapePerformerURL.url = url;
|
||||
}
|
||||
|
||||
updatePerformerEditStateFromScraper(result.data.scrapePerformerURL);
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderEthnicity() {
|
||||
return TableUtils.renderInputGroup({
|
||||
title: "Ethnicity",
|
||||
value: ethnicity,
|
||||
isEditing: !!props.isEditing,
|
||||
placeholder: "Ethnicity",
|
||||
onChange: setEthnicity
|
||||
});
|
||||
}
|
||||
|
||||
function renderScraperMenu() {
|
||||
function renderScraperMenuItem(scraper : GQL.ListPerformerScrapersListPerformerScrapers) {
|
||||
return (
|
||||
<MenuItem
|
||||
text={scraper.name}
|
||||
onClick={() => { onDisplayFreeOnesDialog(scraper); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!props.performer) { return; }
|
||||
if (!props.isEditing) { return; }
|
||||
const scraperMenu = (
|
||||
<Menu>
|
||||
{queryableScrapers ? queryableScrapers.map((s) => renderScraperMenuItem(s)) : undefined}
|
||||
</Menu>
|
||||
);
|
||||
return (
|
||||
<Popover content={scraperMenu} position="bottom">
|
||||
<Button text="Scrape with..."/>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function renderScraperDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={!!isDisplayingScraperDialog}
|
||||
onClose={() => setIsDisplayingScraperDialog(undefined)}
|
||||
title="Scrape"
|
||||
>
|
||||
<div className="dialog-content">
|
||||
<ScrapePerformerSuggest
|
||||
placeholder="Performer name"
|
||||
style={{width: "100%"}}
|
||||
scraperId={isDisplayingScraperDialog ? isDisplayingScraperDialog.id : ""}
|
||||
onSelectPerformer={(query) => setScrapePerformerDetails(query)}
|
||||
/>
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Button onClick={() => onScrapePerformer()}>Scrape</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function urlScrapable(url: string) : boolean {
|
||||
return !!url && !!Scrapers.data && Scrapers.data.listPerformerScrapers && Scrapers.data.listPerformerScrapers.some((s) => {
|
||||
return !!s.performer && !!s.performer.urls && s.performer.urls.some((u) => { return url.includes(u); });
|
||||
});
|
||||
}
|
||||
|
||||
function maybeRenderScrapeButton() {
|
||||
if (!url || !props.isEditing || !urlScrapable(url)) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
minimal={true}
|
||||
icon="import"
|
||||
id="scrape-url-button"
|
||||
onClick={() => onScrapePerformerURL()}/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderURLField() {
|
||||
return (
|
||||
<tr>
|
||||
<td id="url-field">
|
||||
URL
|
||||
{maybeRenderScrapeButton()}
|
||||
</td>
|
||||
<td>
|
||||
{EditableTextUtils.renderInputGroup({
|
||||
value: url, asURL: true, isEditing: !!props.isEditing, onChange: setUrl, placeholder: "URL"
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function renderImageInput() {
|
||||
if (!props.isEditing) { return; }
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td>Image</td>
|
||||
<td><FileInput text="Choose image..." onInputChange={onImageChange} inputProps={{accept: ".jpg,.jpeg"}} /></td>
|
||||
</tr>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function maybeRenderButtons() {
|
||||
if (props.isEditing) {
|
||||
return (
|
||||
<>
|
||||
<Button className="edit-button" text="Save" intent="primary" onClick={() => onSave()}/>
|
||||
{!props.isNew ? <Button className="edit-button" text="Delete" intent="danger" onClick={() => setIsDeleteAlertOpen(true)}/> : undefined}
|
||||
{renderScraperMenu()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
return (
|
||||
<Alert
|
||||
cancelButtonText="Cancel"
|
||||
confirmButtonText="Delete"
|
||||
icon="trash"
|
||||
intent="danger"
|
||||
isOpen={isDeleteAlertOpen}
|
||||
onCancel={() => setIsDeleteAlertOpen(false)}
|
||||
onConfirm={() => onDelete()}
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to delete {name}?
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderName() {
|
||||
if (props.isEditing) {
|
||||
return TableUtils.renderInputGroup(
|
||||
{title: "Name", value: name, isEditing: !!props.isEditing, placeholder: "Name", onChange: setName});
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderAliases() {
|
||||
if (props.isEditing) {
|
||||
return TableUtils.renderInputGroup(
|
||||
{title: "Aliases", value: aliases, isEditing: !!props.isEditing, placeholder: "Aliases", onChange: setAliases});
|
||||
}
|
||||
}
|
||||
|
||||
function renderGender() {
|
||||
return TableUtils.renderHtmlSelect({
|
||||
title: "Gender",
|
||||
value: gender,
|
||||
isEditing: !!props.isEditing,
|
||||
onChange: (value: string) => setGender(value),
|
||||
selectOptions: [""].concat(StashService.getGenderStrings()),
|
||||
});
|
||||
}
|
||||
|
||||
const twitterPrefix = "https://twitter.com/";
|
||||
const instagramPrefix = "https://www.instagram.com/";
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderDeleteAlert()}
|
||||
{renderScraperDialog()}
|
||||
|
||||
<HTMLTable id="performer-details" style={{width: "100%"}}>
|
||||
<tbody>
|
||||
{maybeRenderName()}
|
||||
{maybeRenderAliases()}
|
||||
{renderGender()}
|
||||
{TableUtils.renderInputGroup(
|
||||
{title: "Birthdate (YYYY-MM-DD)", value: birthdate, isEditing: !!props.isEditing, onChange: setBirthdate})}
|
||||
{renderEthnicity()}
|
||||
{TableUtils.renderInputGroup(
|
||||
{title: "Eye Color", value: eyeColor, isEditing: !!props.isEditing, onChange: setEyeColor})}
|
||||
{TableUtils.renderInputGroup(
|
||||
{title: "Country", value: country, isEditing: !!props.isEditing, onChange: setCountry})}
|
||||
{TableUtils.renderInputGroup(
|
||||
{title: "Height (CM)", value: height, isEditing: !!props.isEditing, onChange: setHeight})}
|
||||
{TableUtils.renderInputGroup(
|
||||
{title: "Measurements", value: measurements, isEditing: !!props.isEditing, onChange: setMeasurements})}
|
||||
{TableUtils.renderInputGroup(
|
||||
{title: "Fake Tits", value: fakeTits, isEditing: !!props.isEditing, onChange: setFakeTits})}
|
||||
{TableUtils.renderInputGroup(
|
||||
{title: "Career Length", value: careerLength, isEditing: !!props.isEditing, onChange: setCareerLength})}
|
||||
{TableUtils.renderInputGroup(
|
||||
{title: "Tattoos", value: tattoos, isEditing: !!props.isEditing, onChange: setTattoos})}
|
||||
{TableUtils.renderInputGroup(
|
||||
{title: "Piercings", value: piercings, isEditing: !!props.isEditing, onChange: setPiercings})}
|
||||
{renderURLField()}
|
||||
{TableUtils.renderInputGroup(
|
||||
{title: "Twitter", value: twitter, asURL: true, urlPrefix: twitterPrefix, isEditing: !!props.isEditing, onChange: setTwitter})}
|
||||
{TableUtils.renderInputGroup(
|
||||
{title: "Instagram", value: instagram, asURL: true, urlPrefix: instagramPrefix, isEditing: !!props.isEditing, onChange: setInstagram})}
|
||||
{renderImageInput()}
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
|
||||
{maybeRenderButtons()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { StashService } from "../../../core/StashService";
|
||||
import { ErrorUtils } from "../../../utils/errors";
|
||||
import { ToastUtils } from "../../../utils/toasts";
|
||||
|
||||
interface IPerformerOperationsProps {
|
||||
performer: Partial<GQL.PerformerDataFragment>
|
||||
}
|
||||
|
||||
export const PerformerOperationsPanel: FunctionComponent<IPerformerOperationsProps> = (props: IPerformerOperationsProps) => {
|
||||
|
||||
async function onAutoTag() {
|
||||
if (!props.performer || !props.performer.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await StashService.mutateMetadataAutoTag({ performers: [props.performer.id]});
|
||||
ToastUtils.success("Started auto tagging");
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button text="Auto Tag" onClick={onAutoTag} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { IBaseProps } from "../../../models";
|
||||
import { SceneList } from "../../scenes/SceneList";
|
||||
import { PerformersCriterion } from "../../../models/list-filter/criteria/performers";
|
||||
import { ListFilterModel } from "../../../models/list-filter/filter";
|
||||
|
||||
interface IPerformerDetailsProps {
|
||||
performer: Partial<GQL.PerformerDataFragment>
|
||||
base: IBaseProps
|
||||
}
|
||||
|
||||
export const PerformerScenesPanel: FunctionComponent<IPerformerDetailsProps> = (props: IPerformerDetailsProps) => {
|
||||
|
||||
function filterHook(filter: ListFilterModel) {
|
||||
let performerValue = {id: props.performer.id!, label: props.performer.name!};
|
||||
// if performers is already present, then we modify it, otherwise add
|
||||
let performerCriterion = filter.criteria.find((c) => {
|
||||
return c.type === "performers";
|
||||
});
|
||||
|
||||
if (performerCriterion &&
|
||||
(performerCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
|
||||
performerCriterion.modifier === GQL.CriterionModifier.Includes)) {
|
||||
// add the performer if not present
|
||||
if (!performerCriterion.value.find((p : any) => {
|
||||
return p.id === props.performer.id;
|
||||
})) {
|
||||
performerCriterion.value.push(performerValue);
|
||||
}
|
||||
|
||||
performerCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||
} else {
|
||||
// overwrite
|
||||
performerCriterion = new PerformersCriterion();
|
||||
performerCriterion.value = [performerValue];
|
||||
filter.criteria.push(performerCriterion);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
return (
|
||||
<SceneList
|
||||
base={props.base}
|
||||
subComponent={true}
|
||||
filterHook={filterHook}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import _ from "lodash";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { QueryHookResult } from "react-apollo-hooks";
|
||||
import { FindPerformersQuery, FindPerformersVariables } from "../../core/generated-graphql";
|
||||
import { ListHook } from "../../hooks/ListHook";
|
||||
import { IBaseProps } from "../../models/base-props";
|
||||
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
|
||||
import { PerformerCard } from "./PerformerCard";
|
||||
import { PerformerListTable } from "./PerformerListTable";
|
||||
import { StashService } from "../../core/StashService";
|
||||
|
||||
interface IPerformerListProps extends IBaseProps {}
|
||||
|
||||
export const PerformerList: FunctionComponent<IPerformerListProps> = (props: IPerformerListProps) => {
|
||||
const otherOperations = [
|
||||
{
|
||||
text: "Open Random",
|
||||
onClick: getRandom,
|
||||
}
|
||||
];
|
||||
|
||||
const listData = ListHook.useList({
|
||||
filterMode: FilterMode.Performers,
|
||||
props,
|
||||
otherOperations: otherOperations,
|
||||
renderContent,
|
||||
});
|
||||
|
||||
async function getRandom(result: QueryHookResult<FindPerformersQuery, FindPerformersVariables>, filter: ListFilterModel) {
|
||||
if (result.data && result.data.findPerformers) {
|
||||
let count = result.data.findPerformers.count;
|
||||
let index = Math.floor(Math.random() * count);
|
||||
let filterCopy = _.cloneDeep(filter);
|
||||
filterCopy.itemsPerPage = 1;
|
||||
filterCopy.currentPage = index + 1;
|
||||
const singleResult = await StashService.queryFindPerformers(filterCopy);
|
||||
if (singleResult && singleResult.data && singleResult.data.findPerformers && singleResult.data.findPerformers.performers.length === 1) {
|
||||
let id = singleResult!.data!.findPerformers!.performers[0]!.id;
|
||||
props.history.push("/performers/" + id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderContent(
|
||||
result: QueryHookResult<FindPerformersQuery, FindPerformersVariables>, filter: ListFilterModel) {
|
||||
if (!result.data || !result.data.findPerformers) { return; }
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<div className="grid">
|
||||
{result.data.findPerformers.performers.map((p) => (<PerformerCard key={p.id} performer={p} />))}
|
||||
</div>
|
||||
);
|
||||
} else if (filter.displayMode === DisplayMode.List) {
|
||||
return <PerformerListTable performers={result.data.findPerformers.performers}/>;
|
||||
} else if (filter.displayMode === DisplayMode.Wall) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return listData.template;
|
||||
};
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import {
|
||||
HTMLTable,
|
||||
H5,
|
||||
H6,
|
||||
Button,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { NavigationUtils } from "../../utils/navigation";
|
||||
|
||||
interface IPerformerListTableProps {
|
||||
performers: GQL.PerformerDataFragment[];
|
||||
}
|
||||
|
||||
export const PerformerListTable: FunctionComponent<IPerformerListTableProps> = (props: IPerformerListTableProps) => {
|
||||
|
||||
function maybeRenderFavoriteHeart(performer : GQL.PerformerDataFragment) {
|
||||
if (!performer.favorite) { return; }
|
||||
return (
|
||||
<Button
|
||||
icon="heart"
|
||||
disabled={true}
|
||||
className="favorite"
|
||||
minimal={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPerformerImage(performer : GQL.PerformerDataFragment) {
|
||||
const style: React.CSSProperties = {
|
||||
backgroundImage: `url('${performer.image_path}')`,
|
||||
lineHeight: 5,
|
||||
backgroundSize: "contain",
|
||||
display: "inline-block",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
className="performer-list-thumbnail"
|
||||
to={`/performers/${performer.id}`}
|
||||
style={style}/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPerformerRow(performer : GQL.PerformerDataFragment) {
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td>
|
||||
{renderPerformerImage(performer)}
|
||||
</td>
|
||||
<td style={{textAlign: "left"}}>
|
||||
<Link to={`/performers/${performer.id}`}>
|
||||
<H5 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
|
||||
{performer.name}
|
||||
</H5>
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{performer.aliases ? performer.aliases : ''}
|
||||
</td>
|
||||
<td>
|
||||
{maybeRenderFavoriteHeart(performer)}
|
||||
</td>
|
||||
<td>
|
||||
<Link to={NavigationUtils.makePerformerScenesUrl(performer)}>
|
||||
<H6>{performer.scene_count}</H6>
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{performer.birthdate}
|
||||
</td>
|
||||
<td>
|
||||
{performer.height}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid">
|
||||
<HTMLTable className="bp3-html-table bp3-html-table-bordered bp3-html-table-condensed bp3-html-table-striped bp3-interactive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>Aliases</th>
|
||||
<th>Favourite</th>
|
||||
<th>Scene Count</th>
|
||||
<th>Birthdate</th>
|
||||
<th>Height</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.performers.map(renderPerformerRow)}
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { Performer } from "./PerformerDetails/Performer";
|
||||
import { PerformerList } from "./PerformerList";
|
||||
|
||||
const Performers = () => (
|
||||
<Switch>
|
||||
<Route exact={true} path="/performers" component={PerformerList} />
|
||||
<Route path="/performers/:id" component={Performer} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
export default Performers;
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { Button, Popover, Menu, MenuItem } from "@blueprintjs/core";
|
||||
import { Icons } from "../../utils/icons";
|
||||
|
||||
export interface IOCounterButtonProps {
|
||||
loading: boolean
|
||||
value: number
|
||||
onIncrement: () => void
|
||||
onDecrement: () => void
|
||||
onReset: () => void
|
||||
onMenuOpened?: () => void
|
||||
onMenuClosed?: () => void
|
||||
}
|
||||
|
||||
export const OCounterButton: FunctionComponent<IOCounterButtonProps> = (props: IOCounterButtonProps) => {
|
||||
function renderButton() {
|
||||
return (
|
||||
<Button
|
||||
loading={props.loading}
|
||||
icon={Icons.sweatDrops()}
|
||||
text={props.value}
|
||||
minimal={true}
|
||||
onClick={props.onIncrement}
|
||||
disabled={props.loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.value) {
|
||||
// just render the button by itself
|
||||
return (
|
||||
<Popover
|
||||
interactionKind={"hover"}
|
||||
hoverOpenDelay={1000}
|
||||
position="bottom"
|
||||
disabled={props.loading}
|
||||
onOpening={props.onMenuOpened}
|
||||
onClosing={props.onMenuClosed}
|
||||
>
|
||||
{renderButton()}
|
||||
<Menu>
|
||||
<MenuItem text="Decrement" icon="minus" onClick={props.onDecrement}/>
|
||||
<MenuItem text="Reset" icon="disable" onClick={props.onReset}/>
|
||||
</Menu>
|
||||
</Popover>
|
||||
);
|
||||
} else {
|
||||
return renderButton();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,283 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Elevation,
|
||||
H4,
|
||||
Popover,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { VideoHoverHook } from "../../hooks/VideoHover";
|
||||
import { ColorUtils } from "../../utils/color";
|
||||
import { TextUtils } from "../../utils/text";
|
||||
import { TagLink } from "../Shared/TagLink";
|
||||
import { ZoomUtils } from "../../utils/zoom";
|
||||
import { StashService } from "../../core/StashService";
|
||||
import { Icons } from "../../utils/icons";
|
||||
|
||||
interface ISceneCardProps {
|
||||
scene: GQL.SlimSceneDataFragment;
|
||||
selected: boolean | undefined;
|
||||
zoomIndex: number;
|
||||
onSelectedChanged: (selected : boolean, shiftKey : boolean) => void;
|
||||
}
|
||||
|
||||
export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardProps) => {
|
||||
const [previewPath, setPreviewPath] = useState<string | undefined>(undefined);
|
||||
const videoHoverHook = VideoHoverHook.useVideoHover({resetOnMouseLeave: false});
|
||||
|
||||
const config = StashService.useConfiguration();
|
||||
const showStudioAsText = !!config.data && !!config.data.configuration ? config.data.configuration.interface.showStudioAsText : false;
|
||||
|
||||
function maybeRenderRatingBanner() {
|
||||
if (!props.scene.rating) { return; }
|
||||
return (
|
||||
<div className={`rating-banner ${ColorUtils.classForRating(props.scene.rating)}`}>
|
||||
RATING: {props.scene.rating}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderSceneSpecsOverlay() {
|
||||
return (
|
||||
<div className={`scene-specs-overlay`}>
|
||||
{!!props.scene.file.height ? <span className={`overlay-resolution`}> {TextUtils.resolution(props.scene.file.height)}</span> : undefined}
|
||||
{props.scene.file.duration !== undefined && props.scene.file.duration >= 1 ? TextUtils.secondsToTimestamp(props.scene.file.duration) : ""}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderSceneStudioOverlay() {
|
||||
if (!props.scene.studio) {
|
||||
return;
|
||||
}
|
||||
|
||||
let style: React.CSSProperties = {
|
||||
backgroundImage: `url('${props.scene.studio.image_path}')`,
|
||||
};
|
||||
|
||||
let text = "";
|
||||
|
||||
if (showStudioAsText) {
|
||||
style = {};
|
||||
text = props.scene.studio.name;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`scene-studio-overlay`}>
|
||||
<Link
|
||||
to={`/studios/${props.scene.studio.id}`}
|
||||
style={style}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderTagPopoverButton() {
|
||||
if (props.scene.tags.length <= 0) { return; }
|
||||
|
||||
const tags = props.scene.tags.map((tag) => (
|
||||
<TagLink key={tag.id} tag={tag} />
|
||||
));
|
||||
return (
|
||||
<Popover interactionKind={"hover"} position="bottom">
|
||||
<Button
|
||||
icon="tag"
|
||||
text={props.scene.tags.length}
|
||||
/>
|
||||
<>{tags}</>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderPerformerPopoverButton() {
|
||||
if (props.scene.performers.length <= 0) { return; }
|
||||
|
||||
const performers = props.scene.performers.map((performer) => {
|
||||
return (
|
||||
<>
|
||||
<div className="performer-tag-container">
|
||||
<Link
|
||||
to={`/performers/${performer.id}`}
|
||||
className="performer-tag previewable image"
|
||||
style={{backgroundImage: `url(${performer.image_path})`}}
|
||||
></Link>
|
||||
<TagLink key={performer.id} performer={performer} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Popover interactionKind={"hover"} position="bottom">
|
||||
<Button
|
||||
icon="person"
|
||||
text={props.scene.performers.length}
|
||||
/>
|
||||
<>{performers}</>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderMoviePopoverButton() {
|
||||
if (props.scene.movies.length <= 0) { return; }
|
||||
|
||||
const movies = props.scene.movies.map((sceneMovie) => {
|
||||
let movie = sceneMovie.movie;
|
||||
return (
|
||||
<>
|
||||
<div className="movie-tag-container">
|
||||
<Link
|
||||
to={`/movies/${movie.id}`}
|
||||
className="movie-tag previewable image"
|
||||
style={{backgroundImage: `url(${movie.front_image_path})`}}
|
||||
></Link>
|
||||
<TagLink key={movie.id} movie={movie} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover interactionKind={"hover"} position="bottom">
|
||||
<Button
|
||||
icon="film"
|
||||
text={props.scene.movies.length}
|
||||
/>
|
||||
<>{movies}</>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderSceneMarkerPopoverButton() {
|
||||
if (props.scene.scene_markers.length <= 0) { return; }
|
||||
|
||||
const sceneMarkers = props.scene.scene_markers.map((marker) => {
|
||||
(marker as any).scene = {};
|
||||
(marker as any).scene.id = props.scene.id;
|
||||
return <TagLink key={marker.id} marker={marker} />;
|
||||
});
|
||||
return (
|
||||
<Popover interactionKind={"hover"} position="bottom">
|
||||
<Button
|
||||
icon="map-marker"
|
||||
text={props.scene.scene_markers.length}
|
||||
/>
|
||||
<>{sceneMarkers}</>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOCounter() {
|
||||
if (props.scene.o_counter) {
|
||||
return (
|
||||
<Button
|
||||
icon={Icons.sweatDrops()}
|
||||
text={props.scene.o_counter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (props.scene.tags.length > 0 ||
|
||||
props.scene.performers.length > 0 ||
|
||||
props.scene.movies.length > 0 ||
|
||||
props.scene.scene_markers.length > 0 ||
|
||||
props.scene.o_counter) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<ButtonGroup minimal={true} className="card-section centered">
|
||||
{maybeRenderTagPopoverButton()}
|
||||
{maybeRenderPerformerPopoverButton()}
|
||||
{maybeRenderMoviePopoverButton()}
|
||||
{maybeRenderSceneMarkerPopoverButton()}
|
||||
{maybeRenderOCounter()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
if (!previewPath || previewPath === "") {
|
||||
setPreviewPath(props.scene.paths.preview || "");
|
||||
}
|
||||
VideoHoverHook.onMouseEnter(videoHoverHook);
|
||||
}
|
||||
function onMouseLeave() {
|
||||
VideoHoverHook.onMouseLeave(videoHoverHook);
|
||||
setPreviewPath("");
|
||||
}
|
||||
|
||||
function isPortrait() {
|
||||
let file = props.scene.file;
|
||||
let width = file.width ? file.width : 0;
|
||||
let height = file.height ? file.height : 0;
|
||||
return height > width;
|
||||
}
|
||||
|
||||
function getLinkClassName() {
|
||||
let ret = "image previewable";
|
||||
|
||||
if (isPortrait()) {
|
||||
ret += " portrait";
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function getVideoClassName() {
|
||||
let ret = "preview";
|
||||
|
||||
if (isPortrait()) {
|
||||
ret += " portrait";
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
var shiftKey = false;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={"grid-item scene-card " + ZoomUtils.classForZoom(props.zoomIndex)}
|
||||
elevation={Elevation.ONE}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<Checkbox
|
||||
className="card-select"
|
||||
checked={props.selected}
|
||||
onChange={() => props.onSelectedChanged(!props.selected, shiftKey)}
|
||||
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => { shiftKey = event.shiftKey; event.stopPropagation(); } }
|
||||
/>
|
||||
<Link to={`/scenes/${props.scene.id}`} className={getLinkClassName()}>
|
||||
<div className="video-container">
|
||||
{maybeRenderRatingBanner()}
|
||||
{maybeRenderSceneSpecsOverlay()}
|
||||
{maybeRenderSceneStudioOverlay()}
|
||||
<video className={getVideoClassName()} loop={true} poster={props.scene.paths.screenshot || ""} ref={videoHoverHook.videoEl}>
|
||||
{!!previewPath ? <source src={previewPath} /> : ""}
|
||||
</video>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="card-section">
|
||||
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
|
||||
{!!props.scene.title ? props.scene.title : TextUtils.fileNameFromPath(props.scene.path)}
|
||||
</H4>
|
||||
<span className="bp3-text-small bp3-text-muted">{props.scene.date}</span>
|
||||
<p>{TextUtils.truncate(props.scene.details, 100, "... (continued)")}</p>
|
||||
</div>
|
||||
|
||||
{maybeRenderPopoverButtonGroup()}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
import {
|
||||
Card,
|
||||
Spinner,
|
||||
Tab,
|
||||
Tabs,
|
||||
} from "@blueprintjs/core";
|
||||
import queryString from "query-string";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { StashService } from "../../../core/StashService";
|
||||
import { IBaseProps } from "../../../models";
|
||||
import { GalleryViewer } from "../../Galleries/GalleryViewer";
|
||||
import { ScenePlayer } from "../ScenePlayer/ScenePlayer";
|
||||
import { SceneDetailPanel } from "./SceneDetailPanel";
|
||||
import { SceneEditPanel } from "./SceneEditPanel";
|
||||
import { SceneFileInfoPanel } from "./SceneFileInfoPanel";
|
||||
import { SceneMarkersPanel } from "./SceneMarkersPanel";
|
||||
import { ScenePerformerPanel } from "./ScenePerformerPanel";
|
||||
import { SceneMoviePanel } from "./SceneMoviePanel";
|
||||
import { ErrorUtils } from "../../../utils/errors";
|
||||
import { IOCounterButtonProps, OCounterButton } from "../OCounterButton";
|
||||
import { SceneOperationsPanel } from "./SceneOperationsPanel";
|
||||
|
||||
interface ISceneProps extends IBaseProps {}
|
||||
|
||||
export const Scene: FunctionComponent<ISceneProps> = (props: ISceneProps) => {
|
||||
const [timestamp, setTimestamp] = useState<number>(0);
|
||||
const [autoplay, setAutoplay] = useState<boolean>(false);
|
||||
const [scene, setScene] = useState<Partial<GQL.SceneDataFragment>>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { data, error, loading, refetch } = StashService.useFindScene(props.match.params.id);
|
||||
|
||||
const [oLoading, setOLoading] = useState(false);
|
||||
|
||||
const incrementO = StashService.useSceneIncrementO(scene.id || "0");
|
||||
const decrementO = StashService.useSceneDecrementO(scene.id || "0");
|
||||
const resetO = StashService.useSceneResetO(scene.id || "0");
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(loading);
|
||||
if (!data || !data.findScene || !!error) { return; }
|
||||
setScene(StashService.nullToUndefined(data.findScene));
|
||||
}, [data, loading, error]);
|
||||
|
||||
useEffect(() => {
|
||||
const queryParams = queryString.parse(props.location.search);
|
||||
if (!!queryParams.t && typeof queryParams.t === "string" && timestamp === 0) {
|
||||
const newTimestamp = parseInt(queryParams.t, 10);
|
||||
setTimestamp(newTimestamp);
|
||||
}
|
||||
if (queryParams.autoplay && typeof queryParams.autoplay === "string") {
|
||||
setAutoplay(queryParams.autoplay === "true");
|
||||
}
|
||||
}, [props.location.search, timestamp]);
|
||||
|
||||
function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
|
||||
setTimestamp(marker.seconds);
|
||||
}
|
||||
|
||||
if (!data || !data.findScene || isLoading || Object.keys(scene).length === 0) {
|
||||
return <Spinner size={Spinner.SIZE_LARGE} />;
|
||||
}
|
||||
const modifiedScene =
|
||||
Object.assign({scene_marker_tags: data.sceneMarkerTags}, scene) as GQL.SceneDataFragment; // TODO Hack from angular
|
||||
if (!!error) { return <>error...</>; }
|
||||
|
||||
function updateOCounter(newValue: number) {
|
||||
const modifiedScene = Object.assign({}, scene);
|
||||
modifiedScene.o_counter = newValue;
|
||||
setScene(modifiedScene);
|
||||
}
|
||||
|
||||
async function onIncrementClick() {
|
||||
try {
|
||||
setOLoading(true);
|
||||
const result = await incrementO();
|
||||
updateOCounter(result.data.sceneIncrementO);
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
} finally {
|
||||
setOLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDecrementClick() {
|
||||
try {
|
||||
setOLoading(true);
|
||||
const result = await decrementO();
|
||||
updateOCounter(result.data.sceneDecrementO);
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
} finally {
|
||||
setOLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onResetClick() {
|
||||
try {
|
||||
setOLoading(true);
|
||||
const result = await resetO();
|
||||
updateOCounter(result.data.sceneResetO);
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
} finally {
|
||||
setOLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const oCounterProps : IOCounterButtonProps = {
|
||||
loading: oLoading,
|
||||
value: scene.o_counter || 0,
|
||||
onIncrement: onIncrementClick,
|
||||
onDecrement: onDecrementClick,
|
||||
onReset: onResetClick
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScenePlayer scene={modifiedScene} timestamp={timestamp} autoplay={autoplay}/>
|
||||
<Card id="details-container">
|
||||
<Tabs
|
||||
renderActiveTabPanelOnly={true}
|
||||
large={true}
|
||||
>
|
||||
<Tab id="scene-details-panel" title="Details" panel={<SceneDetailPanel scene={modifiedScene} />} />
|
||||
<Tab
|
||||
id="scene-markers-panel"
|
||||
title="Markers"
|
||||
panel={<SceneMarkersPanel scene={modifiedScene} onClickMarker={onClickMarker} />}
|
||||
/>
|
||||
{modifiedScene.performers.length > 0 ?
|
||||
<Tab
|
||||
id="scene-performer-panel"
|
||||
title="Performers"
|
||||
panel={<ScenePerformerPanel scene={modifiedScene} />}
|
||||
/> : undefined
|
||||
}
|
||||
{modifiedScene.movies.length > 0 ?
|
||||
<Tab
|
||||
id="scene-movie-panel"
|
||||
title="Movies"
|
||||
panel={<SceneMoviePanel scene={modifiedScene} />}
|
||||
/> : undefined
|
||||
}
|
||||
|
||||
{!!modifiedScene.gallery ?
|
||||
<Tab
|
||||
id="scene-gallery-panel"
|
||||
title="Gallery"
|
||||
panel={<GalleryViewer gallery={modifiedScene.gallery} />}
|
||||
/> : undefined
|
||||
}
|
||||
<Tab id="scene-file-info-panel" title="File Info" panel={<SceneFileInfoPanel scene={modifiedScene} />} />
|
||||
<Tab
|
||||
id="scene-edit-panel"
|
||||
title="Edit"
|
||||
panel={
|
||||
<SceneEditPanel
|
||||
scene={modifiedScene}
|
||||
onUpdate={(newScene) => setScene(newScene)}
|
||||
onDelete={() => props.history.push("/scenes")}
|
||||
/>}
|
||||
/>
|
||||
<Tab
|
||||
id="scene-operations-panel"
|
||||
title="Operations"
|
||||
panel={
|
||||
<SceneOperationsPanel
|
||||
scene={modifiedScene}
|
||||
/>}
|
||||
/>
|
||||
|
||||
<Tabs.Expander />
|
||||
<OCounterButton
|
||||
{...oCounterProps}
|
||||
/>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import {
|
||||
H1,
|
||||
H4,
|
||||
H6,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { TextUtils } from "../../../utils/text";
|
||||
import { TagLink } from "../../Shared/TagLink";
|
||||
import { SceneHelpers } from "../helpers";
|
||||
|
||||
interface ISceneDetailProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
}
|
||||
|
||||
export const SceneDetailPanel: FunctionComponent<ISceneDetailProps> = (props: ISceneDetailProps) => {
|
||||
function renderDetails() {
|
||||
if (!props.scene.details || props.scene.details === "") { return; }
|
||||
return (
|
||||
<>
|
||||
<H6>Details</H6>
|
||||
<p className="pre">{props.scene.details}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTags() {
|
||||
if (props.scene.tags.length === 0) { return; }
|
||||
const tags = props.scene.tags.map((tag) => (
|
||||
<TagLink key={tag.id} tag={tag} />
|
||||
));
|
||||
return (
|
||||
<>
|
||||
<H6>Tags</H6>
|
||||
{tags}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{SceneHelpers.maybeRenderStudio(props.scene, 70, false)}
|
||||
<H1 className="bp3-heading">
|
||||
{!!props.scene.title ? props.scene.title : TextUtils.fileNameFromPath(props.scene.path)}
|
||||
</H1>
|
||||
|
||||
{!!props.scene.date ? <H4>{props.scene.date}</H4> : undefined}
|
||||
{!!props.scene.rating ? <H6>Rating: {props.scene.rating}</H6> : undefined}
|
||||
{!!props.scene.file.height ? <H6>Resolution: {TextUtils.resolution(props.scene.file.height)}</H6> : undefined}
|
||||
{renderDetails()}
|
||||
{renderTags()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,487 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
Classes,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
FormGroup,
|
||||
HTMLSelect,
|
||||
InputGroup,
|
||||
Spinner,
|
||||
TextArea,
|
||||
Collapse,
|
||||
Icon,
|
||||
FileInput,
|
||||
Menu,
|
||||
Popover,
|
||||
MenuItem,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { StashService } from "../../../core/StashService";
|
||||
import { ErrorUtils } from "../../../utils/errors";
|
||||
import { ToastUtils } from "../../../utils/toasts";
|
||||
import { FilterMultiSelect } from "../../select/FilterMultiSelect";
|
||||
import { FilterSelect } from "../../select/FilterSelect";
|
||||
import { ValidGalleriesSelect } from "../../select/ValidGalleriesSelect";
|
||||
import { ImageUtils } from "../../../utils/image";
|
||||
import { SceneMovieTable } from "./SceneMovieTable";
|
||||
|
||||
interface IProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
onUpdate: (scene: GQL.SceneDataFragment) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
|
||||
// Editing scene state
|
||||
const [title, setTitle] = useState<string | undefined>(undefined);
|
||||
const [details, setDetails] = useState<string | undefined>(undefined);
|
||||
const [url, setUrl] = useState<string | undefined>(undefined);
|
||||
const [date, setDate] = useState<string | undefined>(undefined);
|
||||
const [rating, setRating] = useState<number | undefined>(undefined);
|
||||
const [galleryId, setGalleryId] = useState<string | undefined>(undefined);
|
||||
const [studioId, setStudioId] = useState<string | undefined>(undefined);
|
||||
const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined);
|
||||
const [movieIds, setMovieIds] = useState<string[] | undefined>(undefined);
|
||||
const [sceneIdx, setSceneIdx] = useState<string[] | undefined>(undefined);
|
||||
const [tagIds, setTagIds] = useState<string[] | undefined>(undefined);
|
||||
const [coverImage, setCoverImage] = useState<string | undefined>(undefined);
|
||||
|
||||
const Scrapers = StashService.useListSceneScrapers();
|
||||
const [queryableScrapers, setQueryableScrapers] = useState<GQL.ListSceneScrapersListSceneScrapers[]>([]);
|
||||
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
const [deleteFile, setDeleteFile] = useState<boolean>(false);
|
||||
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
|
||||
|
||||
const [isCoverImageOpen, setIsCoverImageOpen] = useState<boolean>(false);
|
||||
const [coverImagePreview, setCoverImagePreview] = useState<string | undefined>(undefined);
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const updateScene = StashService.useSceneUpdate(getSceneInput());
|
||||
const deleteScene = StashService.useSceneDestroy(getSceneDeleteInput());
|
||||
|
||||
useEffect(() => {
|
||||
var newQueryableScrapers : GQL.ListSceneScrapersListSceneScrapers[] = [];
|
||||
|
||||
if (!!Scrapers.data && Scrapers.data.listSceneScrapers) {
|
||||
newQueryableScrapers = Scrapers.data.listSceneScrapers.filter((s) => {
|
||||
return s.scene && s.scene.supported_scrapes.includes(GQL.ScrapeType.Fragment);
|
||||
});
|
||||
}
|
||||
|
||||
setQueryableScrapers(newQueryableScrapers);
|
||||
|
||||
}, [Scrapers.data])
|
||||
|
||||
function updateSceneEditState(state: Partial<GQL.SceneDataFragment>) {
|
||||
const perfIds = !!state.performers ? state.performers.map((performer) => performer.id) : undefined;
|
||||
const moviIds = !!state.movies ? state.movies.map((sceneMovie) => sceneMovie.movie.id) : undefined;
|
||||
const scenIdx = !!state.movies ? state.movies.map((movie) => movie.scene_index!) : undefined;
|
||||
|
||||
const tIds = !!state.tags ? state.tags.map((tag) => tag.id) : undefined;
|
||||
|
||||
setTitle(state.title);
|
||||
setDetails(state.details);
|
||||
setUrl(state.url);
|
||||
setDate(state.date);
|
||||
setRating(state.rating == null ? NaN : state.rating);
|
||||
setGalleryId(state.gallery ? state.gallery.id : undefined);
|
||||
setStudioId(state.studio ? state.studio.id : undefined);
|
||||
setMovieIds(moviIds);
|
||||
setPerformerIds(perfIds);
|
||||
setSceneIdx(scenIdx);
|
||||
setTagIds(tIds);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateSceneEditState(props.scene);
|
||||
setCoverImagePreview(props.scene.paths.screenshot);
|
||||
}, [props.scene]);
|
||||
|
||||
ImageUtils.addPasteImageHook(onImageLoad);
|
||||
|
||||
// if (!isNew && !isEditing) {
|
||||
// if (!data || !data.findPerformer || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
|
||||
// if (!!error) { return <>error...</>; }
|
||||
// }
|
||||
|
||||
function getSceneInput(): GQL.SceneUpdateInput {
|
||||
return {
|
||||
id: props.scene.id,
|
||||
title,
|
||||
details,
|
||||
url,
|
||||
date,
|
||||
rating,
|
||||
gallery_id: galleryId,
|
||||
studio_id: studioId,
|
||||
performer_ids: performerIds,
|
||||
movies: makeMovieInputs(),
|
||||
tag_ids: tagIds,
|
||||
cover_image: coverImage,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMovieInputs(): GQL.SceneMovieInput[] | undefined {
|
||||
if (!movieIds) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let ret = movieIds.map((id) => {
|
||||
let r : GQL.SceneMovieInput = {
|
||||
movie_id: id
|
||||
};
|
||||
return r;
|
||||
});
|
||||
|
||||
if (sceneIdx) {
|
||||
sceneIdx.forEach((idx, i) => {
|
||||
if (!!idx && ret.length > i) {
|
||||
ret[i].scene_index = idx;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateScene();
|
||||
props.onUpdate(result.data.sceneUpdate);
|
||||
ToastUtils.success("Updated scene");
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
function getSceneDeleteInput(): GQL.SceneDestroyInput {
|
||||
return {
|
||||
id: props.scene.id,
|
||||
delete_file: deleteFile,
|
||||
delete_generated: deleteGenerated
|
||||
};
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
setIsDeleteAlertOpen(false);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await deleteScene();
|
||||
ToastUtils.success("Deleted scene");
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
props.onDelete();
|
||||
}
|
||||
|
||||
function renderMultiSelect(type: "performers" | "movies" | "tags", initialIds: string[] | undefined) {
|
||||
return (
|
||||
<FilterMultiSelect
|
||||
type={type}
|
||||
onUpdate={(items) => {
|
||||
const ids = items.map((i) => i.id);
|
||||
switch (type) {
|
||||
case "performers": setPerformerIds(ids); break;
|
||||
case "movies": setMovieIds(ids); break;
|
||||
case "tags": setTagIds(ids); break;
|
||||
}
|
||||
}}
|
||||
initialIds={initialIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTableMovies( initialIds: string[] | undefined, initialIdx: string[] | undefined ) {
|
||||
return (
|
||||
<SceneMovieTable
|
||||
initialIds={initialIds}
|
||||
initialIdx={initialIdx}
|
||||
onUpdate={(items) => {
|
||||
const idx = items.map((i) => i);
|
||||
setSceneIdx(idx);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDeleteAlert() {
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
canOutsideClickClose={false}
|
||||
canEscapeKeyClose={false}
|
||||
icon="trash"
|
||||
isCloseButtonShown={false}
|
||||
isOpen={isDeleteAlertOpen}
|
||||
title="Delete Scene?"
|
||||
>
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<p>
|
||||
Are you sure you want to delete this scene? Unless the file is also deleted, this scene will be re-added when scan is performed.
|
||||
</p>
|
||||
<Checkbox checked={deleteFile} label="Delete file" onChange={() => setDeleteFile(!deleteFile)} />
|
||||
<Checkbox checked={deleteGenerated} label="Delete generated supporting files" onChange={() => setDeleteGenerated(!deleteGenerated)} />
|
||||
</div>
|
||||
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Button intent="danger" onClick={() => onDelete()}>Delete</Button>
|
||||
<Button onClick={() => setIsDeleteAlertOpen(false)}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function onImageLoad(this: FileReader) {
|
||||
setCoverImagePreview(this.result as string);
|
||||
setCoverImage(this.result as string);
|
||||
}
|
||||
|
||||
function onCoverImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||
ImageUtils.onImageChange(event, onImageLoad);
|
||||
}
|
||||
|
||||
async function onScrapeClicked(scraper : GQL.ListSceneScrapersListSceneScrapers) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await StashService.queryScrapeScene(scraper.id, getSceneInput());
|
||||
if (!result.data || !result.data.scrapeScene) { return; }
|
||||
updateSceneFromScrapedScene(result.data.scrapeScene);
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderScraperMenuItem(scraper : GQL.ListSceneScrapersListSceneScrapers) {
|
||||
return (
|
||||
<MenuItem
|
||||
text={scraper.name}
|
||||
onClick={() => { onScrapeClicked(scraper); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderScraperMenu() {
|
||||
if (!queryableScrapers || queryableScrapers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scraperMenu = (
|
||||
<Menu>
|
||||
{queryableScrapers ? queryableScrapers.map((s) => renderScraperMenuItem(s)) : undefined}
|
||||
</Menu>
|
||||
);
|
||||
return (
|
||||
<Popover content={scraperMenu} position="bottom">
|
||||
<Button text="Scrape with..."/>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function urlScrapable(url: string) : boolean {
|
||||
return !!url && !!Scrapers.data && Scrapers.data.listSceneScrapers && Scrapers.data.listSceneScrapers.some((s) => {
|
||||
return !!s.scene && !!s.scene.urls && s.scene.urls.some((u) => { return url.includes(u); });
|
||||
});
|
||||
}
|
||||
|
||||
function updateSceneFromScrapedScene(scene : GQL.ScrapedSceneDataFragment) {
|
||||
if (!title && scene.title) {
|
||||
setTitle(scene.title);
|
||||
}
|
||||
|
||||
if (!details && scene.details) {
|
||||
setDetails(scene.details);
|
||||
}
|
||||
|
||||
if (!date && scene.date) {
|
||||
setDate(scene.date);
|
||||
}
|
||||
|
||||
if (!url && scene.url) {
|
||||
setUrl(scene.url);
|
||||
}
|
||||
|
||||
if (!studioId && scene.studio && scene.studio.id) {
|
||||
setStudioId(scene.studio.id);
|
||||
}
|
||||
|
||||
if ((!performerIds || performerIds.length === 0) && scene.performers && scene.performers.length > 0) {
|
||||
let idPerfs = scene.performers.filter((p) => {
|
||||
return p.id !== undefined && p.id !== null;
|
||||
});
|
||||
|
||||
if (idPerfs.length > 0) {
|
||||
let newIds = idPerfs.map((p) => p.id);
|
||||
setPerformerIds(newIds as string[]);
|
||||
}
|
||||
}
|
||||
|
||||
if ((!movieIds || movieIds.length === 0) && scene.movies && scene.movies.length > 0) {
|
||||
let idMovis = scene.movies.filter((p) => {
|
||||
return p.id !== undefined && p.id !== null;
|
||||
});
|
||||
|
||||
if (idMovis.length > 0) {
|
||||
let newIds = idMovis.map((p) => p.id);
|
||||
setMovieIds(newIds as string[]);
|
||||
}
|
||||
}
|
||||
|
||||
if ((!sceneIdx || sceneIdx.length === 0) && scene.movies && scene.movies.length > 0) {
|
||||
let idxScen= scene.movies.filter((p) => {
|
||||
return p.id !== undefined && p.id !== null;
|
||||
});
|
||||
|
||||
if (idxScen.length > 0) {
|
||||
let newIds = idxScen.map((p) => p.id);
|
||||
setSceneIdx(newIds as string[]);
|
||||
}
|
||||
}
|
||||
|
||||
if ((!tagIds || tagIds.length === 0) && scene.tags && scene.tags.length > 0) {
|
||||
let idTags = scene.tags.filter((p) => {
|
||||
return p.id !== undefined && p.id !== null;
|
||||
});
|
||||
|
||||
if (idTags.length > 0) {
|
||||
let newIds = idTags.map((p) => p.id);
|
||||
setTagIds(newIds as string[]);
|
||||
}
|
||||
}
|
||||
|
||||
if (scene.image) {
|
||||
// image is a base64 string
|
||||
setCoverImage(scene.image);
|
||||
setCoverImagePreview(scene.image);
|
||||
}
|
||||
}
|
||||
|
||||
async function onScrapeSceneURL() {
|
||||
if (!url) { return; }
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await StashService.queryScrapeSceneURL(url);
|
||||
if (!result.data || !result.data.scrapeSceneURL) { return; }
|
||||
updateSceneFromScrapedScene(result.data.scrapeSceneURL);
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderScrapeButton() {
|
||||
if (!url || !urlScrapable(url)) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
minimal={true}
|
||||
icon="import"
|
||||
id="scrape-url-button"
|
||||
onClick={() => onScrapeSceneURL()}/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderDeleteAlert()}
|
||||
{isLoading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
||||
<div className="form-container " style={{width: "50%"}}>
|
||||
<FormGroup label="Title">
|
||||
<InputGroup
|
||||
onChange={(newValue: any) => setTitle(newValue.target.value)}
|
||||
value={title}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Details">
|
||||
<TextArea
|
||||
fill={true}
|
||||
onChange={(newValue) => setDetails(newValue.target.value)}
|
||||
value={details}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="URL">
|
||||
<InputGroup
|
||||
onChange={(newValue: any) => setUrl(newValue.target.value)}
|
||||
value={url}
|
||||
/>
|
||||
{maybeRenderScrapeButton()}
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Date" helperText="YYYY-MM-DD">
|
||||
<InputGroup
|
||||
onChange={(newValue: any) => setDate(newValue.target.value)}
|
||||
value={date}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Rating">
|
||||
<HTMLSelect
|
||||
options={["", 1, 2, 3, 4, 5]}
|
||||
onChange={(event) => setRating(parseInt(event.target.value, 10))}
|
||||
value={rating}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Gallery">
|
||||
<ValidGalleriesSelect
|
||||
sceneId={props.scene.id}
|
||||
initialId={galleryId}
|
||||
onSelectItem={(item) => setGalleryId(item ? item.id : undefined)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Studio">
|
||||
<FilterSelect
|
||||
type="studios"
|
||||
onSelectItem={(item) => setStudioId(item ? item.id : undefined)}
|
||||
initialId={studioId}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Performers">
|
||||
{renderMultiSelect("performers", performerIds)}
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Movies/Scenes">
|
||||
{renderMultiSelect("movies", movieIds)}
|
||||
{renderTableMovies(movieIds, sceneIdx)}
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Tags">
|
||||
{renderMultiSelect("tags", tagIds)}
|
||||
</FormGroup>
|
||||
|
||||
<div className="bp3-form-group">
|
||||
<label className="bp3-label collapsible-label" onClick={() => setIsCoverImageOpen(!isCoverImageOpen)}>
|
||||
<Icon className="label-icon" icon={isCoverImageOpen ? "chevron-down" : "chevron-right"}/>
|
||||
<span>Cover Image</span>
|
||||
</label>
|
||||
<Collapse isOpen={isCoverImageOpen}>
|
||||
<img alt="Scene cover" className="scene-cover" src={coverImagePreview} />
|
||||
<FileInput text="Choose image..." onInputChange={onCoverImageChange} inputProps={{accept: ".jpg,.jpeg,.png"}} />
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<Button className="edit-button" text="Save" intent="primary" onClick={() => onSave()}/>
|
||||
<Button className="edit-button" text="Delete" intent="danger" onClick={() => setIsDeleteAlertOpen(true)}/>
|
||||
{renderScraperMenu()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
import {
|
||||
HTMLTable,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { TextUtils } from "../../../utils/text";
|
||||
|
||||
interface ISceneFileInfoPanelProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
}
|
||||
|
||||
export const SceneFileInfoPanel: FunctionComponent<ISceneFileInfoPanelProps> = (props: ISceneFileInfoPanelProps) => {
|
||||
function renderChecksum() {
|
||||
return (
|
||||
<tr>
|
||||
<td>Checksum</td>
|
||||
<td>{props.scene.checksum}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPath() {
|
||||
return (
|
||||
<tr>
|
||||
<td>Path</td>
|
||||
<td><a href={"file://"+props.scene.path}>{"file://"+props.scene.path}</a> </td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStream() {
|
||||
return (
|
||||
<tr>
|
||||
<td>Stream</td>
|
||||
<td><a href={props.scene.paths.stream}>{props.scene.paths.stream}</a> </td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFileSize() {
|
||||
if (props.scene.file.size === undefined) { return; }
|
||||
return (
|
||||
<tr>
|
||||
<td>File Size</td>
|
||||
<td>{TextUtils.fileSize(parseInt(props.scene.file.size, 10))}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDuration() {
|
||||
if (props.scene.file.duration === undefined) { return; }
|
||||
return (
|
||||
<tr>
|
||||
<td>Duration</td>
|
||||
<td>{TextUtils.secondsToTimestamp(props.scene.file.duration)}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDimensions() {
|
||||
if (props.scene.file.duration === undefined) { return; }
|
||||
return (
|
||||
<tr>
|
||||
<td>Dimensions</td>
|
||||
<td>{props.scene.file.width} x {props.scene.file.height}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFrameRate() {
|
||||
if (props.scene.file.framerate === undefined) { return; }
|
||||
return (
|
||||
<tr>
|
||||
<td>Frame Rate</td>
|
||||
<td>{props.scene.file.framerate} frames per second</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBitRate() {
|
||||
if (props.scene.file.bitrate === undefined) { return; }
|
||||
return (
|
||||
<tr>
|
||||
<td>Bit Rate</td>
|
||||
<td>{TextUtils.bitRate(props.scene.file.bitrate)}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function renderVideoCodec() {
|
||||
if (props.scene.file.video_codec === undefined) { return; }
|
||||
return (
|
||||
<tr>
|
||||
<td>Video Codec</td>
|
||||
<td>{props.scene.file.video_codec}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAudioCodec() {
|
||||
if (props.scene.file.audio_codec === undefined) { return; }
|
||||
return (
|
||||
<tr>
|
||||
<td>Audio Codec</td>
|
||||
<td>{props.scene.file.audio_codec}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function renderUrl() {
|
||||
if (!props.scene.url || props.scene.url === "") { return; }
|
||||
return (
|
||||
<tr>
|
||||
<td>Downloaded From</td>
|
||||
<td>{props.scene.url}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<HTMLTable>
|
||||
<tbody>
|
||||
{renderChecksum()}
|
||||
{renderPath()}
|
||||
{renderStream()}
|
||||
{renderFileSize()}
|
||||
{renderDuration()}
|
||||
{renderDimensions()}
|
||||
{renderFrameRate()}
|
||||
{renderBitRate()}
|
||||
{renderVideoCodec()}
|
||||
{renderAudioCodec()}
|
||||
{renderUrl()}
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Divider,
|
||||
FormGroup,
|
||||
H3,
|
||||
Tag,
|
||||
} from "@blueprintjs/core";
|
||||
import { Field, FieldProps, Form, Formik, FormikActions, FormikProps } from "formik";
|
||||
import React, { CSSProperties, FunctionComponent, useState } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { StashService } from "../../../core/StashService";
|
||||
import { TextUtils } from "../../../utils/text";
|
||||
import { FilterMultiSelect } from "../../select/FilterMultiSelect";
|
||||
import { FilterSelect } from "../../select/FilterSelect";
|
||||
import { MarkerTitleSuggest } from "../../select/MarkerTitleSuggest";
|
||||
import { WallPanel } from "../../Wall/WallPanel";
|
||||
import { SceneHelpers } from "../helpers";
|
||||
import { ErrorUtils } from "../../../utils/errors";
|
||||
import { DurationInput } from "../../Shared/DurationInput";
|
||||
|
||||
interface ISceneMarkersPanelProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;
|
||||
}
|
||||
|
||||
interface IFormFields {
|
||||
title: string;
|
||||
seconds: string;
|
||||
primaryTagId: string;
|
||||
tagIds: string[];
|
||||
}
|
||||
|
||||
export const SceneMarkersPanel: FunctionComponent<ISceneMarkersPanelProps> = (props: ISceneMarkersPanelProps) => {
|
||||
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
|
||||
const [editingMarker, setEditingMarker] = useState<GQL.SceneMarkerDataFragment | null>(null);
|
||||
|
||||
const sceneMarkerCreate = StashService.useSceneMarkerCreate();
|
||||
const sceneMarkerUpdate = StashService.useSceneMarkerUpdate();
|
||||
const sceneMarkerDestroy = StashService.useSceneMarkerDestroy();
|
||||
|
||||
const jwplayer = SceneHelpers.getPlayer();
|
||||
|
||||
function onOpenEditor(marker: GQL.SceneMarkerDataFragment | null = null) {
|
||||
setIsEditorOpen(true);
|
||||
setEditingMarker(marker);
|
||||
}
|
||||
|
||||
function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
|
||||
props.onClickMarker(marker);
|
||||
}
|
||||
|
||||
function renderTags() {
|
||||
function renderMarkers(primaryTag: GQL.FindSceneSceneMarkerTags) {
|
||||
const markers = primaryTag.scene_markers.map((marker) => {
|
||||
const markerTags = marker.tags.map((tag) => (
|
||||
<Tag key={tag.id} className="tag-item">{tag.name}</Tag>
|
||||
));
|
||||
|
||||
return (
|
||||
<div key={marker.id}>
|
||||
<Divider />
|
||||
<div>
|
||||
<button className="button-link" onClick={() => onClickMarker(marker)}>{marker.title}</button>
|
||||
{!isEditorOpen ? <button className="button-link" style={{float: "right"}} onClick={() => onOpenEditor(marker)}>Edit</button> : undefined}
|
||||
</div>
|
||||
<div>
|
||||
{TextUtils.secondsToTimestamp(marker.seconds)}
|
||||
</div>
|
||||
<div className="card-section centered">
|
||||
{markerTags}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return markers;
|
||||
}
|
||||
|
||||
const style: CSSProperties = {
|
||||
height: "300px",
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
display: "inline-block",
|
||||
margin: "5px",
|
||||
width: "300px",
|
||||
flex: "0 0 auto",
|
||||
};
|
||||
const tags = (props.scene as any).scene_marker_tags.map((primaryTag: GQL.FindSceneSceneMarkerTags) => {
|
||||
|
||||
return (
|
||||
<div key={primaryTag.tag.id} style={{padding: "1px"}}>
|
||||
<Card style={style}>
|
||||
<div className="content" style={{whiteSpace: "normal"}}>
|
||||
<H3>{primaryTag.tag.name}</H3>
|
||||
{renderMarkers(primaryTag)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return tags;
|
||||
}
|
||||
|
||||
function renderForm() {
|
||||
function onSubmit(values: IFormFields, _: FormikActions<IFormFields>) {
|
||||
const isEditing = !!editingMarker;
|
||||
const variables: GQL.SceneMarkerCreateVariables | GQL.SceneMarkerUpdateVariables = {
|
||||
title: values.title,
|
||||
seconds: parseFloat(values.seconds),
|
||||
scene_id: props.scene.id,
|
||||
primary_tag_id: values.primaryTagId,
|
||||
tag_ids: values.tagIds,
|
||||
};
|
||||
if (!isEditing) {
|
||||
sceneMarkerCreate({ variables }).then((response) => {
|
||||
setIsEditorOpen(false);
|
||||
setEditingMarker(null);
|
||||
}).catch((err) => ErrorUtils.handleApolloError(err));
|
||||
} else {
|
||||
const updateVariables = variables as GQL.SceneMarkerUpdateVariables;
|
||||
updateVariables.id = editingMarker!.id;
|
||||
sceneMarkerUpdate({ variables: updateVariables }).then((response) => {
|
||||
setIsEditorOpen(false);
|
||||
setEditingMarker(null);
|
||||
}).catch((err) => ErrorUtils.handleApolloError(err));
|
||||
}
|
||||
}
|
||||
function onDelete() {
|
||||
if (!editingMarker) { return; }
|
||||
sceneMarkerDestroy({variables: {id: editingMarker.id}}).then((response) => {
|
||||
console.log(response);
|
||||
}).catch((err) => console.error(err));
|
||||
setIsEditorOpen(false);
|
||||
setEditingMarker(null);
|
||||
}
|
||||
function renderTitleField(fieldProps: FieldProps<IFormFields>) {
|
||||
return (
|
||||
<MarkerTitleSuggest
|
||||
initialMarkerString={!!editingMarker ? editingMarker.title : undefined}
|
||||
placeholder="Title"
|
||||
name={fieldProps.field.name}
|
||||
onBlur={fieldProps.field.onBlur}
|
||||
value={fieldProps.field.value}
|
||||
onQueryChange={(query) => fieldProps.form.setFieldValue("title", query)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function renderSecondsField(fieldProps: FieldProps<IFormFields>) {
|
||||
return (
|
||||
<DurationInput
|
||||
onValueChange={(s) => fieldProps.form.setFieldValue("seconds", s)}
|
||||
onReset={() => fieldProps.form.setFieldValue("seconds", Math.round(jwplayer.getPosition()))}
|
||||
numericValue={fieldProps.field.value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function renderPrimaryTagField(fieldProps: FieldProps<IFormFields>) {
|
||||
return (
|
||||
<FilterSelect
|
||||
type="tags"
|
||||
onSelectItem={(tag) => fieldProps.form.setFieldValue("primaryTagId", tag ? tag.id : undefined)}
|
||||
initialId={!!editingMarker ? editingMarker.primary_tag.id : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function renderTagsField(fieldProps: FieldProps<IFormFields>) {
|
||||
return (
|
||||
<FilterMultiSelect
|
||||
type="tags"
|
||||
onUpdate={(tags) => fieldProps.form.setFieldValue("tagIds", tags.map((tag) => tag.id))}
|
||||
initialIds={!!editingMarker ? fieldProps.form.values.tagIds : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function renderFormFields(formikProps: FormikProps<IFormFields>) {
|
||||
let deleteButton: JSX.Element | undefined;
|
||||
if (!!editingMarker) {
|
||||
deleteButton = (
|
||||
<Button
|
||||
type="button"
|
||||
intent="danger"
|
||||
style={{float: "right", marginRight: "10px"}}
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Form style={{marginTop: "10px"}}>
|
||||
<div className="columns is-multiline is-gapless">
|
||||
<FormGroup label="Scene Marker Title" labelFor="title" className="column is-full">
|
||||
<Field name="title" render={renderTitleField} />
|
||||
</FormGroup>
|
||||
<FormGroup label="Time" labelFor="seconds" className="column is-half">
|
||||
<Field name="seconds" render={renderSecondsField} />
|
||||
</FormGroup>
|
||||
<FormGroup label="Primary Tag" labelFor="primaryTagId" className="column is-half">
|
||||
<Field name="primaryTagId" render={renderPrimaryTagField} />
|
||||
</FormGroup>
|
||||
<FormGroup label="Tags" labelFor="tagIds" className="column is-full">
|
||||
<Field name="tagIds" render={renderTagsField} />
|
||||
</FormGroup>
|
||||
</div>
|
||||
<div className="buttons-container">
|
||||
<Button intent="primary" type="submit">Submit</Button>
|
||||
<Button type="button" onClick={() => setIsEditorOpen(false)}>Cancel</Button>
|
||||
{deleteButton}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
let initialValues: any;
|
||||
if (!!editingMarker) {
|
||||
initialValues = {
|
||||
title: editingMarker.title,
|
||||
seconds: editingMarker.seconds,
|
||||
primaryTagId: editingMarker.primary_tag.id,
|
||||
tagIds: editingMarker.tags.map((tag) => tag.id),
|
||||
};
|
||||
} else {
|
||||
initialValues = {title: "", seconds: Math.round(jwplayer.getPosition()), primaryTagId: "", tagIds: []};
|
||||
}
|
||||
return (
|
||||
<Collapse isOpen={isEditorOpen}>
|
||||
{isEditorOpen ? <Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
render={renderFormFields}
|
||||
/> : undefined}
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const newMarkerForm = (
|
||||
<div style={{margin: "5px"}}>
|
||||
<Button onClick={() => onOpenEditor()}>Create</Button>
|
||||
{renderForm()}
|
||||
</div>
|
||||
);
|
||||
if (props.scene.scene_markers.length === 0) {
|
||||
return newMarkerForm;
|
||||
}
|
||||
|
||||
const containerStyle: CSSProperties = {
|
||||
overflowY: "hidden",
|
||||
overflowX: "scroll",
|
||||
whiteSpace: "nowrap",
|
||||
display: "flex",
|
||||
flexWrap: "nowrap",
|
||||
marginBottom: "20px",
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{newMarkerForm}
|
||||
<div style={containerStyle}>
|
||||
{renderTags()}
|
||||
</div>
|
||||
<WallPanel
|
||||
sceneMarkers={props.scene.scene_markers}
|
||||
clickHandler={(marker) => { window.scrollTo(0, 0); onClickMarker(marker as any); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return render();
|
||||
};
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { MovieCard } from "../../Movies/MovieCard";
|
||||
|
||||
interface ISceneMoviePanelProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
}
|
||||
|
||||
export const SceneMoviePanel: FunctionComponent<ISceneMoviePanelProps> = (props: ISceneMoviePanelProps) => {
|
||||
const cards = props.scene.movies.map((sceneMovie) => (
|
||||
<MovieCard key={sceneMovie.movie.id} movie={sceneMovie.movie} sceneIndex={sceneMovie.scene_index} />
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid">
|
||||
{cards}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import * as React from "react";
|
||||
import { HTMLSelect, Divider} from "@blueprintjs/core";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { StashService } from "../../../core/StashService";
|
||||
|
||||
type ValidTypes = GQL.SlimMovieDataFragment;
|
||||
|
||||
export interface IProps {
|
||||
initialIds: string[] | undefined;
|
||||
initialIdx: string[] | undefined;
|
||||
onUpdate: (itemsNumber: string[]) => void;
|
||||
}
|
||||
let items: ValidTypes[];
|
||||
let itemsFilter: ValidTypes[];
|
||||
let storeIdx: string[];
|
||||
|
||||
export const SceneMovieTable: React.FunctionComponent<IProps> = (props: IProps) => {
|
||||
const [itemsNumber, setItemsNumber] = React.useState<string[]>([]);
|
||||
const [initialIdsprev, setinitialIdsprev] = React.useState(props.initialIds);
|
||||
const { data } = StashService.useAllMoviesForFilter();
|
||||
|
||||
items = !!data && !!data.allMovies ? data.allMovies : [];
|
||||
itemsFilter=[];
|
||||
storeIdx=[];
|
||||
|
||||
|
||||
if (!!props.initialIds && !!items && !!props.initialIdx)
|
||||
{
|
||||
for(var i=0; i< props.initialIds!.length; i++)
|
||||
{
|
||||
itemsFilter=itemsFilter.concat(items.filter((x) => x.id ===props.initialIds![i]));
|
||||
}
|
||||
|
||||
}
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
React.useEffect(() => {
|
||||
if (!!props.initialIdx)
|
||||
{
|
||||
setItemsNumber(props.initialIdx);
|
||||
}
|
||||
}, [props.initialIdx]);
|
||||
/* eslint-enable */
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!!props.initialIds) {
|
||||
setinitialIdsprev(props.initialIds);
|
||||
UpdateIndex();
|
||||
}
|
||||
}, [props.initialIds]);
|
||||
|
||||
const updateFieldChanged = (index : any) => (e : any) => {
|
||||
let newArr = [...itemsNumber];
|
||||
newArr[index] = e.target.value;
|
||||
setItemsNumber(newArr);
|
||||
props.onUpdate(newArr);
|
||||
}
|
||||
|
||||
const updateIdsChanged = (index : any, value: string) => {
|
||||
storeIdx.push(value);
|
||||
setItemsNumber(storeIdx);
|
||||
props.onUpdate(storeIdx);
|
||||
}
|
||||
|
||||
|
||||
function UpdateIndex(){
|
||||
|
||||
if (!!props.initialIds && !!initialIdsprev ){
|
||||
loop1:
|
||||
for(var i=0; i< props.initialIds!.length; i++) {
|
||||
for(var j=0; j< initialIdsprev!.length; j++) {
|
||||
|
||||
if (props.initialIds[i]===initialIdsprev[j])
|
||||
{
|
||||
updateIdsChanged(i, props.initialIdx![j]);
|
||||
continue loop1;
|
||||
}
|
||||
}
|
||||
updateIdsChanged(i, "0");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
function renderTableData() {
|
||||
|
||||
return(
|
||||
<tbody>
|
||||
{ itemsFilter!.map((item, index : any) => (
|
||||
<tr>
|
||||
<td>{item.name} </td>
|
||||
<td><Divider /> </td>
|
||||
<td key={item.toString()}> Scene number: <HTMLSelect
|
||||
options={["","1", "2", "3", "4", "5","6","7","8","9","10"]}
|
||||
onChange={updateFieldChanged(index)}
|
||||
value={itemsNumber[index]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<div>
|
||||
<table id='movies'>
|
||||
{renderTableData()}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { StashService } from "../../../core/StashService";
|
||||
import { SceneHelpers } from "../helpers";
|
||||
import { ToastUtils } from "../../../utils/toasts";
|
||||
|
||||
interface IOperationsPanelProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
}
|
||||
|
||||
export const SceneOperationsPanel: FunctionComponent<IOperationsPanelProps> = (props: IOperationsPanelProps) => {
|
||||
|
||||
const jwplayer = SceneHelpers.getPlayer();
|
||||
const generateScreenshot = StashService.useSceneGenerateScreenshot();
|
||||
|
||||
async function onGenerateScreenshot() {
|
||||
let position = jwplayer.getPosition();
|
||||
|
||||
await generateScreenshot({
|
||||
variables: {
|
||||
id: props.scene.id,
|
||||
at: position
|
||||
}
|
||||
});
|
||||
ToastUtils.success("Generating screenshot");
|
||||
}
|
||||
|
||||
async function onGenerateDefaultScreenshot() {
|
||||
await generateScreenshot({
|
||||
variables: {
|
||||
id: props.scene.id,
|
||||
}
|
||||
});
|
||||
ToastUtils.success("Generating screenshot");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button className="edit-button" text="Generate thumbnail from current" onClick={() => onGenerateScreenshot()}/>
|
||||
<Button className="edit-button" text="Generate default thumbnail" onClick={() => onGenerateDefaultScreenshot()}/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { PerformerCard } from "../../performers/PerformerCard";
|
||||
|
||||
interface IScenePerformerPanelProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
}
|
||||
|
||||
export const ScenePerformerPanel: FunctionComponent<IScenePerformerPanelProps> = (props: IScenePerformerPanelProps) => {
|
||||
const cards = props.scene.performers.map((performer) => (
|
||||
<PerformerCard key={performer.id} performer={performer} ageFromDate={props.scene.date} />
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid">
|
||||
{cards}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,110 +0,0 @@
|
|||
import _ from "lodash";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { QueryHookResult } from "react-apollo-hooks";
|
||||
import { FindScenesQuery, FindScenesVariables, SlimSceneDataFragment } from "../../core/generated-graphql";
|
||||
import { ListHook } from "../../hooks/ListHook";
|
||||
import { IBaseProps } from "../../models/base-props";
|
||||
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
|
||||
import { WallPanel } from "../Wall/WallPanel";
|
||||
import { SceneCard } from "./SceneCard";
|
||||
import { SceneListTable } from "./SceneListTable";
|
||||
import { SceneSelectedOptions } from "./SceneSelectedOptions";
|
||||
import { StashService } from "../../core/StashService";
|
||||
|
||||
interface ISceneListProps {
|
||||
base : IBaseProps
|
||||
subComponent?: boolean
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
}
|
||||
|
||||
export const SceneList: FunctionComponent<ISceneListProps> = (props: ISceneListProps) => {
|
||||
const otherOperations = [
|
||||
{
|
||||
text: "Play Random",
|
||||
onClick: playRandom,
|
||||
}
|
||||
];
|
||||
|
||||
const listData = ListHook.useList({
|
||||
filterMode: FilterMode.Scenes,
|
||||
props: props.base,
|
||||
subComponent: props.subComponent,
|
||||
filterHook: props.filterHook,
|
||||
zoomable: true,
|
||||
otherOperations: otherOperations,
|
||||
renderContent,
|
||||
renderSelectedOptions
|
||||
});
|
||||
|
||||
async function playRandom(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel, selectedIds: Set<string>) {
|
||||
// query for a random scene
|
||||
if (result.data && result.data.findScenes) {
|
||||
let count = result.data.findScenes.count;
|
||||
|
||||
let index = Math.floor(Math.random() * count);
|
||||
let filterCopy = _.cloneDeep(filter);
|
||||
filterCopy.itemsPerPage = 1;
|
||||
filterCopy.currentPage = index + 1;
|
||||
const singleResult = await StashService.queryFindScenes(filterCopy);
|
||||
if (singleResult && singleResult.data && singleResult.data.findScenes && singleResult.data.findScenes.scenes.length === 1) {
|
||||
let id = singleResult!.data!.findScenes!.scenes[0].id;
|
||||
// navigate to the scene player page
|
||||
props.base.history.push("/scenes/" + id + "?autoplay=true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelectedOptions(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, selectedIds: Set<string>) {
|
||||
// find the selected items from the ids
|
||||
if (!result.data || !result.data.findScenes) { return undefined; }
|
||||
|
||||
var scenes = result.data.findScenes.scenes;
|
||||
|
||||
var selectedScenes : SlimSceneDataFragment[] = [];
|
||||
selectedIds.forEach((id) => {
|
||||
var scene = scenes.find((scene) => {
|
||||
return scene.id === id;
|
||||
});
|
||||
|
||||
if (scene) {
|
||||
selectedScenes.push(scene);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SceneSelectedOptions selected={selectedScenes} onScenesUpdated={() => { return; }}/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSceneCard(scene : SlimSceneDataFragment, selectedIds: Set<string>, zoomIndex: number) {
|
||||
return (
|
||||
<SceneCard
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
zoomIndex={zoomIndex}
|
||||
selected={selectedIds.has(scene.id)}
|
||||
onSelectedChanged={(selected: boolean, shiftKey: boolean) => listData.onSelectChange(scene.id, selected, shiftKey)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderContent(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel, selectedIds: Set<string>, zoomIndex: number) {
|
||||
if (!result.data || !result.data.findScenes) { return; }
|
||||
if (filter.displayMode === DisplayMode.Grid) {
|
||||
return (
|
||||
<div className="grid">
|
||||
{result.data.findScenes.scenes.map((scene) => renderSceneCard(scene, selectedIds, zoomIndex))}
|
||||
</div>
|
||||
);
|
||||
} else if (filter.displayMode === DisplayMode.List) {
|
||||
return <SceneListTable scenes={result.data.findScenes.scenes}/>;
|
||||
} else if (filter.displayMode === DisplayMode.Wall) {
|
||||
return <WallPanel scenes={result.data.findScenes.scenes} />;
|
||||
}
|
||||
}
|
||||
|
||||
return listData.template;
|
||||
};
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { IBaseProps } from "../../models/base-props";
|
||||
import { SceneList } from "./SceneList";
|
||||
|
||||
interface ISceneListPageProps extends IBaseProps {}
|
||||
|
||||
export const SceneListPage: FunctionComponent<ISceneListPageProps> = (props: ISceneListPageProps) => {
|
||||
return <SceneList base={props}/>;
|
||||
};
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import {
|
||||
HTMLTable,
|
||||
H5,
|
||||
H6,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { TextUtils } from "../../utils/text";
|
||||
import { NavigationUtils } from "../../utils/navigation";
|
||||
|
||||
interface ISceneListTableProps {
|
||||
scenes: GQL.SlimSceneDataFragment[];
|
||||
}
|
||||
|
||||
export const SceneListTable: FunctionComponent<ISceneListTableProps> = (props: ISceneListTableProps) => {
|
||||
|
||||
function renderSceneImage(scene : GQL.SlimSceneDataFragment) {
|
||||
const style: React.CSSProperties = {
|
||||
backgroundImage: `url('${scene.paths.screenshot}')`,
|
||||
lineHeight: 5,
|
||||
backgroundSize: "contain",
|
||||
display: "inline-block",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
className="scene-list-thumbnail"
|
||||
to={`/scenes/${scene.id}`}
|
||||
style={style}/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDuration(scene : GQL.SlimSceneDataFragment) {
|
||||
if (scene.file.duration === undefined) { return; }
|
||||
return TextUtils.secondsToTimestamp(scene.file.duration);
|
||||
}
|
||||
|
||||
function renderTags(tags : GQL.SlimSceneDataTags[]) {
|
||||
return tags.map((tag) => (
|
||||
<Link to={NavigationUtils.makeTagScenesUrl(tag)}>
|
||||
<H6>{tag.name}</H6>
|
||||
</Link>
|
||||
));
|
||||
}
|
||||
|
||||
function renderPerformers(performers : GQL.SlimSceneDataPerformers[]) {
|
||||
return performers.map((performer) => (
|
||||
<Link to={NavigationUtils.makePerformerScenesUrl(performer)}>
|
||||
<H6>{performer.name}</H6>
|
||||
</Link>
|
||||
));
|
||||
}
|
||||
|
||||
function renderStudio(studio : GQL.SlimSceneDataStudio | undefined) {
|
||||
if (!!studio) {
|
||||
return (
|
||||
<Link to={NavigationUtils.makeStudioScenesUrl(studio)}>
|
||||
<H6>{studio.name}</H6>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMovies(movies : GQL.SlimSceneDataMovies[]) {
|
||||
return movies.map((sceneMovie) => (
|
||||
<Link to={NavigationUtils.makeMovieScenesUrl(sceneMovie.movie)}>
|
||||
<H6>{sceneMovie.movie.name}</H6>
|
||||
</Link>
|
||||
));
|
||||
}
|
||||
|
||||
function renderSceneRow(scene : GQL.SlimSceneDataFragment) {
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td>
|
||||
{renderSceneImage(scene)}
|
||||
</td>
|
||||
<td style={{textAlign: "left"}}>
|
||||
<Link to={`/scenes/${scene.id}`}>
|
||||
<H5 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
|
||||
{!!scene.title ? scene.title : TextUtils.fileNameFromPath(scene.path)}
|
||||
</H5>
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{scene.rating ? scene.rating : ''}
|
||||
</td>
|
||||
<td>
|
||||
{renderDuration(scene)}
|
||||
</td>
|
||||
<td>
|
||||
{renderTags(scene.tags)}
|
||||
</td>
|
||||
<td>
|
||||
{renderPerformers(scene.performers)}
|
||||
</td>
|
||||
<td>
|
||||
{renderStudio(scene.studio)}
|
||||
</td>
|
||||
<td>
|
||||
{renderMovies(scene.movies)}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid">
|
||||
<HTMLTable className="bp3-html-table bp3-html-table-bordered bp3-html-table-condensed bp3-html-table-striped bp3-interactive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Title</th>
|
||||
<th>Rating</th>
|
||||
<th>Duration</th>
|
||||
<th>Tags</th>
|
||||
<th>Performers</th>
|
||||
<th>Studio</th>
|
||||
<th>Movies</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.scenes.map(renderSceneRow)}
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import _ from "lodash";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { QueryHookResult } from "react-apollo-hooks";
|
||||
import { FindSceneMarkersQuery, FindSceneMarkersVariables } from "../../core/generated-graphql";
|
||||
import { ListHook } from "../../hooks/ListHook";
|
||||
import { IBaseProps } from "../../models/base-props";
|
||||
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
|
||||
import { WallPanel } from "../Wall/WallPanel";
|
||||
import { StashService } from "../../core/StashService";
|
||||
import { NavigationUtils } from "../../utils/navigation";
|
||||
|
||||
interface IProps extends IBaseProps {}
|
||||
|
||||
export const SceneMarkerList: FunctionComponent<IProps> = (props: IProps) => {
|
||||
const otherOperations = [
|
||||
{
|
||||
text: "Play Random",
|
||||
onClick: playRandom,
|
||||
}
|
||||
];
|
||||
|
||||
const listData = ListHook.useList({
|
||||
filterMode: FilterMode.SceneMarkers,
|
||||
otherOperations: otherOperations,
|
||||
props,
|
||||
renderContent,
|
||||
});
|
||||
|
||||
async function playRandom(result: QueryHookResult<FindSceneMarkersQuery, FindSceneMarkersVariables>, filter: ListFilterModel, selectedIds: Set<string>) {
|
||||
// query for a random scene
|
||||
if (result.data && result.data.findSceneMarkers) {
|
||||
let count = result.data.findSceneMarkers.count;
|
||||
|
||||
let index = Math.floor(Math.random() * count);
|
||||
let filterCopy = _.cloneDeep(filter);
|
||||
filterCopy.itemsPerPage = 1;
|
||||
filterCopy.currentPage = index + 1;
|
||||
const singleResult = await StashService.queryFindSceneMarkers(filterCopy);
|
||||
if (singleResult && singleResult.data && singleResult.data.findSceneMarkers && singleResult.data.findSceneMarkers.scene_markers.length === 1) {
|
||||
// navigate to the scene player page
|
||||
let url = NavigationUtils.makeSceneMarkerUrl(singleResult!.data!.findSceneMarkers!.scene_markers[0])
|
||||
props.history.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderContent(
|
||||
result: QueryHookResult<FindSceneMarkersQuery, FindSceneMarkersVariables>,
|
||||
filter: ListFilterModel,
|
||||
) {
|
||||
if (!result.data || !result.data.findSceneMarkers) { return; }
|
||||
if (filter.displayMode === DisplayMode.Wall) {
|
||||
return <WallPanel sceneMarkers={result.data.findSceneMarkers.scene_markers} />;
|
||||
}
|
||||
}
|
||||
|
||||
return listData.template;
|
||||
};
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
import { Hotkey, Hotkeys, HotkeysTarget } from "@blueprintjs/core";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import ReactJWPlayer from "react-jw-player";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { SceneHelpers } from "../helpers";
|
||||
import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
|
||||
import { StashService } from "../../../core/StashService";
|
||||
|
||||
interface IScenePlayerProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
timestamp: number;
|
||||
autoplay?: boolean;
|
||||
onReady?: any;
|
||||
onSeeked?: any;
|
||||
onTime?: any;
|
||||
config?: GQL.ConfigInterfaceDataFragment;
|
||||
}
|
||||
interface IScenePlayerState {
|
||||
scrubberPosition: number;
|
||||
}
|
||||
|
||||
@HotkeysTarget
|
||||
export class ScenePlayerImpl extends React.Component<IScenePlayerProps, IScenePlayerState> {
|
||||
private player: any;
|
||||
private lastTime = 0;
|
||||
|
||||
constructor(props: IScenePlayerProps) {
|
||||
super(props);
|
||||
this.onReady = this.onReady.bind(this);
|
||||
this.onSeeked = this.onSeeked.bind(this);
|
||||
this.onTime = this.onTime.bind(this);
|
||||
|
||||
this.onScrubberSeek = this.onScrubberSeek.bind(this);
|
||||
this.onScrubberScrolled = this.onScrubberScrolled.bind(this);
|
||||
|
||||
this.state = {scrubberPosition: 0};
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IScenePlayerProps) {
|
||||
if (prevProps.timestamp !== this.props.timestamp) {
|
||||
this.player.seek(this.props.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
renderPlayer() {
|
||||
const config = this.makeJWPlayerConfig(this.props.scene);
|
||||
return (
|
||||
<ReactJWPlayer
|
||||
playerId={SceneHelpers.getJWPlayerId()}
|
||||
playerScript="/jwplayer/jwplayer.js"
|
||||
customProps={config}
|
||||
onReady={this.onReady}
|
||||
onSeeked={this.onSeeked}
|
||||
onTime={this.onTime}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<>
|
||||
<div id="jwplayer-container">
|
||||
{this.renderPlayer()}
|
||||
<ScenePlayerScrubber
|
||||
scene={this.props.scene}
|
||||
position={this.state.scrubberPosition}
|
||||
onSeek={this.onScrubberSeek}
|
||||
onScrolled={this.onScrubberScrolled}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
public renderHotkeys() {
|
||||
const onIncrease = () => {
|
||||
const currentPlaybackRate = !!this.player ? this.player.getPlaybackRate() : 1;
|
||||
this.player.setPlaybackRate(currentPlaybackRate + 0.5);
|
||||
};
|
||||
const onDecrease = () => {
|
||||
const currentPlaybackRate = !!this.player ? this.player.getPlaybackRate() : 1;
|
||||
this.player.setPlaybackRate(currentPlaybackRate - 0.5);
|
||||
};
|
||||
const onReset = () => { this.player.setPlaybackRate(1); };
|
||||
|
||||
return (
|
||||
<Hotkeys>
|
||||
<Hotkey
|
||||
global={true}
|
||||
combo="num2"
|
||||
label="Increase playback speed"
|
||||
preventDefault={true}
|
||||
onKeyDown={onIncrease}
|
||||
/>
|
||||
<Hotkey
|
||||
global={true}
|
||||
combo="num1"
|
||||
label="Decrease playback speed"
|
||||
preventDefault={true}
|
||||
onKeyDown={onDecrease}
|
||||
/>
|
||||
<Hotkey
|
||||
global={true}
|
||||
combo="num0"
|
||||
label="Reset playback speed"
|
||||
preventDefault={true}
|
||||
onKeyDown={onReset}
|
||||
/>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
private shouldRepeat(scene: GQL.SceneDataFragment) {
|
||||
let maxLoopDuration = this.props.config ? this.props.config.maximumLoopDuration : 0;
|
||||
return !!scene.file.duration && !!maxLoopDuration && scene.file.duration < maxLoopDuration;
|
||||
}
|
||||
|
||||
private makeJWPlayerConfig(scene: GQL.SceneDataFragment) {
|
||||
if (!scene.paths.stream) { return {}; }
|
||||
|
||||
let repeat = this.shouldRepeat(scene);
|
||||
let getDurationHook: (() => GQL.Maybe<number>) | undefined = undefined;
|
||||
let seekHook: ((seekToPosition: number, _videoTag: any) => void) | undefined = undefined;
|
||||
let getCurrentTimeHook: ((_videoTag: any) => number) | undefined = undefined;
|
||||
|
||||
if (!this.props.scene.is_streamable) {
|
||||
getDurationHook = () => {
|
||||
return this.props.scene.file.duration;
|
||||
};
|
||||
|
||||
seekHook = (seekToPosition: number, _videoTag: any) => {
|
||||
_videoTag.start = seekToPosition;
|
||||
_videoTag.src = (this.props.scene.paths.stream + "?start=" + seekToPosition);
|
||||
_videoTag.play();
|
||||
};
|
||||
|
||||
getCurrentTimeHook = (_videoTag: any) => {
|
||||
let start = _videoTag.start || 0;
|
||||
return _videoTag.currentTime + start;
|
||||
}
|
||||
}
|
||||
|
||||
let ret = {
|
||||
file: scene.paths.stream,
|
||||
image: scene.paths.screenshot,
|
||||
tracks: [
|
||||
{
|
||||
file: scene.paths.vtt,
|
||||
kind: "thumbnails",
|
||||
},
|
||||
{
|
||||
file: scene.paths.chapters_vtt,
|
||||
kind: "chapters",
|
||||
},
|
||||
],
|
||||
aspectratio: "16:9",
|
||||
width: "100%",
|
||||
floating: {
|
||||
dismissible: true,
|
||||
},
|
||||
cast: {},
|
||||
primary: "html5",
|
||||
autostart: this.props.autoplay || (this.props.config ? this.props.config.autostartVideo : false),
|
||||
repeat: repeat,
|
||||
playbackRateControls: true,
|
||||
playbackRates: [0.75, 1, 1.5, 2, 3, 4],
|
||||
getDurationHook: getDurationHook,
|
||||
seekHook: seekHook,
|
||||
getCurrentTimeHook: getCurrentTimeHook
|
||||
};
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
private onReady() {
|
||||
this.player = SceneHelpers.getPlayer();
|
||||
if (this.props.timestamp > 0) {
|
||||
this.player.seek(this.props.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
private onSeeked() {
|
||||
const position = this.player.getPosition();
|
||||
this.setState({scrubberPosition: position});
|
||||
this.player.play();
|
||||
}
|
||||
|
||||
private onTime(data: any) {
|
||||
const position = this.player.getPosition();
|
||||
const difference = Math.abs(position - this.lastTime);
|
||||
if (difference > 1) {
|
||||
this.lastTime = position;
|
||||
this.setState({scrubberPosition: position});
|
||||
}
|
||||
}
|
||||
|
||||
private onScrubberSeek(seconds: number) {
|
||||
this.player.seek(seconds);
|
||||
}
|
||||
|
||||
private onScrubberScrolled() {
|
||||
this.player.pause();
|
||||
}
|
||||
}
|
||||
|
||||
export const ScenePlayer: FunctionComponent<IScenePlayerProps> = (props: IScenePlayerProps) => {
|
||||
const config = StashService.useConfiguration();
|
||||
|
||||
return <ScenePlayerImpl {...props} config={config.data && config.data.configuration ? config.data.configuration.interface : undefined}/>
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
.scrubber-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
#scrubber-back {
|
||||
float: left;
|
||||
}
|
||||
|
||||
#scrubber-forward {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.scrubber-button {
|
||||
width: 1.5%;
|
||||
height: 100%;
|
||||
line-height: 120px;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
border: 1px solid #555;
|
||||
font-weight: 800;
|
||||
font-size: 20px;
|
||||
color: #FFF;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scrubber-content {
|
||||
-webkit-user-select: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
cursor: -webkit-grab;
|
||||
height: 120px;
|
||||
width: 96%;
|
||||
margin: 0 0.5%;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrubber-content.dragging {
|
||||
cursor: -webkit-grabbing;
|
||||
}
|
||||
|
||||
.scrubber-tags-background {
|
||||
background-color: #555;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
#scrubber-position-indicator {
|
||||
background-color: #CCC;
|
||||
width: 100%;
|
||||
left: -100%;
|
||||
height: 20px;
|
||||
z-index: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
#scrubber-current-position {
|
||||
background-color: #FFF;
|
||||
width: 2px;
|
||||
height: 30px;
|
||||
left: 50%;
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.scrubber-viewport {
|
||||
position: static;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrubber-slider {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
transition: 333ms ease-out;
|
||||
}
|
||||
|
||||
.scrubber-tags {
|
||||
height: 20px;
|
||||
position: relative;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.scrubber-tag {
|
||||
position: absolute;
|
||||
background-color: #000;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.scrubber-tag:hover {
|
||||
z-index: 1;
|
||||
background-color: #444;
|
||||
}
|
||||
.scrubber-tag:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
border-top: solid 5px #000;
|
||||
border-left: solid 5px transparent;
|
||||
border-right: solid 5px transparent;
|
||||
}
|
||||
|
||||
.scrubber-item {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
text-shadow: 1px 1px black;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.scrubber-item span {
|
||||
display: inline-block;
|
||||
align-self: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
import axios from "axios";
|
||||
import React, { CSSProperties, FunctionComponent, useEffect, useRef, useState } from "react";
|
||||
import * as GQL from "../../../core/generated-graphql";
|
||||
import { TextUtils } from "../../../utils/text";
|
||||
import "./ScenePlayerScrubber.scss";
|
||||
|
||||
interface IScenePlayerScrubberProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
position: number;
|
||||
onSeek: (seconds: number) => void;
|
||||
onScrolled: () => void;
|
||||
}
|
||||
|
||||
interface ISceneSpriteItem {
|
||||
start: number;
|
||||
end: number;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export const ScenePlayerScrubber: FunctionComponent<IScenePlayerScrubberProps> = (props: IScenePlayerScrubberProps) => {
|
||||
const contentEl = useRef<HTMLDivElement>(null);
|
||||
const positionIndicatorEl = useRef<HTMLDivElement>(null);
|
||||
const scrubberSliderEl = useRef<HTMLDivElement>(null);
|
||||
const mouseDown = useRef(false);
|
||||
const lastMouseEvent = useRef<any>(null);
|
||||
const startMouseEvent = useRef<any>(null);
|
||||
const velocity = useRef(0);
|
||||
|
||||
const _position = useRef(0);
|
||||
function getPostion() { return _position.current; }
|
||||
function setPosition(newPostion: number, shouldEmit: boolean = true) {
|
||||
if (!scrubberSliderEl.current || !positionIndicatorEl.current) { return; }
|
||||
if (shouldEmit) { props.onScrolled(); }
|
||||
|
||||
const midpointOffset = scrubberSliderEl.current.clientWidth / 2;
|
||||
|
||||
const bounds = getBounds() * -1;
|
||||
if (newPostion > midpointOffset) {
|
||||
_position.current = midpointOffset;
|
||||
} else if (newPostion < bounds - midpointOffset) {
|
||||
_position.current = bounds - midpointOffset;
|
||||
} else {
|
||||
_position.current = newPostion;
|
||||
}
|
||||
|
||||
scrubberSliderEl.current.style.transform = `translateX(${_position.current}px)`;
|
||||
|
||||
const indicatorPosition = (
|
||||
(newPostion - midpointOffset) / (bounds - (midpointOffset * 2)) * scrubberSliderEl.current.clientWidth
|
||||
);
|
||||
positionIndicatorEl.current.style.transform = `translateX(${indicatorPosition}px)`;
|
||||
}
|
||||
|
||||
const [spriteItems, setSpriteItems] = useState<ISceneSpriteItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrubberSliderEl.current) { return; }
|
||||
scrubberSliderEl.current.style.transform = `translateX(${scrubberSliderEl.current.clientWidth / 2}px)`;
|
||||
}, [scrubberSliderEl]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSpriteInfo() {
|
||||
if (!props.scene || !props.scene.paths.vtt) { return; }
|
||||
|
||||
const response = await axios.get<string>(props.scene.paths.vtt, {responseType: "text"});
|
||||
if (response.status !== 200) {
|
||||
console.log(response.statusText);
|
||||
}
|
||||
|
||||
// TODO: This is gnarly
|
||||
const lines = response.data.split("\n");
|
||||
if (lines.shift() !== "WEBVTT") { return; }
|
||||
if (lines.shift() !== "") { return; }
|
||||
let item: ISceneSpriteItem = {start: 0, end: 0, x: 0, y: 0, w: 0, h: 0};
|
||||
const newSpriteItems: ISceneSpriteItem[] = [];
|
||||
while (lines.length) {
|
||||
const line = lines.shift();
|
||||
if (line === undefined) { continue; }
|
||||
|
||||
if (line.includes("#") && line.includes("=") && line.includes(",")) {
|
||||
const size = line.split("#")[1].split("=")[1].split(",");
|
||||
item.x = Number(size[0]);
|
||||
item.y = Number(size[1]);
|
||||
item.w = Number(size[2]);
|
||||
item.h = Number(size[3]);
|
||||
|
||||
newSpriteItems.push(item);
|
||||
item = {start: 0, end: 0, x: 0, y: 0, w: 0, h: 0};
|
||||
} else if (line.includes(" --> ")) {
|
||||
const times = line.split(" --> ");
|
||||
|
||||
const start = times[0].split(":");
|
||||
item.start = (+start[0]) * 60 * 60 + (+start[1]) * 60 + (+start[2]);
|
||||
|
||||
const end = times[1].split(":");
|
||||
item.end = (+end[0]) * 60 * 60 + (+end[1]) * 60 + (+end[2]);
|
||||
}
|
||||
}
|
||||
|
||||
setSpriteItems(newSpriteItems);
|
||||
}
|
||||
|
||||
fetchSpriteInfo();
|
||||
}, [props.scene]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrubberSliderEl.current) { return; }
|
||||
const duration = Number(props.scene.file.duration);
|
||||
const percentage = props.position / duration;
|
||||
const position = (
|
||||
(scrubberSliderEl.current.scrollWidth * percentage) - (scrubberSliderEl.current.clientWidth / 2)
|
||||
) * -1;
|
||||
setPosition(position, false);
|
||||
}, [props.position]);
|
||||
|
||||
useEffect(() => {
|
||||
let element = contentEl.current;
|
||||
if (!element) { return; }
|
||||
element.addEventListener("mousedown", onMouseDown, false);
|
||||
element.addEventListener("mousemove", onMouseMove, false);
|
||||
window.addEventListener("mouseup", onMouseUp, false);
|
||||
|
||||
return () => {
|
||||
if (!element) { return; }
|
||||
element.removeEventListener("mousedown", onMouseDown);
|
||||
element.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
});
|
||||
|
||||
function onMouseUp(this: Window, event: MouseEvent) {
|
||||
if (!startMouseEvent.current || !scrubberSliderEl.current) { return; }
|
||||
mouseDown.current = false;
|
||||
const delta = Math.abs(event.clientX - startMouseEvent.current.clientX);
|
||||
if (delta < 1 && event.target instanceof HTMLDivElement) {
|
||||
const target: HTMLDivElement = event.target;
|
||||
let seekSeconds: number | undefined;
|
||||
|
||||
const spriteIdString = target.getAttribute("data-sprite-item-id");
|
||||
if (spriteIdString != null) {
|
||||
const spritePercentage = event.offsetX / target.clientWidth;
|
||||
const offset = target.offsetLeft + (target.clientWidth * spritePercentage);
|
||||
const percentage = offset / scrubberSliderEl.current.scrollWidth;
|
||||
seekSeconds = percentage * (props.scene.file.duration || 0);
|
||||
}
|
||||
|
||||
const markerIdString = target.getAttribute("data-marker-id");
|
||||
if (markerIdString != null) {
|
||||
const marker = props.scene.scene_markers[Number(markerIdString)];
|
||||
seekSeconds = marker.seconds;
|
||||
}
|
||||
|
||||
if (!!seekSeconds) { props.onSeek(seekSeconds); }
|
||||
} else if (Math.abs(velocity.current) > 25) {
|
||||
const newPosition = getPostion() + (velocity.current * 10);
|
||||
setPosition(newPosition);
|
||||
velocity.current = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseDown(this: HTMLDivElement, event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
mouseDown.current = true;
|
||||
lastMouseEvent.current = event;
|
||||
startMouseEvent.current = event;
|
||||
velocity.current = 0;
|
||||
}
|
||||
|
||||
function onMouseMove(this: HTMLDivElement, event: MouseEvent) {
|
||||
if (!mouseDown.current) { return; }
|
||||
|
||||
// negative dragging right (past), positive left (future)
|
||||
const delta = event.clientX - lastMouseEvent.current.clientX;
|
||||
|
||||
const movement = event.movementX;
|
||||
velocity.current = movement;
|
||||
|
||||
const newPostion = getPostion() + delta;
|
||||
setPosition(newPostion);
|
||||
lastMouseEvent.current = event;
|
||||
}
|
||||
|
||||
function getBounds(): number {
|
||||
if (!scrubberSliderEl.current || !positionIndicatorEl.current) { return 0; }
|
||||
return scrubberSliderEl.current.scrollWidth - scrubberSliderEl.current.clientWidth;
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (!scrubberSliderEl.current) { return; }
|
||||
const newPosition = getPostion() + scrubberSliderEl.current.clientWidth;
|
||||
setPosition(newPosition);
|
||||
}
|
||||
|
||||
function goForward() {
|
||||
if (!scrubberSliderEl.current) { return; }
|
||||
const newPosition = getPostion() - scrubberSliderEl.current.clientWidth;
|
||||
setPosition(newPosition);
|
||||
}
|
||||
|
||||
function renderTags() {
|
||||
function getTagStyle(i: number): CSSProperties {
|
||||
if (!scrubberSliderEl.current ||
|
||||
spriteItems.length === 0 ||
|
||||
getBounds() === 0) { return {}; }
|
||||
|
||||
const tags = window.document.getElementsByClassName("scrubber-tag");
|
||||
if (tags.length === 0) { return {}; }
|
||||
|
||||
let tag: any;
|
||||
for (let index = 0; index < tags.length; index++) {
|
||||
tag = tags.item(index) as any;
|
||||
const id = tag.getAttribute("data-marker-id");
|
||||
if (id === i.toString()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const marker = props.scene.scene_markers[i];
|
||||
const duration = Number(props.scene.file.duration);
|
||||
const percentage = marker.seconds / duration;
|
||||
|
||||
const left = (scrubberSliderEl.current.scrollWidth * percentage) - (tag.clientWidth / 2);
|
||||
return {
|
||||
left: `${left}px`,
|
||||
height: 20,
|
||||
};
|
||||
}
|
||||
|
||||
return props.scene.scene_markers.map((marker, index) => {
|
||||
const dataAttrs = {
|
||||
"data-marker-id": index,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="scrubber-tag"
|
||||
style={getTagStyle(index)}
|
||||
{...dataAttrs}
|
||||
>
|
||||
{marker.title}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSprites() {
|
||||
function getStyleForSprite(index: number): CSSProperties {
|
||||
if (!props.scene.paths.vtt) { return {}; }
|
||||
const sprite = spriteItems[index];
|
||||
const left = sprite.w * index;
|
||||
const path = props.scene.paths.vtt.replace("_thumbs.vtt", "_sprite.jpg"); // TODO: Gnarly
|
||||
return {
|
||||
width: `${sprite.w}px`,
|
||||
height: `${sprite.h}px`,
|
||||
margin: "0px auto",
|
||||
backgroundPosition: -sprite.x + "px " + -sprite.y + "px",
|
||||
backgroundImage: `url(${path})`,
|
||||
left: `${left}px`,
|
||||
};
|
||||
}
|
||||
|
||||
return spriteItems.map((spriteItem, index) => {
|
||||
const dataAttrs = {
|
||||
"data-sprite-item-id": index,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="scrubber-item"
|
||||
style={getStyleForSprite(index)}
|
||||
{...dataAttrs}
|
||||
>
|
||||
<span>{TextUtils.secondsToTimestamp(spriteItem.start)} - {TextUtils.secondsToTimestamp(spriteItem.end)}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="scrubber-wrapper">
|
||||
<button className="scrubber-button" id="scrubber-back" onClick={() => goBack()}><</button>
|
||||
<div ref={contentEl} className="scrubber-content">
|
||||
<div className="scrubber-tags-background" />
|
||||
<div ref={positionIndicatorEl} id="scrubber-position-indicator" />
|
||||
<div id="scrubber-current-position" />
|
||||
<div className="scrubber-viewport">
|
||||
<div ref={scrubberSliderEl} className="scrubber-slider">
|
||||
<div className="scrubber-tags">
|
||||
{renderTags()}
|
||||
</div>
|
||||
{renderSprites()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="scrubber-button" id="scrubber-forward" onClick={() => goForward()}>></button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,328 +0,0 @@
|
|||
import _ from "lodash";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
FormGroup,
|
||||
HTMLSelect,
|
||||
Spinner,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import { FilterSelect } from "../select/FilterSelect";
|
||||
import { StashService } from "../../core/StashService";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { ErrorUtils } from "../../utils/errors";
|
||||
import { ToastUtils } from "../../utils/toasts";
|
||||
import { FilterMultiSet } from "../select/FilterMultiSet";
|
||||
|
||||
interface IListOperationProps {
|
||||
selected: GQL.SlimSceneDataFragment[],
|
||||
onScenesUpdated: () => void;
|
||||
}
|
||||
|
||||
export const SceneSelectedOptions: FunctionComponent<IListOperationProps> = (props: IListOperationProps) => {
|
||||
const [rating, setRating] = useState<string>("");
|
||||
const [studioId, setStudioId] = useState<string | undefined>(undefined);
|
||||
const [performerMode, setPerformerMode] = React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
|
||||
const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined);
|
||||
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
|
||||
const [tagIds, setTagIds] = useState<string[] | undefined>(undefined);
|
||||
|
||||
const updateScenes = StashService.useBulkSceneUpdate(getSceneInput());
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
function makeBulkUpdateIds(ids: string[], mode: GQL.BulkUpdateIdMode) : GQL.BulkUpdateIds {
|
||||
return {
|
||||
mode,
|
||||
ids
|
||||
};
|
||||
}
|
||||
|
||||
function getSceneInput() : GQL.BulkSceneUpdateInput {
|
||||
// need to determine what we are actually setting on each scene
|
||||
var aggregateRating = getRating(props.selected);
|
||||
var aggregateStudioId = getStudioId(props.selected);
|
||||
var aggregatePerformerIds = getPerformerIds(props.selected);
|
||||
var aggregateTagIds = getTagIds(props.selected);
|
||||
|
||||
var sceneInput : GQL.BulkSceneUpdateInput = {
|
||||
ids: props.selected.map((scene) => {
|
||||
return scene.id;
|
||||
})
|
||||
};
|
||||
|
||||
// if rating is undefined
|
||||
if (rating === "") {
|
||||
// and all scenes have the same rating, then we are unsetting the rating.
|
||||
if(aggregateRating) {
|
||||
// an undefined rating is ignored in the server, so set it to 0 instead
|
||||
sceneInput.rating = 0;
|
||||
}
|
||||
// otherwise not setting the rating
|
||||
} else {
|
||||
// if rating is set, then we are setting the rating for all
|
||||
sceneInput.rating = Number.parseInt(rating);
|
||||
}
|
||||
|
||||
// if studioId is undefined
|
||||
if (studioId === undefined) {
|
||||
// and all scenes have the same studioId,
|
||||
// then unset the studioId, otherwise ignoring studioId
|
||||
if (aggregateStudioId) {
|
||||
// an undefined studio_id is ignored in the server, so set it to empty string instead
|
||||
sceneInput.studio_id = "";
|
||||
}
|
||||
} else {
|
||||
// if studioId is set, then we are setting it
|
||||
sceneInput.studio_id = studioId;
|
||||
}
|
||||
|
||||
// if performerIds are empty
|
||||
if (performerMode == GQL.BulkUpdateIdMode.Set && (!performerIds || performerIds.length === 0)) {
|
||||
// and all scenes have the same ids,
|
||||
if (aggregatePerformerIds.length > 0) {
|
||||
// then unset the performerIds, otherwise ignore
|
||||
sceneInput.performer_ids = makeBulkUpdateIds(performerIds || [], performerMode);
|
||||
}
|
||||
} else {
|
||||
// if performerIds non-empty, then we are setting them
|
||||
sceneInput.performer_ids = makeBulkUpdateIds(performerIds || [], performerMode);
|
||||
}
|
||||
|
||||
// if tagIds non-empty, then we are setting them
|
||||
if (tagMode == GQL.BulkUpdateIdMode.Set && (!tagIds || tagIds.length === 0)) {
|
||||
// and all scenes have the same ids,
|
||||
if (aggregateTagIds.length > 0) {
|
||||
// then unset the tagIds, otherwise ignore
|
||||
sceneInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
|
||||
}
|
||||
} else {
|
||||
// if tagIds non-empty, then we are setting them
|
||||
sceneInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
|
||||
}
|
||||
|
||||
return sceneInput;
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await updateScenes();
|
||||
ToastUtils.success("Updated scenes");
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
props.onScenesUpdated();
|
||||
}
|
||||
|
||||
function getRating(state: GQL.SlimSceneDataFragment[]) {
|
||||
var ret : number | undefined;
|
||||
var first = true;
|
||||
|
||||
state.forEach((scene : GQL.SlimSceneDataFragment) => {
|
||||
if (first) {
|
||||
ret = scene.rating;
|
||||
first = false;
|
||||
} else {
|
||||
if (ret !== scene.rating) {
|
||||
ret = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function getStudioId(state: GQL.SlimSceneDataFragment[]) {
|
||||
var ret : string | undefined;
|
||||
var first = true;
|
||||
|
||||
state.forEach((scene : GQL.SlimSceneDataFragment) => {
|
||||
if (first) {
|
||||
ret = scene.studio ? scene.studio.id : undefined;
|
||||
first = false;
|
||||
} else {
|
||||
var studioId = scene.studio ? scene.studio.id : undefined;
|
||||
if (ret !== studioId) {
|
||||
ret = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function toId(object : any) {
|
||||
return object.id;
|
||||
}
|
||||
|
||||
function getPerformerIds(state: GQL.SlimSceneDataFragment[]) {
|
||||
var ret : string[] = [];
|
||||
var first = true;
|
||||
|
||||
state.forEach((scene : GQL.SlimSceneDataFragment) => {
|
||||
if (first) {
|
||||
ret = !!scene.performers ? scene.performers.map(toId).sort() : [];
|
||||
first = false;
|
||||
} else {
|
||||
const perfIds = !!scene.performers ? scene.performers.map(toId).sort() : [];
|
||||
|
||||
if (!_.isEqual(ret, perfIds)) {
|
||||
ret = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function getTagIds(state: GQL.SlimSceneDataFragment[]) {
|
||||
var ret : string[] = [];
|
||||
var first = true;
|
||||
|
||||
state.forEach((scene : GQL.SlimSceneDataFragment) => {
|
||||
if (first) {
|
||||
ret = !!scene.tags ? scene.tags.map(toId).sort() : [];
|
||||
first = false;
|
||||
} else {
|
||||
const tIds = !!scene.tags ? scene.tags.map(toId).sort() : [];
|
||||
|
||||
if (!_.isEqual(ret, tIds)) {
|
||||
ret = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function updateScenesEditState(state: GQL.SlimSceneDataFragment[]) {
|
||||
function toId(object : any) {
|
||||
return object.id;
|
||||
}
|
||||
|
||||
var rating : string = "";
|
||||
var studioId : string | undefined;
|
||||
var performerIds : string[] = [];
|
||||
var tagIds : string[] = [];
|
||||
var first = true;
|
||||
|
||||
state.forEach((scene : GQL.SlimSceneDataFragment) => {
|
||||
var thisRating = scene.rating ? scene.rating.toString() : "";
|
||||
var thisStudio = scene.studio ? scene.studio.id : undefined;
|
||||
|
||||
if (first) {
|
||||
rating = thisRating;
|
||||
studioId = thisStudio;
|
||||
performerIds = !!scene.performers ? scene.performers.map(toId).sort() : [];
|
||||
tagIds = !!scene.tags ? scene.tags.map(toId).sort() : [];
|
||||
first = false;
|
||||
} else {
|
||||
if (rating !== thisRating) {
|
||||
rating = "";
|
||||
}
|
||||
if (studioId !== thisStudio) {
|
||||
studioId = undefined;
|
||||
}
|
||||
const perfIds = !!scene.performers ? scene.performers.map(toId).sort() : [];
|
||||
const tIds = !!scene.tags ? scene.tags.map(toId).sort() : [];
|
||||
|
||||
if (!_.isEqual(performerIds, perfIds)) {
|
||||
performerIds = [];
|
||||
}
|
||||
|
||||
if (!_.isEqual(tagIds, tIds)) {
|
||||
tagIds = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setRating(rating);
|
||||
setStudioId(studioId);
|
||||
if (performerMode == GQL.BulkUpdateIdMode.Set) {
|
||||
setPerformerIds(performerIds);
|
||||
}
|
||||
|
||||
if (tagMode == GQL.BulkUpdateIdMode.Set) {
|
||||
setTagIds(tagIds);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateScenesEditState(props.selected);
|
||||
}, [props.selected, performerMode, tagMode]);
|
||||
|
||||
function renderMultiSelect(type: "performers" | "tags", initialIds: string[] | undefined) {
|
||||
let mode = GQL.BulkUpdateIdMode.Add;
|
||||
switch (type) {
|
||||
case "performers": mode = performerMode; break;
|
||||
case "tags": mode = tagMode; break;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMultiSet
|
||||
type={type}
|
||||
onUpdate={(items) => {
|
||||
const ids = items.map((i) => i.id);
|
||||
switch (type) {
|
||||
case "performers": setPerformerIds(ids); break;
|
||||
case "tags": setTagIds(ids); break;
|
||||
}
|
||||
}}
|
||||
onSetMode={(mode) => {
|
||||
switch (type) {
|
||||
case "performers": setPerformerMode(mode); break;
|
||||
case "tags": setTagMode(mode); break;
|
||||
}
|
||||
}}
|
||||
initialIds={initialIds}
|
||||
mode={mode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
return (
|
||||
<>
|
||||
{isLoading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
||||
<div className="operation-container">
|
||||
<FormGroup className="operation-item" label="Rating">
|
||||
<HTMLSelect
|
||||
options={["", 1, 2, 3, 4, 5]}
|
||||
onChange={(event) => setRating(event.target.value)}
|
||||
value={rating}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup className="operation-item" label="Studio">
|
||||
<FilterSelect
|
||||
type="studios"
|
||||
onSelectItem={(item : any) => setStudioId(item ? item.id : undefined)}
|
||||
initialId={studioId}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup className="operation-item" label="Performers">
|
||||
{renderMultiSelect("performers", performerIds)}
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup className="operation-item" label="Tags">
|
||||
{renderMultiSelect("tags", tagIds)}
|
||||
</FormGroup>
|
||||
|
||||
<ButtonGroup className="operation-item">
|
||||
<Button
|
||||
intent="primary"
|
||||
onClick={() => onSave()}>
|
||||
Apply
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return render();
|
||||
};
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import {
|
||||
Divider,
|
||||
} from "@blueprintjs/core";
|
||||
import React, { } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import videojs from "video.js";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
|
||||
export class SceneHelpers {
|
||||
private static videoJSPlayer: videojs.Player | null;
|
||||
|
||||
public static maybeRenderStudio(
|
||||
scene: GQL.SceneDataFragment | GQL.SlimSceneDataFragment,
|
||||
height: number,
|
||||
showDivider: boolean,
|
||||
) {
|
||||
if (!scene.studio) { return; }
|
||||
const style: React.CSSProperties = {
|
||||
backgroundImage: `url('${scene.studio.image_path}')`,
|
||||
width: "100%",
|
||||
height: `${height}px`,
|
||||
lineHeight: 5,
|
||||
backgroundSize: "contain",
|
||||
display: "inline-block",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{showDivider ? <Divider /> : undefined}
|
||||
<Link
|
||||
to={`/studios/${scene.studio.id}`}
|
||||
style={style}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
public static getJWPlayerId(): string { return "main-jwplayer"; }
|
||||
public static getPlayer(): any {
|
||||
return (window as any).jwplayer("main-jwplayer");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import React from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { Scene } from "./SceneDetails/Scene";
|
||||
import { SceneMarkerList } from "./SceneMarkerList";
|
||||
import { SceneListPage } from "./SceneListPage";
|
||||
|
||||
const Scenes = () => (
|
||||
<Switch>
|
||||
<Route exact={true} path="/scenes" component={SceneListPage} />
|
||||
<Route exact={true} path="/scenes/markers" component={SceneMarkerList} />
|
||||
<Route path="/scenes/:id" component={Scene} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
export default Scenes;
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { MenuItem } from "@blueprintjs/core";
|
||||
import { IMultiSelectProps, ItemPredicate, ItemRenderer, MultiSelect } from "@blueprintjs/select";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { StashService } from "../../core/StashService";
|
||||
import { HTMLInputProps } from "../../models";
|
||||
import { ErrorUtils } from "../../utils/errors";
|
||||
import { ToastUtils } from "../../utils/toasts";
|
||||
|
||||
const InternalPerformerMultiSelect = MultiSelect.ofType<GQL.AllPerformersForFilterAllPerformers>();
|
||||
const InternalTagMultiSelect = MultiSelect.ofType<GQL.AllTagsForFilterAllTags>();
|
||||
const InternalStudioMultiSelect = MultiSelect.ofType<GQL.AllStudiosForFilterAllStudios>();
|
||||
const InternalMovieMultiSelect = MultiSelect.ofType<GQL.AllMoviesForFilterAllMovies>();
|
||||
|
||||
type ValidTypes =
|
||||
GQL.AllPerformersForFilterAllPerformers |
|
||||
GQL.AllTagsForFilterAllTags |
|
||||
GQL.AllMoviesForFilterAllMovies |
|
||||
GQL.AllStudiosForFilterAllStudios;
|
||||
|
||||
interface IProps extends HTMLInputProps, Partial<IMultiSelectProps<ValidTypes>> {
|
||||
type: "performers" | "studios" | "movies" | "tags";
|
||||
initialIds?: string[];
|
||||
onUpdate: (items: ValidTypes[]) => void;
|
||||
}
|
||||
|
||||
export const FilterMultiSelect: React.FunctionComponent<IProps> = (props: IProps) => {
|
||||
let MultiSelectImpl = getMultiSelectImpl();
|
||||
let InternalMultiSelect = MultiSelectImpl.getInternalMultiSelect();
|
||||
const data = MultiSelectImpl.getData();
|
||||
|
||||
const [selectedItems, setSelectedItems] = React.useState<ValidTypes[]>([]);
|
||||
const [items, setItems] = React.useState<ValidTypes[]>([]);
|
||||
const [newTagName, setNewTagName] = React.useState<string>("");
|
||||
const createTag = StashService.useTagCreate(getTagInput() as GQL.TagCreateInput);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!!data) {
|
||||
MultiSelectImpl.translateData();
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
function getTagInput() {
|
||||
const tagInput: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { name: newTagName };
|
||||
return tagInput;
|
||||
}
|
||||
|
||||
async function onCreateNewObject(item: ValidTypes) {
|
||||
var created : any;
|
||||
if (props.type === "tags") {
|
||||
try {
|
||||
created = await createTag();
|
||||
|
||||
items.push(created.data.tagCreate);
|
||||
setItems(items.slice());
|
||||
addSelectedItem(created.data.tagCreate);
|
||||
|
||||
ToastUtils.success("Created tag");
|
||||
} catch (e) {
|
||||
ErrorUtils.handle(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createNewTag(query : string) {
|
||||
setNewTagName(query);
|
||||
return {
|
||||
name : query
|
||||
};
|
||||
}
|
||||
|
||||
function createNewRenderer(query: string, active: boolean, handleClick: React.MouseEventHandler<HTMLElement>) {
|
||||
// if tag already exists with that name, then don't return anything
|
||||
if (items.find((item) => {
|
||||
return item.name === query;
|
||||
})) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
icon="add"
|
||||
text={`Create "${query}"`}
|
||||
active={active}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!!props.initialIds && !!items) {
|
||||
const initialItems = items.filter((item) => props.initialIds!.includes(item.id));
|
||||
setSelectedItems(initialItems);
|
||||
}
|
||||
}, [props.initialIds, items]);
|
||||
|
||||
function getMultiSelectImpl() {
|
||||
let getInternalMultiSelect: () => new (props: IMultiSelectProps<any>) => MultiSelect<any>;
|
||||
let getData: () => GQL.AllPerformersForFilterQuery | GQL.AllStudiosForFilterQuery | GQL.AllMoviesForFilterQuery | GQL.AllTagsForFilterQuery | undefined;
|
||||
let translateData: () => void;
|
||||
let createNewObject: ((query : string) => void) | undefined = undefined;
|
||||
|
||||
switch (props.type) {
|
||||
case "performers": {
|
||||
getInternalMultiSelect = () => { return InternalPerformerMultiSelect; };
|
||||
getData = () => { const { data } = StashService.useAllPerformersForFilter(); return data; }
|
||||
translateData = () => { let perfData = data as GQL.AllPerformersForFilterQuery; setItems(!!perfData && !!perfData.allPerformers ? perfData.allPerformers : []); };
|
||||
break;
|
||||
}
|
||||
case "studios": {
|
||||
getInternalMultiSelect = () => { return InternalStudioMultiSelect; };
|
||||
getData = () => { const { data } = StashService.useAllStudiosForFilter(); return data; }
|
||||
translateData = () => { let studioData = data as GQL.AllStudiosForFilterQuery; setItems(!!studioData && !!studioData.allStudios ? studioData.allStudios : []); };
|
||||
break;
|
||||
}
|
||||
case "movies": {
|
||||
getInternalMultiSelect = () => { return InternalMovieMultiSelect; };
|
||||
getData = () => { const { data } = StashService.useAllMoviesForFilter(); return data; }
|
||||
translateData = () => { let moviData = data as GQL.AllMoviesForFilterQuery; setItems(!!moviData && !!moviData.allMovies ? moviData.allMovies : []); };
|
||||
break;
|
||||
}
|
||||
|
||||
case "tags": {
|
||||
getInternalMultiSelect = () => { return InternalTagMultiSelect; };
|
||||
getData = () => { const { data } = StashService.useAllTagsForFilter(); return data; }
|
||||
translateData = () => { let tagData = data as GQL.AllTagsForFilterQuery; setItems(!!tagData && !!tagData.allTags ? tagData.allTags : []); };
|
||||
createNewObject = createNewTag;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error("Unhandled case in FilterMultiSelect");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getInternalMultiSelect: getInternalMultiSelect,
|
||||
getData: getData,
|
||||
translateData: translateData,
|
||||
createNewObject: createNewObject
|
||||
};
|
||||
}
|
||||
|
||||
const renderItem: ItemRenderer<ValidTypes> = (item, itemProps) => {
|
||||
if (!itemProps.modifiers.matchesPredicate) { return null; }
|
||||
return (
|
||||
<MenuItem
|
||||
active={itemProps.modifiers.active}
|
||||
disabled={itemProps.modifiers.disabled}
|
||||
key={item.id}
|
||||
onClick={itemProps.handleClick}
|
||||
text={item.name}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const filter: ItemPredicate<ValidTypes> = (query, item) => {
|
||||
if (selectedItems.includes(item)) { return false; }
|
||||
return item.name!.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||
};
|
||||
|
||||
function addSelectedItem(item: ValidTypes) {
|
||||
selectedItems.push(item);
|
||||
setSelectedItems(selectedItems);
|
||||
props.onUpdate(selectedItems);
|
||||
}
|
||||
|
||||
function onItemSelect(item: ValidTypes) {
|
||||
if (item.id === undefined) {
|
||||
// create the new item, if applicable
|
||||
onCreateNewObject(item);
|
||||
} else {
|
||||
addSelectedItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
function onItemRemove(value: string, index: number) {
|
||||
const newSelectedItems = selectedItems.filter((_, i) => i !== index);
|
||||
setSelectedItems(newSelectedItems);
|
||||
props.onUpdate(newSelectedItems);
|
||||
}
|
||||
|
||||
return (
|
||||
<InternalMultiSelect
|
||||
items={items}
|
||||
selectedItems={selectedItems}
|
||||
itemRenderer={renderItem}
|
||||
itemPredicate={filter}
|
||||
tagRenderer={(tag) => tag.name}
|
||||
tagInputProps={{ onRemove: onItemRemove }}
|
||||
onItemSelect={onItemSelect}
|
||||
resetOnSelect={true}
|
||||
popoverProps={{position: "bottom"}}
|
||||
createNewItemFromQuery={MultiSelectImpl.createNewObject}
|
||||
createNewItemRenderer={createNewRenderer}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { ControlGroup, Button } from "@blueprintjs/core";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { FilterMultiSelect } from "./FilterMultiSelect";
|
||||
|
||||
type ValidTypes =
|
||||
GQL.AllPerformersForFilterAllPerformers |
|
||||
GQL.AllTagsForFilterAllTags |
|
||||
GQL.AllMoviesForFilterAllMovies |
|
||||
GQL.AllStudiosForFilterAllStudios;
|
||||
|
||||
interface IFilterMultiSetProps {
|
||||
type: "performers" | "studios" | "movies" | "tags";
|
||||
initialIds?: string[];
|
||||
mode: GQL.BulkUpdateIdMode;
|
||||
onUpdate: (items: ValidTypes[]) => void;
|
||||
onSetMode: (mode: GQL.BulkUpdateIdMode) => void;
|
||||
}
|
||||
|
||||
export const FilterMultiSet: React.FunctionComponent<IFilterMultiSetProps> = (props: IFilterMultiSetProps) => {
|
||||
function onUpdate(items: ValidTypes[]) {
|
||||
props.onUpdate(items);
|
||||
}
|
||||
|
||||
function getModeIcon() {
|
||||
switch(props.mode) {
|
||||
case GQL.BulkUpdateIdMode.Set:
|
||||
return "edit";
|
||||
case GQL.BulkUpdateIdMode.Add:
|
||||
return "plus";
|
||||
case GQL.BulkUpdateIdMode.Remove:
|
||||
return "cross";
|
||||
}
|
||||
}
|
||||
|
||||
function getModeText() {
|
||||
switch(props.mode) {
|
||||
case GQL.BulkUpdateIdMode.Set:
|
||||
return "Set";
|
||||
case GQL.BulkUpdateIdMode.Add:
|
||||
return "Add";
|
||||
case GQL.BulkUpdateIdMode.Remove:
|
||||
return "Remove";
|
||||
}
|
||||
}
|
||||
|
||||
function nextMode() {
|
||||
switch(props.mode) {
|
||||
case GQL.BulkUpdateIdMode.Set:
|
||||
return GQL.BulkUpdateIdMode.Add;
|
||||
case GQL.BulkUpdateIdMode.Add:
|
||||
return GQL.BulkUpdateIdMode.Remove;
|
||||
case GQL.BulkUpdateIdMode.Remove:
|
||||
return GQL.BulkUpdateIdMode.Set;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlGroup>
|
||||
<Button
|
||||
icon={getModeIcon()}
|
||||
minimal={true}
|
||||
onClick={() => props.onSetMode(nextMode())}
|
||||
title={getModeText()}
|
||||
/>
|
||||
<FilterMultiSelect
|
||||
type={props.type}
|
||||
initialIds={props.initialIds}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</ControlGroup>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { Button, MenuItem } from "@blueprintjs/core";
|
||||
import { ISelectProps, ItemPredicate, ItemRenderer, Select } from "@blueprintjs/select";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { StashService } from "../../core/StashService";
|
||||
import { HTMLInputProps } from "../../models";
|
||||
|
||||
const InternalPerformerSelect = Select.ofType<GQL.AllPerformersForFilterAllPerformers>();
|
||||
const InternalTagSelect = Select.ofType<GQL.AllTagsForFilterAllTags>();
|
||||
const InternalStudioSelect = Select.ofType<GQL.AllStudiosForFilterAllStudios>();
|
||||
const InternalMovieSelect = Select.ofType<GQL.AllMoviesForFilterAllMovies>();
|
||||
|
||||
type ValidTypes =
|
||||
GQL.AllPerformersForFilterAllPerformers |
|
||||
GQL.AllTagsForFilterAllTags |
|
||||
GQL.AllStudiosForFilterAllStudios |
|
||||
GQL.AllMoviesForFilterAllMovies;
|
||||
|
||||
interface IProps extends HTMLInputProps {
|
||||
type: "performers" | "studios" | "movies" | "tags";
|
||||
initialId?: string;
|
||||
noSelectionString?: string;
|
||||
onSelectItem: (item: ValidTypes | undefined) => void;
|
||||
}
|
||||
|
||||
function addNoneOption(items: ValidTypes[]) {
|
||||
// Add a none option to clear the gallery
|
||||
if (!items.find((item) => item.id === "0")) { items.unshift({id: "0", name: "None"}); }
|
||||
}
|
||||
|
||||
export const FilterSelect: React.FunctionComponent<IProps> = (props: IProps) => {
|
||||
let items: ValidTypes[];
|
||||
let InternalSelect: new (props: ISelectProps<any>) => Select<any>;
|
||||
switch (props.type) {
|
||||
case "performers": {
|
||||
const { data } = StashService.useAllPerformersForFilter();
|
||||
items = !!data && !!data.allPerformers ? data.allPerformers : [];
|
||||
addNoneOption(items);
|
||||
InternalSelect = InternalPerformerSelect;
|
||||
break;
|
||||
}
|
||||
case "studios": {
|
||||
const { data } = StashService.useAllStudiosForFilter();
|
||||
items = !!data && !!data.allStudios ? data.allStudios : [];
|
||||
addNoneOption(items);
|
||||
InternalSelect = InternalStudioSelect;
|
||||
break;
|
||||
}
|
||||
case "movies": {
|
||||
const { data } = StashService.useAllMoviesForFilter();
|
||||
items = !!data && !!data.allMovies ? data.allMovies : [];
|
||||
addNoneOption(items);
|
||||
InternalSelect = InternalMovieSelect;
|
||||
break;
|
||||
}
|
||||
|
||||
case "tags": {
|
||||
const { data } = StashService.useAllTagsForFilter();
|
||||
items = !!data && !!data.allTags ? data.allTags : [];
|
||||
InternalSelect = InternalTagSelect;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.error("Unhandled case in FilterSelect");
|
||||
return <>Unhandled case in FilterSelect</>;
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
const [selectedItem, setSelectedItem] = React.useState<ValidTypes | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!!items) {
|
||||
const initialItem = items.find((item) => props.initialId === item.id);
|
||||
if (!!initialItem) {
|
||||
setSelectedItem(initialItem);
|
||||
} else {
|
||||
setSelectedItem(undefined);
|
||||
}
|
||||
}
|
||||
}, [props.initialId, items]);
|
||||
/* eslint-enable */
|
||||
|
||||
const renderItem: ItemRenderer<ValidTypes> = (item, itemProps) => {
|
||||
if (!itemProps.modifiers.matchesPredicate) { return null; }
|
||||
return (
|
||||
<MenuItem
|
||||
active={itemProps.modifiers.active}
|
||||
disabled={itemProps.modifiers.disabled}
|
||||
key={item.id}
|
||||
onClick={itemProps.handleClick}
|
||||
text={item.name}
|
||||
shouldDismissPopover={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const filter: ItemPredicate<ValidTypes> = (query, item) => {
|
||||
return item.name!.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||
};
|
||||
|
||||
function onItemSelect(item: ValidTypes | undefined) {
|
||||
if (item && item.id === "0") {
|
||||
item = undefined;
|
||||
}
|
||||
|
||||
props.onSelectItem(item);
|
||||
setSelectedItem(item);
|
||||
}
|
||||
|
||||
const noSelection = props.noSelectionString !== undefined ? props.noSelectionString : "(No selection)"
|
||||
const buttonText = selectedItem ? selectedItem.name : noSelection;
|
||||
return (
|
||||
<InternalSelect
|
||||
items={items}
|
||||
itemRenderer={renderItem}
|
||||
itemPredicate={filter}
|
||||
noResults={<MenuItem disabled={true} text="No results." />}
|
||||
onItemSelect={onItemSelect}
|
||||
popoverProps={{position: "bottom"}}
|
||||
{...props}
|
||||
>
|
||||
<Button fill={true} text={buttonText} />
|
||||
</InternalSelect>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { MenuItem } from "@blueprintjs/core";
|
||||
import { ItemPredicate, ItemRenderer, Suggest } from "@blueprintjs/select";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { StashService } from "../../core/StashService";
|
||||
import { HTMLInputProps } from "../../models";
|
||||
|
||||
const InternalSuggest = Suggest.ofType<GQL.MarkerStringsMarkerStrings>();
|
||||
|
||||
interface IProps extends HTMLInputProps {
|
||||
initialMarkerString?: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
}
|
||||
|
||||
export const MarkerTitleSuggest: React.FunctionComponent<IProps> = (props: IProps) => {
|
||||
const { data } = StashService.useMarkerStrings();
|
||||
const markerStrings = !!data && !!data.markerStrings ? data.markerStrings : [];
|
||||
const [selectedItem, setSelectedItem] = React.useState<GQL.MarkerStringsMarkerStrings | null>(null);
|
||||
|
||||
if (!!props.initialMarkerString && !selectedItem) {
|
||||
const initialItem = markerStrings.find((item) => {
|
||||
return props.initialMarkerString!.toLowerCase() === item!.title.toLowerCase();
|
||||
});
|
||||
if (!!initialItem) { setSelectedItem(initialItem); }
|
||||
}
|
||||
|
||||
const renderInputValue = (markerString: GQL.MarkerStringsMarkerStrings) => markerString.title;
|
||||
|
||||
const renderItem: ItemRenderer<GQL.MarkerStringsMarkerStrings> = (markerString, itemProps) => {
|
||||
if (!itemProps.modifiers.matchesPredicate) { return null; }
|
||||
return (
|
||||
<MenuItem
|
||||
active={itemProps.modifiers.active}
|
||||
disabled={itemProps.modifiers.disabled}
|
||||
label={markerString.count.toString()}
|
||||
key={markerString.id}
|
||||
onClick={itemProps.handleClick}
|
||||
text={markerString.title}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const filter: ItemPredicate<GQL.MarkerStringsMarkerStrings> = (query, item) => {
|
||||
return item.title.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<InternalSuggest
|
||||
inputValueRenderer={renderInputValue}
|
||||
items={markerStrings as any}
|
||||
itemRenderer={renderItem}
|
||||
itemPredicate={filter}
|
||||
onItemSelect={(item) => { props.onQueryChange(item.title); setSelectedItem(item); }}
|
||||
onQueryChange={(query) => { props.onQueryChange(query); setSelectedItem(null); }}
|
||||
activeItem={null}
|
||||
selectedItem={selectedItem}
|
||||
popoverProps={{position: "bottom"}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { MenuItem } from "@blueprintjs/core";
|
||||
import { ItemRenderer, Suggest } from "@blueprintjs/select";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { StashService } from "../../core/StashService";
|
||||
import { HTMLInputProps } from "../../models";
|
||||
|
||||
const InternalSuggest = Suggest.ofType<GQL.ScrapePerformerListScrapePerformerList>();
|
||||
|
||||
interface IProps extends HTMLInputProps {
|
||||
scraperId: string;
|
||||
onSelectPerformer: (query: GQL.ScrapePerformerListScrapePerformerList) => void;
|
||||
}
|
||||
|
||||
export const ScrapePerformerSuggest: React.FunctionComponent<IProps> = (props: IProps) => {
|
||||
const [query, setQuery] = React.useState<string>("");
|
||||
const [selectedItem, setSelectedItem] = React.useState<GQL.ScrapePerformerListScrapePerformerList | undefined>();
|
||||
const [debouncedQuery, setDebouncedQuery] = React.useState<string>("");
|
||||
const { data, error, loading } = StashService.useScrapePerformerList(props.scraperId, debouncedQuery);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [query])
|
||||
|
||||
const performerNames = !!data && !!data.scrapePerformerList ? data.scrapePerformerList : [];
|
||||
|
||||
const renderInputValue = (performer: GQL.ScrapePerformerListScrapePerformerList) => performer.name || "";
|
||||
|
||||
const renderItem: ItemRenderer<GQL.ScrapePerformerListScrapePerformerList> = (performer, itemProps) => {
|
||||
if (!itemProps.modifiers.matchesPredicate) { return null; }
|
||||
return (
|
||||
<MenuItem
|
||||
active={itemProps.modifiers.active}
|
||||
disabled={itemProps.modifiers.disabled}
|
||||
key={performer.name}
|
||||
onClick={itemProps.handleClick}
|
||||
text={performer.name}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function renderLoadingError() {
|
||||
if (error) {
|
||||
return (<MenuItem disabled={true} text={error.toString()} />);
|
||||
}
|
||||
if (loading) {
|
||||
return (<MenuItem disabled={true} text="Loading..." />);
|
||||
}
|
||||
if (debouncedQuery && data && !!data.scrapePerformerList && data.scrapePerformerList.length === 0) {
|
||||
return (<MenuItem disabled={true} text="No results" />);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<InternalSuggest
|
||||
inputValueRenderer={renderInputValue}
|
||||
items={performerNames}
|
||||
itemRenderer={renderItem}
|
||||
onItemSelect={(item) => { props.onSelectPerformer(item); setSelectedItem(item); }}
|
||||
onQueryChange={(newQuery) => { setQuery(newQuery); }}
|
||||
activeItem={null}
|
||||
selectedItem={selectedItem}
|
||||
noResults={renderLoadingError()}
|
||||
popoverProps={{position: "bottom"}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { Button, MenuItem } from "@blueprintjs/core";
|
||||
import { ItemPredicate, ItemRenderer, Select } from "@blueprintjs/select";
|
||||
import * as GQL from "../../core/generated-graphql";
|
||||
import { StashService } from "../../core/StashService";
|
||||
import { HTMLInputProps } from "../../models";
|
||||
|
||||
const InternalSelect = Select.ofType<GQL.ValidGalleriesForSceneValidGalleriesForScene>();
|
||||
|
||||
interface IProps extends HTMLInputProps {
|
||||
initialId?: string;
|
||||
sceneId: string;
|
||||
onSelectItem: (item: GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined) => void;
|
||||
}
|
||||
|
||||
export const ValidGalleriesSelect: React.FunctionComponent<IProps> = (props: IProps) => {
|
||||
const { data } = StashService.useValidGalleriesForScene(props.sceneId);
|
||||
const items = !!data && !!data.validGalleriesForScene ? data.validGalleriesForScene : [];
|
||||
// Add a none option to clear the gallery
|
||||
if (!items.find((item) => item.id === "0")) { items.unshift({id: "0", path: "None"}); }
|
||||
|
||||
const [selectedItem, setSelectedItem] = React.useState<GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined>(undefined);
|
||||
const [isInitialized, setIsInitialized] = React.useState<boolean>(false);
|
||||
|
||||
if (!!props.initialId && !selectedItem && !isInitialized) {
|
||||
const initialItem = items.find((item) => props.initialId === item.id);
|
||||
if (!!initialItem) {
|
||||
setSelectedItem(initialItem);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}
|
||||
|
||||
const renderItem: ItemRenderer<GQL.ValidGalleriesForSceneValidGalleriesForScene> = (item, itemProps) => {
|
||||
if (!itemProps.modifiers.matchesPredicate) { return null; }
|
||||
return (
|
||||
<MenuItem
|
||||
active={itemProps.modifiers.active}
|
||||
disabled={itemProps.modifiers.disabled}
|
||||
key={item.id}
|
||||
onClick={itemProps.handleClick}
|
||||
text={item.path}
|
||||
shouldDismissPopover={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const filter: ItemPredicate<GQL.ValidGalleriesForSceneValidGalleriesForScene> = (query, item) => {
|
||||
return item.path!.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||
};
|
||||
|
||||
function onItemSelect(item: GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined) {
|
||||
if (item && item.id === "0") {
|
||||
item = undefined;
|
||||
}
|
||||
|
||||
props.onSelectItem(item);
|
||||
setSelectedItem(item);
|
||||
}
|
||||
|
||||
const buttonText = selectedItem ? selectedItem.path : "(No selection)";
|
||||
return (
|
||||
<InternalSelect
|
||||
items={items}
|
||||
itemRenderer={renderItem}
|
||||
itemPredicate={filter}
|
||||
noResults={<MenuItem disabled={true} text="No results." />}
|
||||
onItemSelect={onItemSelect}
|
||||
popoverProps={{position: "bottom"}}
|
||||
{...props}
|
||||
>
|
||||
<Button fill={true} text={buttonText} />
|
||||
</InternalSelect>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,631 +0,0 @@
|
|||
import ApolloClient from "apollo-client";
|
||||
import { WebSocketLink } from 'apollo-link-ws';
|
||||
import { InMemoryCache } from 'apollo-cache-inmemory';
|
||||
import { HttpLink, split } from "apollo-boost";
|
||||
import _ from "lodash";
|
||||
import { ListFilterModel } from "../models/list-filter/filter";
|
||||
import * as GQL from "./generated-graphql";
|
||||
import { getMainDefinition } from "apollo-utilities";
|
||||
|
||||
export class StashService {
|
||||
public static client: ApolloClient<any>;
|
||||
private static cache: InMemoryCache;
|
||||
|
||||
public static initialize() {
|
||||
const platformUrl = new URL(window.location.origin);
|
||||
const wsPlatformUrl = new URL(window.location.origin);
|
||||
wsPlatformUrl.protocol = "ws:";
|
||||
|
||||
if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") {
|
||||
platformUrl.port = "9999"; // TODO: Hack. Development expects port 9999
|
||||
wsPlatformUrl.port = "9999";
|
||||
|
||||
if (process.env.REACT_APP_HTTPS === "true") {
|
||||
platformUrl.protocol = "https:";
|
||||
}
|
||||
}
|
||||
|
||||
if (platformUrl.protocol === "https:") {
|
||||
wsPlatformUrl.protocol = "wss:";
|
||||
}
|
||||
const url = platformUrl.toString().slice(0, -1) + "/graphql";
|
||||
const wsUrl = wsPlatformUrl.toString().slice(0, -1) + "/graphql";
|
||||
|
||||
const httpLink = new HttpLink({
|
||||
uri: url,
|
||||
});
|
||||
|
||||
const wsLink = new WebSocketLink({
|
||||
uri: wsUrl,
|
||||
options: {
|
||||
reconnect: true
|
||||
},
|
||||
});
|
||||
|
||||
const link = split(
|
||||
({ query }) => {
|
||||
const { kind, operation } = getMainDefinition(query);
|
||||
return kind === 'OperationDefinition' && operation === 'subscription';
|
||||
},
|
||||
wsLink,
|
||||
httpLink,
|
||||
);
|
||||
|
||||
StashService.cache = new InMemoryCache();
|
||||
StashService.client = new ApolloClient({
|
||||
link: link,
|
||||
cache: StashService.cache
|
||||
});
|
||||
|
||||
(window as any).StashService = StashService;
|
||||
return StashService.client;
|
||||
}
|
||||
|
||||
private static invalidateCache() {
|
||||
StashService.client.resetStore();
|
||||
}
|
||||
|
||||
private static invalidateQueries(queries : string[]) {
|
||||
if (!!StashService.cache) {
|
||||
const cache = StashService.cache as any;
|
||||
const keyMatchers = queries.map(query => {
|
||||
return new RegExp("^" + query);
|
||||
});
|
||||
|
||||
const rootQuery = cache.data.data.ROOT_QUERY;
|
||||
Object.keys(rootQuery).forEach(key => {
|
||||
if (keyMatchers.some(matcher => {
|
||||
return !!key.match(matcher);
|
||||
})) {
|
||||
delete rootQuery[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static useFindGalleries(filter: ListFilterModel) {
|
||||
return GQL.useFindGalleries({
|
||||
variables: {
|
||||
filter: filter.makeFindFilter(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static useFindScenes(filter: ListFilterModel) {
|
||||
let sceneFilter = {};
|
||||
// if (!!filter && filter.criteriaFilterOpen) {
|
||||
sceneFilter = filter.makeSceneFilter();
|
||||
// }
|
||||
// if (filter.customCriteria) {
|
||||
// filter.customCriteria.forEach(criteria => {
|
||||
// scene_filter[criteria.key] = criteria.value;
|
||||
// });
|
||||
// }
|
||||
|
||||
return GQL.useFindScenes({
|
||||
variables: {
|
||||
filter: filter.makeFindFilter(),
|
||||
scene_filter: sceneFilter,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static queryFindScenes(filter: ListFilterModel) {
|
||||
let sceneFilter = {};
|
||||
sceneFilter = filter.makeSceneFilter();
|
||||
|
||||
return StashService.client.query<GQL.FindScenesQuery>({
|
||||
query: GQL.FindScenesDocument,
|
||||
variables: {
|
||||
filter: filter.makeFindFilter(),
|
||||
scene_filter: sceneFilter,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static useFindSceneMarkers(filter: ListFilterModel) {
|
||||
let sceneMarkerFilter = {};
|
||||
// if (!!filter && filter.criteriaFilterOpen) {
|
||||
sceneMarkerFilter = filter.makeSceneMarkerFilter();
|
||||
// }
|
||||
// if (filter.customCriteria) {
|
||||
// filter.customCriteria.forEach(criteria => {
|
||||
// scene_filter[criteria.key] = criteria.value;
|
||||
// });
|
||||
// }
|
||||
|
||||
return GQL.useFindSceneMarkers({
|
||||
variables: {
|
||||
filter: filter.makeFindFilter(),
|
||||
scene_marker_filter: sceneMarkerFilter,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static queryFindSceneMarkers(filter: ListFilterModel) {
|
||||
let sceneMarkerFilter = {};
|
||||
sceneMarkerFilter = filter.makeSceneMarkerFilter();
|
||||
|
||||
return StashService.client.query<GQL.FindSceneMarkersQuery>({
|
||||
query: GQL.FindSceneMarkersDocument,
|
||||
variables: {
|
||||
filter: filter.makeFindFilter(),
|
||||
scene_marker_filter: sceneMarkerFilter,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static useFindStudios(filter: ListFilterModel) {
|
||||
return GQL.useFindStudios({
|
||||
variables: {
|
||||
filter: filter.makeFindFilter(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static useFindMovies(filter: ListFilterModel) {
|
||||
return GQL.useFindMovies({
|
||||
variables: {
|
||||
filter: filter.makeFindFilter(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static useFindPerformers(filter: ListFilterModel) {
|
||||
let performerFilter = {};
|
||||
// if (!!filter && filter.criteriaFilterOpen) {
|
||||
performerFilter = filter.makePerformerFilter();
|
||||
// }
|
||||
// if (filter.customCriteria) {
|
||||
// filter.customCriteria.forEach(criteria => {
|
||||
// scene_filter[criteria.key] = criteria.value;
|
||||
// });
|
||||
// }
|
||||
|
||||
return GQL.useFindPerformers({
|
||||
variables: {
|
||||
filter: filter.makeFindFilter(),
|
||||
performer_filter: performerFilter,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static queryFindPerformers(filter: ListFilterModel) {
|
||||
let performerFilter = {};
|
||||
performerFilter = filter.makePerformerFilter();
|
||||
|
||||
return StashService.client.query<GQL.FindPerformersQuery>({
|
||||
query: GQL.FindPerformersDocument,
|
||||
variables: {
|
||||
filter: filter.makeFindFilter(),
|
||||
performer_filter: performerFilter,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static useFindGallery(id: string) { return GQL.useFindGallery({ variables: { id } }); }
|
||||
public static useFindScene(id: string) { return GQL.useFindScene({ variables: { id } }); }
|
||||
public static useFindPerformer(id: string) {
|
||||
const skip = id === "new" ? true : false;
|
||||
return GQL.useFindPerformer({ variables: { id }, skip });
|
||||
}
|
||||
public static useFindStudio(id: string) {
|
||||
const skip = id === "new" ? true : false;
|
||||
return GQL.useFindStudio({ variables: { id }, skip });
|
||||
}
|
||||
public static useFindMovie(id: string) {
|
||||
const skip = id === "new" ? true : false;
|
||||
return GQL.useFindMovie({ variables: { id }, skip });
|
||||
}
|
||||
|
||||
// TODO - scene marker manipulation functions are handled differently
|
||||
private static sceneMarkerMutationImpactedQueries = [
|
||||
"findSceneMarkers",
|
||||
"findScenes",
|
||||
"markerStrings",
|
||||
"sceneMarkerTags"
|
||||
];
|
||||
|
||||
public static useSceneMarkerCreate() {
|
||||
return GQL.useSceneMarkerCreate({ refetchQueries: ["FindScene"] });
|
||||
}
|
||||
public static useSceneMarkerUpdate() {
|
||||
return GQL.useSceneMarkerUpdate({ refetchQueries: ["FindScene"] });
|
||||
}
|
||||
public static useSceneMarkerDestroy() {
|
||||
return GQL.useSceneMarkerDestroy({ refetchQueries: ["FindScene"] });
|
||||
}
|
||||
|
||||
public static useListPerformerScrapers() {
|
||||
return GQL.useListPerformerScrapers();
|
||||
}
|
||||
public static useScrapePerformerList(scraperId: string, q: string) {
|
||||
return GQL.useScrapePerformerList({ variables: { scraper_id: scraperId, query: q } });
|
||||
}
|
||||
public static useScrapePerformer(scraperId: string, scrapedPerformer: GQL.ScrapedPerformerInput) {
|
||||
return GQL.useScrapePerformer({ variables: { scraper_id: scraperId, scraped_performer: scrapedPerformer } });
|
||||
}
|
||||
|
||||
public static useListSceneScrapers() {
|
||||
return GQL.useListSceneScrapers();
|
||||
}
|
||||
|
||||
public static useScrapeFreeonesPerformers(q: string) { return GQL.useScrapeFreeonesPerformers({ variables: { q } }); }
|
||||
public static useMarkerStrings() { return GQL.useMarkerStrings(); }
|
||||
public static useAllTags() { return GQL.useAllTags(); }
|
||||
public static useAllTagsForFilter() { return GQL.useAllTagsForFilter(); }
|
||||
public static useAllPerformersForFilter() { return GQL.useAllPerformersForFilter(); }
|
||||
public static useAllStudiosForFilter() { return GQL.useAllStudiosForFilter(); }
|
||||
public static useAllMoviesForFilter() { return GQL.useAllMoviesForFilter(); }
|
||||
public static useValidGalleriesForScene(sceneId: string) {
|
||||
return GQL.useValidGalleriesForScene({ variables: { scene_id: sceneId } });
|
||||
}
|
||||
public static useStats() { return GQL.useStats(); }
|
||||
public static useVersion() { return GQL.useVersion(); }
|
||||
public static useLatestVersion() { return GQL.useLatestVersion({ notifyOnNetworkStatusChange: true, errorPolicy: 'ignore' }); }
|
||||
|
||||
public static useConfiguration() { return GQL.useConfiguration(); }
|
||||
public static useDirectories(path?: string) { return GQL.useDirectories({ variables: { path } }); }
|
||||
|
||||
private static performerMutationImpactedQueries = [
|
||||
"findPerformers",
|
||||
"findScenes",
|
||||
"findSceneMarkers",
|
||||
"allPerformers"
|
||||
];
|
||||
|
||||
|
||||
public static usePerformerCreate() {
|
||||
return GQL.usePerformerCreate({
|
||||
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
|
||||
});
|
||||
}
|
||||
public static usePerformerUpdate() {
|
||||
return GQL.usePerformerUpdate({
|
||||
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
|
||||
});
|
||||
}
|
||||
public static usePerformerDestroy() {
|
||||
return GQL.usePerformerDestroy({
|
||||
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
|
||||
});
|
||||
}
|
||||
|
||||
private static sceneMutationImpactedQueries = [
|
||||
"findPerformers",
|
||||
"findScenes",
|
||||
"findSceneMarkers",
|
||||
"findStudios",
|
||||
"findMovies",
|
||||
"allTags"
|
||||
// TODO - add "findTags" when it is implemented
|
||||
];
|
||||
|
||||
public static useSceneUpdate(input: GQL.SceneUpdateInput) {
|
||||
return GQL.useSceneUpdate({
|
||||
variables: input,
|
||||
update: () => StashService.invalidateQueries(StashService.sceneMutationImpactedQueries),
|
||||
refetchQueries: ["AllTagsForFilter"]
|
||||
});
|
||||
}
|
||||
|
||||
// remove findScenes for bulk scene update so that we don't lose
|
||||
// existing results
|
||||
private static sceneBulkMutationImpactedQueries = [
|
||||
"findPerformers",
|
||||
"findSceneMarkers",
|
||||
"findStudios",
|
||||
"findMovies",
|
||||
"allTags"
|
||||
];
|
||||
|
||||
public static useBulkSceneUpdate(input: GQL.BulkSceneUpdateInput) {
|
||||
return GQL.useBulkSceneUpdate({
|
||||
variables: input,
|
||||
update: () => StashService.invalidateQueries(StashService.sceneBulkMutationImpactedQueries)
|
||||
});
|
||||
}
|
||||
|
||||
public static useScenesUpdate(input: GQL.SceneUpdateInput[]) {
|
||||
return GQL.useScenesUpdate({ variables: { input: input } });
|
||||
}
|
||||
|
||||
public static useSceneIncrementO(id: string) {
|
||||
return GQL.useSceneIncrementO({
|
||||
variables: {id: id}
|
||||
});
|
||||
}
|
||||
|
||||
public static useSceneDecrementO(id: string) {
|
||||
return GQL.useSceneDecrementO({
|
||||
variables: {id: id}
|
||||
});
|
||||
}
|
||||
|
||||
public static useSceneResetO(id: string) {
|
||||
return GQL.useSceneResetO({
|
||||
variables: {id: id}
|
||||
});
|
||||
}
|
||||
|
||||
public static useSceneDestroy(input: GQL.SceneDestroyInput) {
|
||||
return GQL.useSceneDestroy({
|
||||
variables: input,
|
||||
update: () => StashService.invalidateQueries(StashService.sceneMutationImpactedQueries)
|
||||
});
|
||||
}
|
||||
|
||||
public static useSceneGenerateScreenshot() {
|
||||
return GQL.useSceneGenerateScreenshot({
|
||||
update: () => StashService.invalidateQueries(["findScenes"]),
|
||||
});
|
||||
}
|
||||
|
||||
private static studioMutationImpactedQueries = [
|
||||
"findStudios",
|
||||
"findScenes",
|
||||
"allStudios"
|
||||
];
|
||||
|
||||
public static useStudioCreate(input: GQL.StudioCreateInput) {
|
||||
return GQL.useStudioCreate({
|
||||
variables: input,
|
||||
update: () => StashService.invalidateQueries(StashService.studioMutationImpactedQueries)
|
||||
});
|
||||
}
|
||||
|
||||
public static useStudioUpdate(input: GQL.StudioUpdateInput) {
|
||||
return GQL.useStudioUpdate({
|
||||
variables: input,
|
||||
update: () => StashService.invalidateQueries(StashService.studioMutationImpactedQueries)
|
||||
});
|
||||
}
|
||||
|
||||
public static useStudioDestroy(input: GQL.StudioDestroyInput) {
|
||||
return GQL.useStudioDestroy({
|
||||
variables: input,
|
||||
update: () => StashService.invalidateQueries(StashService.studioMutationImpactedQueries)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private static movieMutationImpactedQueries = [
|
||||
"findMovies",
|
||||
"findScenes",
|
||||
"allMovies"
|
||||
];
|
||||
|
||||
public static useMovieCreate(input: GQL.MovieCreateInput) {
|
||||
return GQL.useMovieCreate({
|
||||
variables: input,
|
||||
update: () => StashService.invalidateQueries(StashService.movieMutationImpactedQueries)
|
||||
});
|
||||
}
|
||||
|
||||
public static useMovieUpdate(input: GQL.MovieUpdateInput) {
|
||||
return GQL.useMovieUpdate({
|
||||
variables: input,
|
||||
update: () => StashService.invalidateQueries(StashService.movieMutationImpactedQueries)
|
||||
});
|
||||
}
|
||||
|
||||
public static useMovieDestroy(input: GQL.MovieDestroyInput) {
|
||||
return GQL.useMovieDestroy({
|
||||
variables: input,
|
||||
update: () => StashService.invalidateQueries(StashService.movieMutationImpactedQueries)
|
||||
});
|
||||
}
|
||||
|
||||
private static tagMutationImpactedQueries = [
|
||||
"findScenes",
|
||||
"findSceneMarkers",
|
||||
"sceneMarkerTags",
|
||||
"allTags"
|
||||
];
|
||||
|
||||
public static useTagCreate(input: GQL.TagCreateInput) {
|
||||
return GQL.useTagCreate({
|
||||
variables: input,
|
||||
refetchQueries: ["AllTags"],
|
||||
update: () => StashService.invalidateQueries(StashService.tagMutationImpactedQueries)
|
||||
});
|
||||
}
|
||||
public static useTagUpdate(input: GQL.TagUpdateInput) {
|
||||
return GQL.useTagUpdate({
|
||||
variables: input,
|
||||
refetchQueries: ["AllTags"],
|
||||
update: () => StashService.invalidateQueries(StashService.tagMutationImpactedQueries)
|
||||
});
|
||||
}
|
||||
public static useTagDestroy(input: GQL.TagDestroyInput) {
|
||||
return GQL.useTagDestroy({
|
||||
variables: input,
|
||||
refetchQueries: ["AllTags"],
|
||||
update: () => StashService.invalidateQueries(StashService.tagMutationImpactedQueries)
|
||||
});
|
||||
}
|
||||
|
||||
public static useConfigureGeneral(input: GQL.ConfigGeneralInput) {
|
||||
return GQL.useConfigureGeneral({ variables: { input }, refetchQueries: ["Configuration"] });
|
||||
}
|
||||
|
||||
public static useConfigureInterface(input: GQL.ConfigInterfaceInput) {
|
||||
return GQL.useConfigureInterface({ variables: { input }, refetchQueries: ["Configuration"] });
|
||||
}
|
||||
|
||||
public static useMetadataUpdate() {
|
||||
return GQL.useMetadataUpdate();
|
||||
}
|
||||
|
||||
public static useLoggingSubscribe() {
|
||||
return GQL.useLoggingSubscribe();
|
||||
}
|
||||
|
||||
public static useLogs() {
|
||||
return GQL.useLogs({
|
||||
fetchPolicy: 'no-cache'
|
||||
});
|
||||
}
|
||||
|
||||
public static useJobStatus() {
|
||||
return GQL.useJobStatus({
|
||||
fetchPolicy: 'no-cache'
|
||||
});
|
||||
}
|
||||
|
||||
public static mutateStopJob() {
|
||||
return StashService.client.mutate<GQL.StopJobMutation>({
|
||||
mutation: GQL.StopJobDocument,
|
||||
});
|
||||
}
|
||||
|
||||
public static queryScrapeFreeones(performerName: string) {
|
||||
return StashService.client.query<GQL.ScrapeFreeonesQuery>({
|
||||
query: GQL.ScrapeFreeonesDocument,
|
||||
variables: {
|
||||
performer_name: performerName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static queryScrapePerformer(scraperId: string, scrapedPerformer: GQL.ScrapedPerformerInput) {
|
||||
return StashService.client.query<GQL.ScrapePerformerQuery>({
|
||||
query: GQL.ScrapePerformerDocument,
|
||||
variables: {
|
||||
scraper_id: scraperId,
|
||||
scraped_performer: scrapedPerformer,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static queryScrapePerformerURL(url: string) {
|
||||
return StashService.client.query<GQL.ScrapePerformerUrlQuery>({
|
||||
query: GQL.ScrapePerformerUrlDocument,
|
||||
variables: {
|
||||
url: url,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static queryScrapeSceneURL(url: string) {
|
||||
return StashService.client.query<GQL.ScrapeSceneUrlQuery>({
|
||||
query: GQL.ScrapeSceneUrlDocument,
|
||||
variables: {
|
||||
url: url,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static queryScrapeScene(scraperId: string, scene: GQL.SceneUpdateInput) {
|
||||
return StashService.client.query<GQL.ScrapeSceneQuery>({
|
||||
query: GQL.ScrapeSceneDocument,
|
||||
variables: {
|
||||
scraper_id: scraperId,
|
||||
scene: scene,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static mutateMetadataScan(input: GQL.ScanMetadataInput) {
|
||||
return StashService.client.mutate<GQL.MetadataScanMutation>({
|
||||
mutation: GQL.MetadataScanDocument,
|
||||
variables: { input },
|
||||
});
|
||||
}
|
||||
|
||||
public static mutateMetadataAutoTag(input: GQL.AutoTagMetadataInput) {
|
||||
return StashService.client.mutate<GQL.MetadataAutoTagMutation>({
|
||||
mutation: GQL.MetadataAutoTagDocument,
|
||||
variables: { input },
|
||||
});
|
||||
}
|
||||
|
||||
public static mutateMetadataGenerate(input: GQL.GenerateMetadataInput) {
|
||||
return StashService.client.mutate<GQL.MetadataGenerateMutation>({
|
||||
mutation: GQL.MetadataGenerateDocument,
|
||||
variables: { input },
|
||||
});
|
||||
}
|
||||
|
||||
public static mutateMetadataClean() {
|
||||
return StashService.client.mutate<GQL.MetadataCleanMutation>({
|
||||
mutation: GQL.MetadataCleanDocument,
|
||||
});
|
||||
}
|
||||
|
||||
public static mutateMetadataExport() {
|
||||
return StashService.client.mutate<GQL.MetadataExportMutation>({
|
||||
mutation: GQL.MetadataExportDocument,
|
||||
});
|
||||
}
|
||||
|
||||
public static mutateMetadataImport() {
|
||||
return StashService.client.mutate<GQL.MetadataImportMutation>({
|
||||
mutation: GQL.MetadataImportDocument,
|
||||
});
|
||||
}
|
||||
|
||||
public static querySceneByPathRegex(filter: GQL.FindFilterType) {
|
||||
return StashService.client.query<GQL.FindScenesByPathRegexQuery>({
|
||||
query: GQL.FindScenesByPathRegexDocument,
|
||||
variables: { filter: filter },
|
||||
});
|
||||
}
|
||||
|
||||
public static queryParseSceneFilenames(filter: GQL.FindFilterType, config: GQL.SceneParserInput) {
|
||||
return StashService.client.query<GQL.ParseSceneFilenamesQuery>({
|
||||
query: GQL.ParseSceneFilenamesDocument,
|
||||
variables: { filter: filter, config: config },
|
||||
fetchPolicy: "network-only",
|
||||
});
|
||||
}
|
||||
|
||||
private static stringGenderMap = new Map<string, GQL.GenderEnum>(
|
||||
[["Male", GQL.GenderEnum.Male],
|
||||
["Female", GQL.GenderEnum.Female],
|
||||
["Transgender Male", GQL.GenderEnum.TransgenderMale],
|
||||
["Transgender Female", GQL.GenderEnum.TransgenderFemale],
|
||||
["Intersex", GQL.GenderEnum.Intersex]]
|
||||
);
|
||||
|
||||
public static genderToString(value?: GQL.GenderEnum) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const foundEntry = Array.from(StashService.stringGenderMap.entries()).find((e) => {
|
||||
return e[1] === value;
|
||||
});
|
||||
|
||||
if (foundEntry) {
|
||||
return foundEntry[0];
|
||||
}
|
||||
}
|
||||
|
||||
public static stringToGender(value?: string) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return StashService.stringGenderMap.get(value);
|
||||
}
|
||||
|
||||
public static getGenderStrings() {
|
||||
return Array.from(StashService.stringGenderMap.keys());
|
||||
}
|
||||
|
||||
public static nullToUndefined(value: any): any {
|
||||
if (_.isPlainObject(value)) {
|
||||
return _.mapValues(value, StashService.nullToUndefined);
|
||||
}
|
||||
if (_.isArray(value)) {
|
||||
return value.map(StashService.nullToUndefined);
|
||||
}
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private constructor() { }
|
||||
}
|
||||
|
|
@ -1,449 +0,0 @@
|
|||
import { Spinner } from "@blueprintjs/core";
|
||||
import _ from "lodash";
|
||||
import queryString from "query-string";
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { QueryHookResult } from "react-apollo-hooks";
|
||||
import { ListFilter } from "../components/list/ListFilter";
|
||||
import { Pagination } from "../components/list/Pagination";
|
||||
import { StashService } from "../core/StashService";
|
||||
import { IBaseProps } from "../models";
|
||||
import { Criterion } from "../models/list-filter/criteria/criterion";
|
||||
import { ListFilterModel } from "../models/list-filter/filter";
|
||||
import { DisplayMode, FilterMode } from "../models/list-filter/types";
|
||||
import { useInterfaceLocalForage } from "./LocalForage";
|
||||
|
||||
export interface IListHookData {
|
||||
filter: ListFilterModel;
|
||||
template: JSX.Element;
|
||||
options: IListHookOptions;
|
||||
onSelectChange: (id: string, selected : boolean, shiftKey: boolean) => void;
|
||||
}
|
||||
|
||||
interface IListHookOperation {
|
||||
text: string;
|
||||
onClick: (result: QueryHookResult<any, any>, filter: ListFilterModel, selectedIds: Set<string>) => void;
|
||||
}
|
||||
|
||||
export interface IFilterListImpl {
|
||||
getData: (filter : ListFilterModel) => QueryHookResult<any, any>;
|
||||
getItems: (data: any) => any[];
|
||||
getCount: (data: any) => number;
|
||||
}
|
||||
|
||||
const SceneFilterListImpl: IFilterListImpl = {
|
||||
getData: (filter : ListFilterModel) => { return StashService.useFindScenes(filter); },
|
||||
getItems: (data: any) => { return !!data && !!data.findScenes ? data.findScenes.scenes : []; },
|
||||
getCount: (data: any) => { return !!data && !!data.findScenes ? data.findScenes.count : 0; }
|
||||
}
|
||||
|
||||
const SceneMarkerFilterListImpl: IFilterListImpl = {
|
||||
getData: (filter : ListFilterModel) => { return StashService.useFindSceneMarkers(filter); },
|
||||
getItems: (data: any) => { return !!data && !!data.findSceneMarkers ? data.findSceneMarkers.scene_markers : []; },
|
||||
getCount: (data: any) => { return !!data && !!data.findSceneMarkers ? data.findSceneMarkers.count : 0; }
|
||||
}
|
||||
|
||||
const GalleryFilterListImpl: IFilterListImpl = {
|
||||
getData: (filter : ListFilterModel) => { return StashService.useFindGalleries(filter); },
|
||||
getItems: (data: any) => { return !!data && !!data.findGalleries ? data.findGalleries.galleries : []; },
|
||||
getCount: (data: any) => { return !!data && !!data.findGalleries ? data.findGalleries.count : 0; }
|
||||
}
|
||||
|
||||
const StudioFilterListImpl: IFilterListImpl = {
|
||||
getData: (filter : ListFilterModel) => { return StashService.useFindStudios(filter); },
|
||||
getItems: (data: any) => { return !!data && !!data.findStudios ? data.findStudios.studios : []; },
|
||||
getCount: (data: any) => { return !!data && !!data.findStudios ? data.findStudios.count : 0; }
|
||||
}
|
||||
|
||||
const PerformerFilterListImpl: IFilterListImpl = {
|
||||
getData: (filter : ListFilterModel) => { return StashService.useFindPerformers(filter); },
|
||||
getItems: (data: any) => { return !!data && !!data.findPerformers ? data.findPerformers.performers : []; },
|
||||
getCount: (data: any) => { return !!data && !!data.findPerformers ? data.findPerformers.count : 0; }
|
||||
}
|
||||
|
||||
const MoviesFilterListImpl: IFilterListImpl = {
|
||||
getData: (filter : ListFilterModel) => { return StashService.useFindMovies(filter); },
|
||||
getItems: (data: any) => { return !!data && !!data.findMovies ? data.findMovies.movies : []; },
|
||||
getCount: (data: any) => { return !!data && !!data.findMovies ? data.findMovies.count : 0; }
|
||||
}
|
||||
|
||||
|
||||
function getFilterListImpl(filterMode: FilterMode) {
|
||||
switch (filterMode) {
|
||||
case FilterMode.Scenes: {
|
||||
return SceneFilterListImpl;
|
||||
}
|
||||
case FilterMode.SceneMarkers: {
|
||||
return SceneMarkerFilterListImpl;
|
||||
}
|
||||
case FilterMode.Galleries: {
|
||||
return GalleryFilterListImpl;
|
||||
}
|
||||
case FilterMode.Studios: {
|
||||
return StudioFilterListImpl;
|
||||
}
|
||||
case FilterMode.Performers: {
|
||||
return PerformerFilterListImpl;
|
||||
}
|
||||
case FilterMode.Movies: {
|
||||
return MoviesFilterListImpl;
|
||||
}
|
||||
default: {
|
||||
console.error("REMOVE DEFAULT IN LIST HOOK");
|
||||
return SceneFilterListImpl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface IListHookOptions {
|
||||
filterMode: FilterMode;
|
||||
subComponent?: boolean;
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
props: IBaseProps;
|
||||
zoomable?: boolean;
|
||||
otherOperations?: IListHookOperation[];
|
||||
renderContent: (result: QueryHookResult<any, any>, filter: ListFilterModel, selectedIds: Set<string>, zoomIndex: number) => JSX.Element | undefined;
|
||||
renderSelectedOptions?: (result: QueryHookResult<any, any>, selectedIds: Set<string>) => JSX.Element | undefined;
|
||||
}
|
||||
|
||||
function updateFromQueryString(queryStr: string, setFilter: (value: React.SetStateAction<ListFilterModel>) => void, forageData?: any) {
|
||||
const queryParams = queryString.parse(queryStr);
|
||||
setFilter((f) => {
|
||||
const newFilter = _.cloneDeep(f);
|
||||
newFilter.configureFromQueryParameters(queryParams);
|
||||
|
||||
if (forageData) {
|
||||
const forageParams = queryString.parse(forageData.filter);
|
||||
newFilter.overridePrefs(queryParams, forageParams);
|
||||
}
|
||||
|
||||
return newFilter;
|
||||
});
|
||||
}
|
||||
|
||||
export class ListHook {
|
||||
public static useList(options: IListHookOptions): IListHookData {
|
||||
const [filter, setFilter] = useState<ListFilterModel>(new ListFilterModel(options.filterMode));
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [lastClickedId, setLastClickedId] = useState<string | undefined>(undefined);
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [zoomIndex, setZoomIndex] = useState<number>(1);
|
||||
|
||||
const [interfaceForage, setInterfaceForage] = useInterfaceLocalForage();
|
||||
const forageInitialised = useRef<boolean>(false);
|
||||
|
||||
const filterListImpl = getFilterListImpl(options.filterMode);
|
||||
|
||||
// Initialise from interface forage when loaded
|
||||
useEffect(() => {
|
||||
function updateFromLocalForage(queryData: any) {
|
||||
const queryParams = queryString.parse(queryData.filter);
|
||||
|
||||
setFilter((f) => {
|
||||
const newFilter = _.cloneDeep(f);
|
||||
newFilter.configureFromQueryParameters(queryParams);
|
||||
newFilter.currentPage = queryData.currentPage;
|
||||
newFilter.itemsPerPage = queryData.itemsPerPage;
|
||||
return newFilter;
|
||||
});
|
||||
}
|
||||
|
||||
function initialise() {
|
||||
forageInitialised.current = true;
|
||||
|
||||
let forageData: any;
|
||||
|
||||
if (interfaceForage.data && interfaceForage.data.queries[options.filterMode]) {
|
||||
forageData = interfaceForage.data.queries[options.filterMode];
|
||||
}
|
||||
|
||||
if (!options.props!.location.search && forageData) {
|
||||
// we have some data, try to load it
|
||||
updateFromLocalForage(forageData);
|
||||
} else {
|
||||
// use query string instead - include the forageData to include the following
|
||||
// preferences if not specified: displayMode, itemsPerPage, sortBy and sortDir
|
||||
updateFromQueryString(options.props!.location.search, setFilter, forageData);
|
||||
}
|
||||
}
|
||||
|
||||
// don't use query parameters for sub-components
|
||||
if (!options.subComponent) {
|
||||
// initialise once when the forage is loaded
|
||||
if (!forageInitialised.current && !interfaceForage.loading) {
|
||||
initialise();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [interfaceForage.data, interfaceForage.loading, options.props, options.filterMode, options.subComponent]);
|
||||
|
||||
// Update the filter when the query parameters change
|
||||
useEffect(() => {
|
||||
// don't use query parameters for sub-components
|
||||
if (!options.subComponent) {
|
||||
// only update from the URL if the forage is initialised
|
||||
if (forageInitialised.current) {
|
||||
updateFromQueryString(options.props!.location.search, setFilter);
|
||||
}
|
||||
}
|
||||
}, [options.props, options.filterMode, options.subComponent]);
|
||||
|
||||
function getFilter() {
|
||||
if (!options.filterHook) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
// make a copy of the filter and call the hook
|
||||
let newFilter = _.cloneDeep(filter);
|
||||
return options.filterHook(newFilter);
|
||||
}
|
||||
|
||||
const result = filterListImpl.getData(getFilter());
|
||||
|
||||
useEffect(() => {
|
||||
setTotalCount(filterListImpl.getCount(result.data));
|
||||
|
||||
// select none when data changes
|
||||
onSelectNone();
|
||||
setLastClickedId(undefined);
|
||||
}, [result.data, filterListImpl])
|
||||
|
||||
// Update the query parameters when the data changes
|
||||
|
||||
useEffect(() => {
|
||||
// don't use query parameters for sub-components
|
||||
if (!options.subComponent) {
|
||||
// don't update this until local forage is loaded
|
||||
if (forageInitialised.current) {
|
||||
const location = Object.assign({}, options.props.history.location);
|
||||
const includePrefs = true;
|
||||
location.search = "?" + filter.makeQueryParameters(includePrefs);
|
||||
|
||||
if (location.search !== options.props.history.location.search) {
|
||||
options.props.history.replace(location);
|
||||
}
|
||||
|
||||
setInterfaceForage((d) => {
|
||||
const dataClone = _.cloneDeep(d);
|
||||
dataClone!.queries[options.filterMode] = {
|
||||
filter: location.search,
|
||||
itemsPerPage: filter.itemsPerPage,
|
||||
currentPage: filter.currentPage
|
||||
};
|
||||
return dataClone;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [result.data, filter, options.subComponent, options.filterMode, options.props.history, setInterfaceForage]);
|
||||
|
||||
function onChangePageSize(pageSize: number) {
|
||||
const newFilter = _.cloneDeep(filter);
|
||||
newFilter.itemsPerPage = pageSize;
|
||||
newFilter.currentPage = 1;
|
||||
setFilter(newFilter);
|
||||
}
|
||||
|
||||
function onChangeQuery(query: string) {
|
||||
const newFilter = _.cloneDeep(filter);
|
||||
newFilter.searchTerm = query;
|
||||
newFilter.currentPage = 1;
|
||||
setFilter(newFilter);
|
||||
}
|
||||
|
||||
function onChangeSortDirection(sortDirection: "asc" | "desc") {
|
||||
const newFilter = _.cloneDeep(filter);
|
||||
newFilter.sortDirection = sortDirection;
|
||||
setFilter(newFilter);
|
||||
}
|
||||
|
||||
function onChangeSortBy(sortBy: string) {
|
||||
const newFilter = _.cloneDeep(filter);
|
||||
newFilter.sortBy = sortBy;
|
||||
newFilter.currentPage = 1;
|
||||
setFilter(newFilter);
|
||||
}
|
||||
|
||||
function onChangeDisplayMode(displayMode: DisplayMode) {
|
||||
const newFilter = _.cloneDeep(filter);
|
||||
newFilter.displayMode = displayMode;
|
||||
setFilter(newFilter);
|
||||
}
|
||||
|
||||
function onAddCriterion(criterion: Criterion, oldId?: string) {
|
||||
const newFilter = _.cloneDeep(filter);
|
||||
|
||||
// Find if we are editing an existing criteria, then modify that. Or create a new one.
|
||||
const existingIndex = newFilter.criteria.findIndex((c) => {
|
||||
// If we modified an existing criterion, then look for the old id.
|
||||
const id = !!oldId ? oldId : criterion.getId();
|
||||
return c.getId() === id;
|
||||
});
|
||||
if (existingIndex === -1) {
|
||||
newFilter.criteria.push(criterion);
|
||||
} else {
|
||||
newFilter.criteria[existingIndex] = criterion;
|
||||
}
|
||||
|
||||
// Remove duplicate modifiers
|
||||
newFilter.criteria = newFilter.criteria.filter((obj, pos, arr) => {
|
||||
return arr.map((mapObj: any) => mapObj.getId()).indexOf(obj.getId()) === pos;
|
||||
});
|
||||
|
||||
newFilter.currentPage = 1;
|
||||
setFilter(newFilter);
|
||||
}
|
||||
|
||||
function onRemoveCriterion(removedCriterion: Criterion) {
|
||||
const newFilter = _.cloneDeep(filter);
|
||||
newFilter.criteria = newFilter.criteria.filter((criterion) => criterion.getId() !== removedCriterion.getId());
|
||||
newFilter.currentPage = 1;
|
||||
setFilter(newFilter);
|
||||
}
|
||||
|
||||
function onChangePage(page: number) {
|
||||
const newFilter = _.cloneDeep(filter);
|
||||
newFilter.currentPage = page;
|
||||
setFilter(newFilter);
|
||||
}
|
||||
|
||||
function onSelectChange(id: string, selected : boolean, shiftKey: boolean) {
|
||||
if (shiftKey) {
|
||||
multiSelect(id, selected);
|
||||
} else {
|
||||
singleSelect(id, selected);
|
||||
}
|
||||
}
|
||||
|
||||
function singleSelect(id: string, selected: boolean) {
|
||||
setLastClickedId(id);
|
||||
|
||||
const newSelectedIds = _.clone(selectedIds);
|
||||
if (selected) {
|
||||
newSelectedIds.add(id);
|
||||
} else {
|
||||
newSelectedIds.delete(id);
|
||||
}
|
||||
|
||||
setSelectedIds(newSelectedIds);
|
||||
}
|
||||
|
||||
function multiSelect(id: string, selected : boolean) {
|
||||
let startIndex = 0;
|
||||
let thisIndex = -1;
|
||||
|
||||
if (!!lastClickedId) {
|
||||
startIndex = filterListImpl.getItems(result.data).findIndex((item) => {
|
||||
return item.id === lastClickedId;
|
||||
});
|
||||
}
|
||||
|
||||
thisIndex = filterListImpl.getItems(result.data).findIndex((item) => {
|
||||
return item.id === id;
|
||||
});
|
||||
|
||||
selectRange(startIndex, thisIndex);
|
||||
}
|
||||
|
||||
function selectRange(startIndex : number, endIndex : number) {
|
||||
if (startIndex > endIndex) {
|
||||
let tmp = startIndex;
|
||||
startIndex = endIndex;
|
||||
endIndex = tmp;
|
||||
}
|
||||
|
||||
const subset = filterListImpl.getItems(result.data).slice(startIndex, endIndex + 1);
|
||||
const newSelectedIds : Set<string> = new Set();
|
||||
|
||||
subset.forEach((item) => {
|
||||
newSelectedIds.add(item.id);
|
||||
});
|
||||
|
||||
setSelectedIds(newSelectedIds);
|
||||
}
|
||||
|
||||
function onSelectAll() {
|
||||
const newSelectedIds : Set<string> = new Set();
|
||||
filterListImpl.getItems(result.data).forEach((item) => {
|
||||
newSelectedIds.add(item.id);
|
||||
});
|
||||
|
||||
setSelectedIds(newSelectedIds);
|
||||
setLastClickedId(undefined);
|
||||
}
|
||||
|
||||
function onSelectNone() {
|
||||
const newSelectedIds : Set<string> = new Set();
|
||||
setSelectedIds(newSelectedIds);
|
||||
setLastClickedId(undefined);
|
||||
}
|
||||
|
||||
function onChangeZoom(newZoomIndex : number) {
|
||||
setZoomIndex(newZoomIndex);
|
||||
}
|
||||
|
||||
const otherOperations = options.otherOperations ? options.otherOperations.map((o) => {
|
||||
return {
|
||||
text: o.text,
|
||||
onClick: () => {
|
||||
o.onClick(result, filter, selectedIds);
|
||||
}
|
||||
}
|
||||
}) : undefined;
|
||||
|
||||
function maybeRenderContent() {
|
||||
if (!result.loading && !result.error) {
|
||||
return options.renderContent(result, filter, selectedIds, zoomIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderPagination() {
|
||||
if (!result.loading && !result.error) {
|
||||
return (
|
||||
<Pagination
|
||||
itemsPerPage={filter.itemsPerPage}
|
||||
currentPage={filter.currentPage}
|
||||
totalItems={totalCount}
|
||||
onChangePage={onChangePage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getTemplate() {
|
||||
if (!options.subComponent && !forageInitialised.current) {
|
||||
return (
|
||||
<div>
|
||||
{!result.error ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
||||
{result.error ? <h1>{result.error.message}</h1> : undefined}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<ListFilter
|
||||
onChangePageSize={onChangePageSize}
|
||||
onChangeQuery={onChangeQuery}
|
||||
onChangeSortDirection={onChangeSortDirection}
|
||||
onChangeSortBy={onChangeSortBy}
|
||||
onChangeDisplayMode={onChangeDisplayMode}
|
||||
onAddCriterion={onAddCriterion}
|
||||
onRemoveCriterion={onRemoveCriterion}
|
||||
onSelectAll={onSelectAll}
|
||||
onSelectNone={onSelectNone}
|
||||
zoomIndex={options.zoomable ? zoomIndex : undefined}
|
||||
onChangeZoom={options.zoomable ? onChangeZoom : undefined}
|
||||
otherOperations={otherOperations}
|
||||
filter={filter}
|
||||
/>
|
||||
{options.renderSelectedOptions && selectedIds.size > 0 ? options.renderSelectedOptions(result, selectedIds) : undefined}
|
||||
{result.loading || (!options.subComponent && !forageInitialised.current) ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
||||
{result.error ? <h1>{result.error.message}</h1> : undefined}
|
||||
{maybeRenderContent()}
|
||||
{maybeRenderPagination()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { filter, template: getTemplate(), options, onSelectChange };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import localForage from "localforage";
|
||||
import _ from "lodash";
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
|
||||
interface IInterfaceWallConfig {
|
||||
}
|
||||
export interface IInterfaceConfig {
|
||||
wall: IInterfaceWallConfig;
|
||||
queries: any;
|
||||
}
|
||||
|
||||
type ValidTypes = IInterfaceConfig | undefined;
|
||||
|
||||
interface ILocalForage<T> {
|
||||
data: T;
|
||||
setData: React.Dispatch<React.SetStateAction<T>>;
|
||||
error: Error | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useInterfaceLocalForage(): [ILocalForage<IInterfaceConfig | undefined>, React.Dispatch<React.SetStateAction<IInterfaceConfig | undefined>>] {
|
||||
const result = useLocalForage("interface");
|
||||
// Set defaults
|
||||
React.useEffect(() => {
|
||||
if (!result.data) {
|
||||
result.setData({
|
||||
wall: {
|
||||
// nothing here currently
|
||||
},
|
||||
queries: {}
|
||||
});
|
||||
} else if (!result.data.queries) {
|
||||
let newData = Object.assign({}, result.data);
|
||||
newData.queries = {};
|
||||
result.setData(newData);
|
||||
}
|
||||
});
|
||||
return [result, result.setData];
|
||||
}
|
||||
|
||||
function useLocalForage(item: string): ILocalForage<ValidTypes> {
|
||||
const [json, setJson] = React.useState<ValidTypes>(undefined);
|
||||
const [err, setErr] = React.useState(null);
|
||||
const [loaded, setLoaded] = React.useState<boolean>(false);
|
||||
|
||||
const prevJson = React.useRef<ValidTypes>(undefined);
|
||||
React.useEffect(() => {
|
||||
async function runAsync() {
|
||||
if (typeof json !== "undefined" && !_.isEqual(json, prevJson.current)) {
|
||||
await localForage.setItem(item, JSON.stringify(json));
|
||||
}
|
||||
prevJson.current = json;
|
||||
}
|
||||
runAsync();
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
async function runAsync() {
|
||||
try {
|
||||
const serialized = await localForage.getItem<any>(item);
|
||||
const parsed = JSON.parse(serialized);
|
||||
if (typeof json === "undefined" && !Object.is(parsed, null)) {
|
||||
setErr(null);
|
||||
setJson(parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
setErr(error);
|
||||
}
|
||||
setLoaded(true);
|
||||
}
|
||||
runAsync();
|
||||
});
|
||||
|
||||
return {data: json, setData: setJson, error: err, loading: !loaded};
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { StashService } from "../core/StashService";
|
||||
|
||||
export interface IVideoHoverHookData {
|
||||
videoEl: React.RefObject<HTMLVideoElement>;
|
||||
isPlaying: React.MutableRefObject<boolean>;
|
||||
isHovering: React.MutableRefObject<boolean>;
|
||||
options: IVideoHoverHookOptions;
|
||||
}
|
||||
|
||||
export interface IVideoHoverHookOptions {
|
||||
resetOnMouseLeave: boolean;
|
||||
}
|
||||
|
||||
export class VideoHoverHook {
|
||||
public static useVideoHover(options: IVideoHoverHookOptions): IVideoHoverHookData {
|
||||
const videoEl = useRef<HTMLVideoElement>(null);
|
||||
const isPlaying = useRef<boolean>(false);
|
||||
const isHovering = useRef<boolean>(false);
|
||||
|
||||
const config = StashService.useConfiguration();
|
||||
const soundEnabled = !!config.data && !!config.data.configuration ? config.data.configuration.interface.soundOnPreview : true;
|
||||
|
||||
useEffect(() => {
|
||||
const videoTag = videoEl.current;
|
||||
if (!videoTag) { return; }
|
||||
videoTag.onplaying = () => {
|
||||
if (isHovering.current === true) {
|
||||
isPlaying.current = true;
|
||||
} else {
|
||||
videoTag.pause();
|
||||
}
|
||||
};
|
||||
videoTag.onpause = () => isPlaying.current = false;
|
||||
}, [videoEl]);
|
||||
|
||||
useEffect(() => {
|
||||
const videoTag = videoEl.current;
|
||||
if (!videoTag) { return; }
|
||||
videoTag.volume = soundEnabled ? 0.05 : 0;
|
||||
}, [soundEnabled]);
|
||||
|
||||
return {videoEl, isPlaying, isHovering, options};
|
||||
}
|
||||
|
||||
public static onMouseEnter(data: IVideoHoverHookData) {
|
||||
data.isHovering.current = true;
|
||||
|
||||
const videoTag = data.videoEl.current;
|
||||
if (!videoTag) { return; }
|
||||
if (videoTag.paused && !data.isPlaying.current) {
|
||||
videoTag.play().catch((error) => {
|
||||
console.log(error.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static onMouseLeave(data: IVideoHoverHookData) {
|
||||
data.isHovering.current = false;
|
||||
|
||||
const videoTag = data.videoEl.current;
|
||||
if (!videoTag) { return; }
|
||||
if (!videoTag.paused && data.isPlaying) {
|
||||
videoTag.pause();
|
||||
if (data.options.resetOnMouseLeave) {
|
||||
videoTag.removeAttribute("src");
|
||||
videoTag.load();
|
||||
data.isPlaying.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue