This commit is contained in:
Infinite 2020-01-03 22:29:21 +01:00
parent 716c33fc8e
commit 4d44deff64
128 changed files with 28709 additions and 0 deletions

2
ui/v2.5/.env Normal file
View file

@ -0,0 +1,2 @@
BROWSER=none
PORT=3001

1
ui/v2.5/.eslintcache Normal file

File diff suppressed because one or more lines are too long

8
ui/v2.5/.eslintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"extends": [
"react-app"
],
"rules": {
"jsx-a11y/anchor-is-valid": "off"
}
}

23
ui/v2.5/.gitignore vendored Executable file
View file

@ -0,0 +1,23 @@
# 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.5/.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,18 @@
{
// 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.5/.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,10 @@
{
"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]
}

47
ui/v2.5/README.md Executable file
View file

@ -0,0 +1,47 @@
* 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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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/).

18
ui/v2.5/codegen.yml Normal file
View file

@ -0,0 +1,18 @@
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"

73
ui/v2.5/package.json Normal file
View file

@ -0,0 +1,73 @@
{
"name": "stash",
"version": "0.1.0",
"private": true,
"dependencies": {
"@blueprintjs/core": "3.22.1",
"@blueprintjs/select": "3.11.2",
"@fortawesome/fontawesome-svg-core": "^1.2.26",
"@fortawesome/free-solid-svg-icons": "^5.12.0",
"@fortawesome/react-fontawesome": "^0.1.8",
"apollo-boost": "0.4.0",
"apollo-link-ws": "^1.0.19",
"axios": "0.18.1",
"bootstrap": "^4.4.1",
"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.12.0",
"react-apollo": "2.5.6",
"react-apollo-hooks": "0.4.5",
"react-bootstrap": "^1.0.0-beta.16",
"react-dom": "16.12.0",
"react-hotkeys": "^2.0.0",
"react-images": "0.5.19",
"react-jw-player": "1.19.0",
"react-photo-gallery": "7.0.2",
"react-router-bootstrap": "^0.25.0",
"react-router-dom": "^5.1.2",
"react-scripts": "3.3.0",
"subscriptions-transport-ws": "^0.9.16",
"video.js": "^7.6.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "eslint --cache src/**/*.{ts,tsx}",
"lint:fix": "eslint --fix 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": {
"@types/jest": "24.0.13",
"@types/lodash": "4.14.132",
"@types/node": "11.13.0",
"@types/query-string": "6.3.0",
"@types/react": "16.9.15",
"@types/react-dom": "16.9.4",
"@types/react-router-bootstrap": "^0.24.5",
"@types/react-router-dom": "5.1.3",
"@types/video.js": "^7.2.11",
"eslint": "^6.7.2",
"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",
"typescript": "3.4.5"
}
}

BIN
ui/v2.5/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

41
ui/v2.5/public/index.html Executable file
View file

@ -0,0 +1,41 @@
<!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"
/>
<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

View file

@ -0,0 +1,92 @@
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

View file

@ -0,0 +1,95 @@
/*!
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],{92:function(t,e,r){"use strict";r.r(e);var n=r(42),i=r(64),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}}]);

15
ui/v2.5/public/manifest.json Executable file
View file

@ -0,0 +1,15 @@
{
"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"
}

BIN
ui/v2.5/src/.index.scss.swp Normal file

Binary file not shown.

42
ui/v2.5/src/App.tsx Executable file
View file

@ -0,0 +1,42 @@
import React 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 Tags from "./components/Tags/Tags";
import { SceneFilenameParser } from "./components/scenes/SceneFilenameParser";
import { library } from '@fortawesome/fontawesome-svg-core'
import { fas } from '@fortawesome/free-solid-svg-icons'
import 'bootstrap/dist/css/bootstrap.min.css';
library.add(fas);
export const App: React.FC = () => (
<div className="bp3-dark">
<ErrorBoundary>
<MainNavbar />
<div className="main">
<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="/settings" component={Settings} />
<Route path="/sceneFilenameParser" component={SceneFilenameParser} />
<Route component={PageNotFound} />
</Switch>
</div>
</ErrorBoundary>
</div>
);

View file

@ -0,0 +1,34 @@
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;
}
}

View file

@ -0,0 +1,13 @@
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;

View file

@ -0,0 +1,30 @@
import _ from "lodash";
import React, { useEffect, useState } from "react";
import { Spinner } from 'react-bootstrap';
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: React.FC<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]);
if (!data || !data.findGallery || isLoading) { return <Spinner animation="border" variant="light" />; }
if (!!error) { return <>{error.message}</>; }
return (
<div style={{width: "75vw", margin: "0 auto"}}>
<GalleryViewer gallery={gallery as any} />
</div>
);
};

View file

@ -0,0 +1,54 @@
import _ from "lodash";
import React from "react";
import { Table } from 'react-bootstrap';
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: React.FC<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 (
<Table 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="" src={`${gallery.files[0].path}?thumb=true`} /> : undefined}
</Link>
</td>
<td><Link to={`/galleries/${gallery.id}`}>{gallery.path}</Link></td>
</tr>
))}
</tbody>
</Table>
);
} else if (filter.displayMode === DisplayMode.Wall) {
return <h1>TODO</h1>;
}
}
return listData.template;
};

View file

@ -0,0 +1,47 @@
import _ from "lodash";
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>
);
};

View file

@ -0,0 +1,94 @@
import { Nav, Navbar, Button } from "react-bootstrap";
import { LinkContainer } from 'react-router-bootstrap';
import React from "react";
import { Link, useLocation } from "react-router-dom";
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconName } from '@fortawesome/fontawesome-svg-core';
interface IMenuItem {
text: string;
href: string;
icon: IconName;
}
const menuItems:IMenuItem[] = [
{
icon: "play-circle",
text: "Scenes",
href: "/scenes"
},
{
href: "/scenes/markers",
icon: "map-marker-alt",
text: "Markers"
},
{
href: "/galleries",
icon: "image",
text: "Galleries"
},
{
href: "/performers",
icon: "user",
text: "Performers"
},
{
href: "/studios",
icon: "video",
text: "Studios"
},
{
href: "/tags",
icon: "tag",
text: "Tags"
}
];
export const MainNavbar: React.FC = () => {
const location = useLocation();
const path = location.pathname === '/performers'
? '/performers/new'
: location.pathname === '/studios'
? '/studios/new' : null;
const newButton = path === null ? '' : (
<LinkContainer to={path}>
<Button variant="primary">New</Button>
</LinkContainer>
);
return (
<Navbar fixed="top" variant="dark" bg="dark">
<Navbar.Brand href="#home">
<Link to="/">
<Button variant="secondary">Stash</Button>
</Link>
</Navbar.Brand>
<Nav className="mr-auto">
{menuItems.map((i) => (
<LinkContainer
activeClassName="active"
exact={true}
to={i.href}
>
<Button variant="secondary">
<FontAwesomeIcon icon={i.icon} />
{i.text}
</Button>
</LinkContainer>
))}
</Nav>
<Nav>
{newButton}
<LinkContainer
exact={true}
to="/settings">
<Button variant="secondary">
<FontAwesomeIcon icon="cog" />
</Button>
</LinkContainer>
</Nav>
</Navbar>
);
};

View file

@ -0,0 +1,7 @@
import React, { FunctionComponent } from "react";
export const PageNotFound: FunctionComponent = () => {
return (
<h1>Page not found.</h1>
);
};

View file

@ -0,0 +1,50 @@
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]);
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>
);
};

View file

@ -0,0 +1,57 @@
import {
H1,
H4,
H6,
HTMLTable,
Spinner,
Tag,
} from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import * as GQL from "../../core/generated-graphql";
import { TextUtils } from "../../utils/text";
import { StashService } from "../../core/StashService";
interface IProps {}
export const SettingsAboutPanel: FunctionComponent<IProps> = (props: IProps) => {
const { data, error, loading } = StashService.useVersion();
function maybeRenderTag() {
if (!data || !data.version || !data.version.version) { return; }
return (
<tr>
<td>Version:</td>
<td>{data.version.version}</td>
</tr>
);
}
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>
{!data || loading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
{!!error ? <span>error.message</span> : undefined}
{renderVersion()}
</>
);
};

View file

@ -0,0 +1,292 @@
import {
AnchorButton,
Button,
Divider,
FormGroup,
H1,
H4,
H6,
InputGroup,
Spinner,
Tag,
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 [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 { data, error, loading } = StashService.useConfiguration();
const updateGeneralConfig = StashService.useConfigureGeneral({
stashes,
databasePath,
generatedPath,
maxTranscodeSize,
maxStreamingTranscodeSize,
username,
password,
logFile,
logOut,
logLevel,
logAccess,
excludes,
});
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);
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);
}
}, [data]);
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>
<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>
</>
);
};

View file

@ -0,0 +1,145 @@
import {
Button,
Checkbox,
Divider,
FormGroup,
H4,
Spinner,
TextArea,
NumericInput
} from "@blueprintjs/core";
import _ from "lodash";
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]);
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>
</>
);
};

View file

@ -0,0 +1,188 @@
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>&nbsp;
<span className={levelClass(props.logEntry.level)}>{level}</span>&nbsp;
<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>
</>
);
};

View file

@ -0,0 +1,53 @@
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.queryMetadataGenerate({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>
);
};

View file

@ -0,0 +1,272 @@
import {
Alert,
Button,
Checkbox,
Divider,
FormGroup,
H4,
AnchorButton,
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.queryMetadataImport().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.queryMetadataClean().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.queryMetadataScan({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.queryMetadataAutoTag(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.queryStopJob().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.queryMetadataExport().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>
</>
);
};

View file

@ -0,0 +1,150 @@
import { Button, Form, Modal, Nav, Navbar, OverlayTrigger, Popover } from 'react-bootstrap';
import React, { 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>;
isNew: boolean;
isEditing: boolean;
onToggleEdit: () => void;
onSave: () => void;
onDelete: () => void;
onAutoTag?: () => void;
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
// TODO: only for performers. make generic
scrapers?: GQL.ListPerformerScrapersListPerformerScrapers[];
onDisplayScraperDialog?: (scraper: GQL.ListPerformerScrapersListPerformerScrapers) => void;
}
export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
function renderEditButton() {
if (props.isNew) { return; }
return (
<Button
variant="primary"
onClick={() => props.onToggleEdit()}
>
{ props.isEditing ? "Cancel" : "Edit"}
</Button>
);
}
function renderSaveButton() {
if (!props.isEditing) { return; }
return <Button variant="success" onClick={() => props.onSave()}>Save</Button>;
}
function renderDeleteButton() {
if (props.isNew || props.isEditing) { return; }
return <Button variant="danger" onClick={() => setIsDeleteAlertOpen(true)}>Delete</Button>;
}
function renderImageInput() {
if (!props.isEditing) { return; }
return (
<Form.Group controlId="cover-file">
<Form.Label>Choose image...</Form.Label>
<Form.Control type="file" accept=".jpg,.jpeg,.png" onChange={props.onImageChange} />
</Form.Group>
)
}
function renderScraperMenu() {
if (!props.performer) { return; }
if (!props.isEditing) { return; }
const popover = (
<Popover id="scraper-popover">
<Popover.Content>
<div>
{ props.scrapers ? props.scrapers.map((s) => (
<div onClick={() => props.onDisplayScraperDialog && props.onDisplayScraperDialog(s) }>
{s.name}
</div>
)) : ''}
</div>
</Popover.Content>
</Popover>
);
return (
<OverlayTrigger trigger="click" placement="bottom" overlay={popover}>
<Button>Scrape with...</Button>
</OverlayTrigger>
);
}
function renderAutoTagButton() {
if (props.isNew || props.isEditing) { return; }
if (!!props.onAutoTag) {
return (<Button onClick={() => {
if (props.onAutoTag) { props.onAutoTag() }
}}>Auto Tag</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);
}
return (
<Link to={linkSrc}>
Scenes
</Link>
);
}
function renderDeleteAlert() {
var name;
if (props.performer) {
name = props.performer.name;
}
if (props.studio) {
name = props.studio.name;
}
return (
<Modal
show={isDeleteAlertOpen}
>
<Modal.Body>
Are you sure you want to delete {name}?
</Modal.Body>
<Modal.Footer>
<Button variant="danger" onClick={props.onDelete}>Delete</Button>
<Button variant="secondary" onClick={() => setIsDeleteAlertOpen(false)}>Cancel</Button>
</Modal.Footer>
</Modal>
);
}
return (
<>
{renderDeleteAlert()}
<Navbar bg="dark">
<Nav className="mr-auto ml-auto">
{renderEditButton()}
{renderScraperMenu()}
{renderImageInput()}
{renderSaveButton()}
{renderAutoTagButton()}
{renderScenesButton()}
{renderDeleteButton()}
</Nav>
</Navbar>
</>
);
};

View file

@ -0,0 +1,134 @@
import React, { useState, useEffect } from "react";
import { Button, ButtonGroup, InputGroup, Form } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { TextUtils } from "../../utils/text";
interface IProps {
disabled?: boolean
numericValue: number
onValueChange(valueAsNumber: number): void
onReset?(): void
}
export const DurationInput: React.FC<IProps> = (props: IProps) => {
const [value, setValue] = useState<string>(secondsToString(props.numericValue));
useEffect(() => {
setValue(secondsToString(props.numericValue));
}, [props.numericValue]);
function secondsToString(seconds : number) {
let ret = TextUtils.secondsToTimestamp(seconds);
if (ret.startsWith("00:")) {
ret = ret.substr(3);
if (ret.startsWith("0")) {
ret = ret.substr(1);
}
}
return ret;
}
function stringToSeconds(v : string) {
if (!v) {
return 0;
}
let splits = v.split(":");
if (splits.length > 3) {
return 0;
}
let seconds = 0;
let factor = 1;
while(splits.length > 0) {
let thisSplit = splits.pop();
if (thisSplit === undefined) {
return 0;
}
let thisInt = parseInt(thisSplit, 10);
if (isNaN(thisInt)) {
return 0;
}
seconds += factor * thisInt;
factor *= 60;
}
return seconds;
}
function increment() {
let seconds = stringToSeconds(value);
seconds += 1;
props.onValueChange(seconds);
}
function decrement() {
let seconds = stringToSeconds(value);
seconds -= 1;
props.onValueChange(seconds);
}
function renderButtons() {
return (
<ButtonGroup
vertical={true}
>
<Button
disabled={props.disabled}
onClick={() => increment()}
>
<FontAwesomeIcon icon="chevron-up" />
</Button>
<Button
disabled={props.disabled}
onClick={() => decrement()}
>
<FontAwesomeIcon icon="chevron-down" />
</Button>
</ButtonGroup>
)
}
function onReset() {
if (props.onReset) {
props.onReset();
}
}
function maybeRenderReset() {
if (props.onReset) {
return (
<Button
onClick={() => onReset()}
>
<FontAwesomeIcon icon="clock" />
</Button>
)
}
}
return (
<Form.Group>
<InputGroup>
<Form.Control
disabled={props.disabled}
defaultValue={value}
onChange={(e : any) => setValue(e.target.value)}
onBlur={() => props.onValueChange(stringToSeconds(value))}
placeholder="hh:mm:ss"
>
{renderButtons()}
</Form.Control>
<InputGroup.Append>
{maybeRenderReset()}
</InputGroup.Append>
</InputGroup>
</Form.Group>
)
};

View file

@ -0,0 +1,85 @@
import { Button, InputGroup, Form, Modal, Spinner } from 'react-bootstrap';
import React, { useEffect, useState } from "react";
import { StashService } from "../../../core/StashService";
interface IProps {
directories: string[];
onDirectoriesChanged: (directories: string[]) => void;
}
export const FolderSelect: React.FC<IProps> = (props: IProps) => {
const [currentDirectory, setCurrentDirectory] = useState<string>("");
const [isDisplayingDialog, setIsDisplayingDialog] = useState<boolean>(false);
const [selectedDirectories, setSelectedDirectories] = useState<string[]>([]);
const { data, error, loading } = StashService.useDirectories(currentDirectory);
useEffect(() => {
setSelectedDirectories(props.directories);
}, [props.directories]);
const selectableDirectories:string[] = data && data.directories && !error ? StashService.nullToUndefined(data.directories) : [];
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 (
<Modal
show={isDisplayingDialog}
onHide={() => setIsDisplayingDialog(false)}
title=""
>
<Modal.Header>
Select Directory
</Modal.Header>
<Modal.Body>
<div className="dialog-content">
<InputGroup>
<Form.Control
placeholder="File path"
onChange={(e: any) => setCurrentDirectory(e.target.value)}
defaultValue={currentDirectory}
/>
<InputGroup.Append>
{(!data || !data.directories || loading) ? <Spinner animation="border" variant="light" /> : undefined}
</InputGroup.Append>
</InputGroup>
/>
{selectableDirectories.map((path) => {
return <div key={path} onClick={() => setCurrentDirectory(path)}>{path}</div>;
})}
</div>
</Modal.Body>
<Modal.Footer>
<Button onClick={() => onSelectDirectory()}>Add</Button>
</Modal.Footer>
</Modal>
);
}
return (
<>
{!!error ? <h1>{error.message}</h1> : undefined}
{renderDialog()}
<Form.Group>
{selectedDirectories.map((path) => {
return <div key={path}>{path} <a onClick={() => onRemoveDirectory(path)}>Remove</a></div>;
})}
</Form.Group>
<Button onClick={() => setIsDisplayingDialog(true)}>Add Directory</Button>
</>
);
};

View file

@ -0,0 +1,35 @@
import { Badge } from 'react-bootstrap';
import React from "react";
import { Link } from "react-router-dom";
import { PerformerDataFragment, SceneMarkerDataFragment, TagDataFragment } from "../../core/generated-graphql";
import { NavigationUtils } from "../../utils/navigation";
import { TextUtils } from "../../utils/text";
interface IProps {
tag?: Partial<TagDataFragment>;
performer?: Partial<PerformerDataFragment>;
marker?: Partial<SceneMarkerDataFragment>;
}
export const TagLink: React.FC<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.marker) {
link = NavigationUtils.makeSceneMarkerUrl(props.marker);
title = `${props.marker.title} - ${TextUtils.secondsToTimestamp(props.marker.seconds || 0)}`;
}
return (
<Badge
className="tag-item"
variant="secondary"
>
<Link to={link}>{title}</Link>
</Badge>
);
};

View file

@ -0,0 +1,63 @@
import { Spinner } from 'react-bootstrap';
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">
<div className="level-item has-text-centered">
<div>
<p className="heading">Scenes</p>
<p className="title">{data.stats.scene_count}</p>
</div>
</div>
<div className="level-item has-text-centered">
<div>
<p className="heading">Galleries</p>
<p className="title">{data.stats.gallery_count}</p>
</div>
</div>
<div className="level-item has-text-centered">
<div>
<p className="heading">Performers</p>
<p className="title">{data.stats.performer_count}</p>
</div>
</div>
<div className="level-item has-text-centered">
<div>
<p className="heading">Studios</p>
<p className="title">{data.stats.studio_count}</p>
</div>
</div>
<div className="level-item has-text-centered">
<div>
<p className="heading">Tags</p>
<p className="title">{data.stats.tag_count}</p>
</div>
</div>
</nav>
);
}
return (
<div id="details-container">
{!data || loading ?
<Spinner animation="border" role="status" size="sm">
<span className="sr-only">Loading...</span>
</Spinner> : undefined}
{!!error ? <span>error.message</span> : undefined}
{renderStats()}
<h3>Notes</h3>
<pre>
{`
This is still an early version, some things are still a work in progress.
`}
</pre>
</div>
);
};

View file

@ -0,0 +1,28 @@
import { Card } from 'react-bootstrap';
import React from "react";
import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql";
interface IProps {
studio: GQL.StudioDataFragment;
}
export const StudioCard: React.FC<IProps> = (props: IProps) => {
return (
<Card
className="col-4"
>
<Link
to={`/studios/${props.studio.id}`}
className="studio previewable image"
style={{backgroundImage: `url(${props.studio.image_path})`}}
/>
<div className="card-section">
<h4 className="text-truncate">
{props.studio.name}
</h4>
<span>{props.studio.scene_count} scenes.</span>
</div>
</Card>
);
};

View file

@ -0,0 +1,162 @@
import { Form, Spinner, Table } from 'react-bootstrap';
import React, { useEffect, useState } from "react";
import { useParams, useHistory } from 'react-router-dom';
import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
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";
export const Studio: React.FC = () => {
const { id = '' } = useParams();
const history = useHistory();
const isNew = 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);
const { data, error, loading } = StashService.useFindStudio(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);
}
function updateStudioData(studio:Partial<GQL.StudioDataFragment>) {
setImage(undefined);
updateStudioEditState(studio);
setImagePreview(studio.image_path);
setStudio(studio);
}
useEffect(() => {
if (data && data.findStudio) {
setImage(undefined);
updateStudioEditState(data.findStudio);
setImagePreview(data.findStudio.image_path);
setStudio(data.findStudio);
}
}, [data]);
function onImageLoad(this: FileReader) {
setImagePreview(this.result as string);
setImage(this.result as string);
}
ImageUtils.addPasteImageHook(onImageLoad);
if (!isNew && !isEditing) {
if (!data || !data.findStudio || loading) { return <Spinner animation="border" variant="light" />; }
if (!!error) { return <>error...</>; }
}
function getStudioInput() {
const input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput> = {
name,
url,
image,
};
if (!isNew) {
(input as GQL.StudioUpdateInput).id = id;
}
return input;
}
async function onSave() {
try {
if (!isNew) {
const result = await updateStudio();
updateStudioData(result.data.studioUpdate)
setIsEditing(false);
} else {
const result = await createStudio();
history.push(`/studios/${result.data.studioCreate.id}`);
}
} catch (e) {
ErrorUtils.handle(e);
}
}
async function onAutoTag() {
if (!studio || !studio.id) {
return;
}
try {
await StashService.queryMetadataAutoTag({ studios: [studio.id]});
ToastUtils.success("Started auto tagging");
} catch (e) {
ErrorUtils.handle(e);
}
}
async function onDelete() {
try {
await deleteStudio();
} catch (e) {
ErrorUtils.handle(e);
}
// redirect to studios page
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 className="studio" alt="" 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>
{ !isEditing
? <span>{studio.name}</span>
: <Form.Group controlId="studio-name">
<Form.Label>Name</Form.Label>
<Form.Control
defaultValue={studio.name || ''}
placeholder="Name"
onChange={(event:any) => setName(event.target.value)}
/>
</Form.Group>
}
</h1>
<Table style={{width: "100%"}}>
<tbody>
{TableUtils.renderInputGroup({title: "URL", value: studio.url, isEditing, onChange: (val:string) => setUrl(val)})}
</tbody>
</Table>
</div>
</div>
);
};

View file

@ -0,0 +1,36 @@
import _ from "lodash";
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;
};

View file

@ -0,0 +1,13 @@
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;

View file

@ -0,0 +1,143 @@
import React, { useState } from "react";
import { Button, Form, Modal, Spinner } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql";
import { StashService } from "../../core/StashService";
import { ErrorUtils } from "../../utils/errors";
import { NavigationUtils } from "../../utils/navigation";
import { ToastUtils } from "../../utils/toasts";
export const TagList: React.FC = () => {
// Editing / New state
const [name, setName] = useState('');
const [editingTag, setEditingTag] = useState<Partial<GQL.TagDataFragment> | null>(null);
const [deletingTag, setDeletingTag] = useState<Partial<GQL.TagDataFragment> | null>(null);
const { data, error } = 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);
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(null);
} catch (e) {
ErrorUtils.handle(e);
}
}
async function onAutoTag(tag : GQL.TagDataFragment) {
if (!tag) {
return;
}
try {
await StashService.queryMetadataAutoTag({ tags: [tag.id]});
ToastUtils.success("Started auto tagging");
} catch (e) {
ErrorUtils.handle(e);
}
}
async function onDelete() {
try {
await deleteTag();
ToastUtils.success("Deleted tag");
setDeletingTag(null);
} catch (e) {
ErrorUtils.handle(e);
}
}
const deleteAlert = (
<Modal
onHide={() => {}}
show={!!deletingTag}
>
<Modal.Body>
<FontAwesomeIcon icon="trash-alt" color="danger" />
<span>Are you sure you want to delete {deletingTag && deletingTag.name}?</span>
</Modal.Body>
<Modal.Footer>
<div>
<Button variant="danger" onClick={onDelete}>Delete</Button>
<Button onClick={() => setDeletingTag(null)}>Cancel</Button>
</div>
</Modal.Footer>
</Modal>
);
if (!data || !data.allTags) { return <Spinner animation="border" variant="light" />; }
if (!!error) { return <>{error.message}</>; }
const tagElements = data.allTags.map((tag) => {
return (
<>
{deleteAlert}
<div key={tag.id} className="tag-list-row">
<span onClick={() => setEditingTag(tag)}>{tag.name}</span>
<div style={{float: "right"}}>
<Button onClick={() => onAutoTag(tag)}>Auto Tag</Button>
<Link to={NavigationUtils.makeTagScenesUrl(tag)}>Scenes: {tag.scene_count}</Link>
<Link to={NavigationUtils.makeTagSceneMarkersUrl(tag)}>
Markers: {tag.scene_marker_count}
</Link>
<span>Total: {(tag.scene_count || 0) + (tag.scene_marker_count || 0)}</span>
<Button variant="danger" onClick={() => setDeletingTag(tag)}>
<FontAwesomeIcon icon="trash-alt" color="danger" />
</Button>
</div>
</div>
</>
);
});
return (
<div id="tag-list-container">
<Button variant="primary" style={{marginTop: "20px"}} onClick={() => setEditingTag({})}>New Tag</Button>
<Modal
onHide={() => {setEditingTag(null)}}
show={!!editingTag}
>
<Modal.Header>
{ editingTag && editingTag.id ? "Edit Tag" : "New Tag" }
</Modal.Header>
<Modal.Body>
<Form.Group controlId="tag-name">
<Form.Label>Name</Form.Label>
<Form.Control
onChange={(newValue: any) => setName(newValue.target.value)}
defaultValue={(editingTag && editingTag.name) || ''}
/>
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button onClick={() => onEdit()}>{editingTag && editingTag.id ? "Update" : "Create"}</Button>
</Modal.Footer>
</Modal>
{tagElements}
</div>
);
};

View file

@ -0,0 +1,11 @@
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;

View file

@ -0,0 +1,99 @@
.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;
}

View file

@ -0,0 +1,132 @@
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 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>
);
};

View file

@ -0,0 +1,89 @@
import _ from "lodash";
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();
};

View file

@ -0,0 +1,218 @@
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 } 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 { 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";
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 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" | "tags" | "" = "";
if (criterion instanceof PerformersCriterion) {
type = "performers";
} else if (criterion instanceof StudiosCriterion) {
type = "studios";
} 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 {
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>
</>
);
};

View file

@ -0,0 +1,290 @@
import {
Button,
ButtonGroup,
HTMLSelect,
InputGroup,
Menu,
MenuItem,
Popover,
Tag,
Tooltip,
Slider,
} from "@blueprintjs/core";
import { debounce } from "lodash";
import React, { FunctionComponent, SyntheticEvent, useEffect, 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) => {
let searchCallback: any;
const [editingCriterion, setEditingCriterion] = useState<Criterion | undefined>(undefined);
useEffect(() => {
searchCallback = debounce((event: any) => {
props.onChangeQuery(event.target.value);
}, 500);
});
function onChangePageSize(event: SyntheticEvent<HTMLSelectElement>) {
const val = event!.currentTarget!.value;
props.onChangePageSize(parseInt(val, 10));
}
function onChangeQuery(event: SyntheticEvent<HTMLInputElement>) {
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();
};

View file

@ -0,0 +1,119 @@
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);
if (page < 1) { page = 1; }
if (page > pagerState.totalPages) { page = pagerState.totalPages; }
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,
};
}
}

View file

@ -0,0 +1,45 @@
import React from "react";
import { Card } from 'react-bootstrap';
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: React.FC<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="col-3">
<Link
to={`/performers/${props.performer.id}`}
className="performer previewable image"
style={{backgroundImage: `url(${props.performer.image_path})`}}
>
{maybeRenderFavoriteBanner()}
</Link>
<div className="card-section">
<h4 className="text-truncate">
{props.performer.name}
</h4>
{age !== 0 ? <div>{ageString}</div> : ''}
<span>Stars in {props.performer.scene_count} <Link to={NavigationUtils.makePerformerScenesUrl(props.performer)}>scenes</Link>.
</span>
</div>
</Card>
);
};

View file

@ -0,0 +1,387 @@
import _ from "lodash";
import { Button, Form, Modal, Spinner, Table } from 'react-bootstrap';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { 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 { ScrapePerformerSuggest } from "../../select/ScrapePerformerSuggest";
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
import { ToastUtils } from "../../../utils/toasts";
import { EditableTextUtils } from "../../../utils/editabletext";
import { ImageUtils } from "../../../utils/image";
interface IPerformerProps extends IBaseProps {}
export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) => {
const isNew = props.match.params.id === "new";
// Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew);
const [isDisplayingScraperDialog, setIsDisplayingScraperDialog] = useState<GQL.ListPerformerScrapersListPerformerScrapers | undefined>(undefined);
const [scrapePerformerDetails, setScrapePerformerDetails] = useState<GQL.ScrapePerformerListScrapePerformerList | undefined>(undefined);
// 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);
// Performer state
const [performer, setPerformer] = useState<Partial<GQL.PerformerDataFragment>>({});
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined);
// Network state
const [isLoading, setIsLoading] = useState(false);
const Scrapers = StashService.useListPerformerScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.ListPerformerScrapersListPerformerScrapers[]>([]);
const { data, error, loading } = StashService.useFindPerformer(props.match.params.id);
const updatePerformer = StashService.usePerformerUpdate(getPerformerInput() as GQL.PerformerUpdateInput);
const createPerformer = StashService.usePerformerCreate(getPerformerInput() as GQL.PerformerCreateInput);
const deletePerformer = StashService.usePerformerDestroy(getPerformerInput() as GQL.PerformerDestroyInput);
function updatePerformerEditState(state: Partial<GQL.PerformerDataFragment | 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);
}
useEffect(() => {
setIsLoading(loading);
if (!data || !data.findPerformer || !!error) { return; }
setPerformer(data.findPerformer);
}, [data]);
useEffect(() => {
setImagePreview(performer.image_path);
setImage(undefined);
updatePerformerEditState(performer);
if (!isNew) {
setIsEditing(false);
}
}, [performer]);
function onImageLoad(this: FileReader) {
setImagePreview(this.result as string);
setImage(this.result as string);
}
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 ((!isNew && !isEditing && (!data || !data.findPerformer)) || isLoading) {
return <Spinner animation="border" variant="light" />;
}
if (!!error) { return <>error...</>; }
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,
};
if (!isNew) {
(performerInput as GQL.PerformerUpdateInput).id = props.match.params.id;
}
return performerInput;
}
async function onSave() {
setIsLoading(true);
try {
if (!isNew) {
const result = await updatePerformer();
setPerformer(result.data.performerUpdate);
} else {
const result = await createPerformer();
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();
} catch (e) {
ErrorUtils.handle(e);
}
setIsLoading(false);
// redirect to performers page
props.history.push(`/performers`);
}
async function onAutoTag() {
if (!performer || !performer.id) {
return;
}
try {
await StashService.queryMetadataAutoTag({ performers: [performer.id]});
ToastUtils.success("Started auto tagging");
} catch (e) {
ErrorUtils.handle(e);
}
}
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;
return ret as GQL.ScrapedPerformerInput;
}
async function onScrapePerformer() {
setIsDisplayingScraperDialog(undefined);
setIsLoading(true);
try {
if (!scrapePerformerDetails || !isDisplayingScraperDialog) { return; }
const result = await StashService.queryScrapePerformer(isDisplayingScraperDialog.id, getQueryScraperPerformerInput());
if (!result.data || !result.data.scrapePerformer) { return; }
updatePerformerEditState(result.data.scrapePerformer);
} catch (e) {
ErrorUtils.handle(e);
}
setIsLoading(false);
}
async function onScrapePerformerURL() {
if (!url) { return; }
setIsLoading(true);
try {
const result = await StashService.queryScrapePerformerURL(url);
if (!result.data || !result.data.scrapePerformerURL) { return; }
updatePerformerEditState(result.data.scrapePerformerURL);
} catch (e) {
ErrorUtils.handle(e);
} finally {
setIsLoading(false);
}
}
function renderEthnicity() {
return TableUtils.renderHtmlSelect({
title: "Ethnicity",
value: ethnicity,
isEditing,
onChange: (value: string) => setEthnicity(value),
selectOptions: ["white", "black", "asian", "hispanic"],
});
}
function renderScraperDialog() {
return (
<Modal
show={!!isDisplayingScraperDialog}
onHide={() => setIsDisplayingScraperDialog(undefined)}
>
<Modal.Header>
Scrape
</Modal.Header>
<Modal.Body>
<div className="dialog-content">
<ScrapePerformerSuggest
placeholder="Performer name"
style={{width: "100%"}}
scraperId={isDisplayingScraperDialog ? isDisplayingScraperDialog.id : ""}
onSelectPerformer={(query) => setScrapePerformerDetails(query)}
/>
</div>
</Modal.Body>
<Modal.Footer>
<Button onClick={() => onScrapePerformer()}>Scrape</Button>
</Modal.Footer>
</Modal>
);
}
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 || !isEditing || !urlScrapable(url)) {
return undefined;
}
return (
<Button
id="scrape-url-button"
onClick={() => onScrapePerformerURL()}>
<FontAwesomeIcon icon="file-upload" />
</Button>
)
}
function renderURLField() {
return (
<tr>
<td id="url-field">
URL
{maybeRenderScrapeButton()}
</td>
<td>
{EditableTextUtils.renderInputGroup({
value: url, isEditing, onChange: setUrl, placeholder: "URL"
})}
</td>
</tr>
);
}
return (
<>
{renderScraperDialog()}
<div className="row is-multiline no-spacing">
<div className="col-6 details-image-container">
<img className="performer" alt="" src={imagePreview} />
</div>
<div className="col-6 details-detail-container">
<DetailsEditNavbar
performer={performer}
isNew={isNew}
isEditing={isEditing}
onToggleEdit={() => { setIsEditing(!isEditing); updatePerformerEditState(performer); }}
onSave={onSave}
onDelete={onDelete}
onImageChange={onImageChange}
scrapers={queryableScrapers}
onDisplayScraperDialog={onDisplayFreeOnesDialog}
onAutoTag={onAutoTag}
/>
<h1>
{ !isEditing
? <span>{name}</span>
: <Form.Control
defaultValue={name}
placeholder="Name"
onChange={(event: any) => setName(event.target.value)} />
}
</h1>
<h6>
<Form.Group className="aliases-field" controlId="aliases">
<Form.Label>Aliases:</Form.Label>
{EditableTextUtils.renderInputGroup({
value: aliases, isEditing: isEditing, placeholder: "Aliases", onChange: setAliases
})}
</Form.Group>
</h6>
<div>
<span style={{fontWeight: 300}}>Favorite:</span>
<Button
disabled={!isEditing}
className={favorite ? "favorite" : undefined}
onClick={() => setFavorite(!favorite)}
>
<FontAwesomeIcon icon="heart" />
</Button>
</div>
<Table id="performer-details" style={{width: "100%"}}>
<tbody>
{TableUtils.renderInputGroup(
{title: "Birthdate (YYYY-MM-DD)", value: birthdate, isEditing, onChange: setBirthdate})}
{renderEthnicity()}
{TableUtils.renderInputGroup(
{title: "Eye Color", value: eyeColor, isEditing, onChange: setEyeColor})}
{TableUtils.renderInputGroup(
{title: "Country", value: country, isEditing, onChange: setCountry})}
{TableUtils.renderInputGroup(
{title: "Height (CM)", value: height, isEditing, onChange: setHeight})}
{TableUtils.renderInputGroup(
{title: "Measurements", value: measurements, isEditing, onChange: setMeasurements})}
{TableUtils.renderInputGroup(
{title: "Fake Tits", value: fakeTits, isEditing, onChange: setFakeTits})}
{TableUtils.renderInputGroup(
{title: "Career Length", value: careerLength, isEditing, onChange: setCareerLength})}
{TableUtils.renderInputGroup(
{title: "Tattoos", value: tattoos, isEditing, onChange: setTattoos})}
{TableUtils.renderInputGroup(
{title: "Piercings", value: piercings, isEditing, onChange: setPiercings})}
{renderURLField()}
{TableUtils.renderInputGroup(
{title: "Twitter", value: twitter, isEditing, onChange: setTwitter})}
{TableUtils.renderInputGroup(
{title: "Instagram", value: instagram, isEditing, onChange: setInstagram})}
</tbody>
</Table>
</div>
</div>
</>
);
};

View file

@ -0,0 +1,62 @@
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;
};

View file

@ -0,0 +1,100 @@
import React from "react";
import { Button, Table } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
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: React.FC<IPerformerListTableProps> = (props: IPerformerListTableProps) => {
function maybeRenderFavoriteHeart(performer : GQL.PerformerDataFragment) {
if (!performer.favorite) { return; }
return (
<Button disabled className="favorite">
<FontAwesomeIcon icon="heart" />
</Button>
);
}
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 className="text-truncate">
{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">
<Table bordered striped>
<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>
</Table>
</div>
</>
);
};

View file

@ -0,0 +1,13 @@
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;

View file

@ -0,0 +1,241 @@
import { Button, ButtonGroup, Card, Form, Popover, OverlayTrigger } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import React, { 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";
interface ISceneCardProps {
scene: GQL.SlimSceneDataFragment;
selected: boolean | undefined;
zoomIndex: number;
onSelectedChanged: (selected : boolean, shiftKey : boolean) => void;
}
export const SceneCard: React.FC<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 popover = (
<Popover id="tag-popover">
{ props.scene.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} />
)) }
</Popover>
);
return (
<OverlayTrigger trigger="hover" placement="bottom" overlay={popover}>
<Button>
<FontAwesomeIcon icon="tag" />
{props.scene.tags.length}
</Button>
</OverlayTrigger>
);
}
function maybeRenderPerformerPopoverButton() {
if (props.scene.performers.length <= 0) { return; }
const popover = (
<Popover id="performer-popover">
{
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>
);
})
}
</Popover>
);
return (
<OverlayTrigger trigger="hover" placement="bottom" overlay={popover}>
<Button>
<FontAwesomeIcon icon="user" />
{props.scene.performers.length}
</Button>
</OverlayTrigger>
);
}
function maybeRenderSceneMarkerPopoverButton() {
if (props.scene.scene_markers.length <= 0)
return;
const popover = (
<Popover id="marker-popover">
{ 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} />;
}) }
</Popover>
);
return (
<OverlayTrigger trigger="hover" placement="bottom" overlay={popover}>
<Button>
<FontAwesomeIcon icon="tag" />
{props.scene.scene_markers.length}
</Button>
</OverlayTrigger>
);
}
function maybeRenderPopoverButtonGroup() {
if (props.scene.tags.length > 0 ||
props.scene.performers.length > 0 ||
props.scene.scene_markers.length > 0) {
return (
<>
<hr />
<ButtonGroup className="mr-2">
{maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()}
{maybeRenderSceneMarkerPopoverButton()}
</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={"col-4 " + ZoomUtils.classForZoom(props.zoomIndex)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<Form.Control
type="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 className="text-truncate">
{!!props.scene.title ? props.scene.title : TextUtils.fileNameFromPath(props.scene.path)}
</h4>
<span>{props.scene.date}</span>
<p>{TextUtils.truncate(props.scene.details, 100, "... (continued)")}</p>
</div>
{maybeRenderPopoverButtonGroup()}
</Card>
);
};

View file

@ -0,0 +1,95 @@
import { Card, Spinner, Tab, Tabs } from 'react-bootstrap';
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";
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 } = StashService.useFindScene(props.match.params.id);
useEffect(() => {
setIsLoading(loading);
if (!data || !data.findScene || !!error) { return; }
setScene(StashService.nullToUndefined(data.findScene));
}, [data]);
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");
}
});
function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
setTimestamp(marker.seconds);
}
if (!data || !data.findScene || isLoading || Object.keys(scene).length === 0) {
return <Spinner animation="border"/>;
}
const modifiedScene =
Object.assign({scene_marker_tags: data.sceneMarkerTags}, scene) as GQL.SceneDataFragment; // TODO Hack from angular
if (!!error) { return <>error...</>; }
return (
<>
<ScenePlayer scene={modifiedScene} timestamp={timestamp} autoplay={autoplay}/>
<Card id="details-container">
<Tabs id="scene-tabs" mountOnEnter={true}>
<Tab eventKey="scene-details-panel" title="Details">
<SceneDetailPanel scene={modifiedScene} />
</Tab>
<Tab
eventKey="scene-markers-panel"
title="Markers">
<SceneMarkersPanel scene={modifiedScene} onClickMarker={onClickMarker} />
</Tab>
{modifiedScene.performers.length > 0 ?
<Tab
eventKey="scene-performer-panel"
title="Performers">
<ScenePerformerPanel scene={modifiedScene} />
</Tab> : ''
}
{!!modifiedScene.gallery ?
<Tab
eventKey="scene-gallery-panel"
title="Gallery">
<GalleryViewer gallery={modifiedScene.gallery} />
</Tab> : ''
}
<Tab eventKey="scene-file-info-panel" title="File Info">
<SceneFileInfoPanel scene={modifiedScene} />
</Tab>
<Tab
eventKey="scene-edit-panel"
title="Edit">
<SceneEditPanel
scene={modifiedScene}
onUpdate={(newScene) => setScene(newScene)}
onDelete={() => props.history.push("/scenes")}
/>
</Tab>
</Tabs>
</Card>
</>
);
};

View file

@ -0,0 +1,48 @@
import React 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: React.FC<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)}
<h1>
{!!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()}
</>
);
};

View file

@ -0,0 +1,393 @@
import React, { 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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Collapse, Dropdown, DropdownButton, Form, Button, Modal, Spinner } from 'react-bootstrap';
interface IProps {
scene: GQL.SceneDataFragment;
onUpdate: (scene: GQL.SceneDataFragment) => void;
onDelete: () => void;
}
export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
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 [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 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);
setPerformerIds(perfIds);
setTagIds(tIds);
}
useEffect(() => {
updateSceneEditState(props.scene);
setCoverImagePreview(props.scene.paths.screenshot);
}, [props.scene]);
ImageUtils.addPasteImageHook(onImageLoad);
function getSceneInput(): GQL.SceneUpdateInput {
return {
id: props.scene.id,
title,
details,
url,
date,
rating,
gallery_id: galleryId,
studio_id: studioId,
performer_ids: performerIds,
tag_ids: tagIds,
cover_image: coverImage,
};
}
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" | "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 "tags": setTagIds(ids); break;
}
}}
initialIds={initialIds}
/>
);
}
function renderDeleteAlert() {
return (
<Modal
keyboard={false}
onHide={() => {}}
show={isDeleteAlertOpen}
>
<Modal.Header>
<FontAwesomeIcon icon="trash-alt" />
<span>Delete Scene?</span>
</Modal.Header>
<Modal.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>
<Form>
<Form.Check checked={deleteFile} label="Delete file" onChange={() => setDeleteFile(!deleteFile)} />
<Form.Check checked={deleteGenerated} label="Delete generated supporting files" onChange={() => setDeleteGenerated(!deleteGenerated)} />
</Form>
</Modal.Body>
<Modal.Footer>
<div>
<Button variant="danger" onClick={onDelete}>Delete</Button>
<Button onClick={() => setIsDeleteAlertOpen(false)}>Cancel</Button>
</div>
</Modal.Footer>
</Modal>
);
}
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 renderScraperMenu() {
if (!queryableScrapers || queryableScrapers.length === 0) {
return;
}
return (
<DropdownButton id="scene-scrape" title="Scrape with...">
{ queryableScrapers.map(s => (
<Dropdown.Item onClick={() => onScrapeClicked(s)}>{s.name}</Dropdown.Item>
))
}
</DropdownButton>
);
}
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 ((!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[]);
}
}
}
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
id="scrape-url-button"
onClick={onScrapeSceneURL}>
<FontAwesomeIcon icon="file-download" />
</Button>
)
}
return (
<>
{renderDeleteAlert()}
{isLoading ? <Spinner animation="border" variant="light" /> : undefined}
<div className="form-container " style={{width: "50%"}}>
<Form.Group controlId="title">
<Form.Label>Title</Form.Label>
<Form.Control
onChange={(newValue: any) => setTitle(newValue.target.value)}
value={title}
/>
</Form.Group>
<Form.Group controlId="details">
<Form.Label>Details</Form.Label>
<Form.Control
as="textarea"
onChange={(newValue: any) => setDetails(newValue.target.value)}
value={details}
/>
</Form.Group>
<Form.Group controlId="url">
<Form.Label>URL</Form.Label>
<Form.Control
onChange={(newValue: any) => setUrl(newValue.target.value)}
value={url}
/>
{maybeRenderScrapeButton()}
</Form.Group>
<Form.Group controlId="date">
<Form.Label>Date</Form.Label>
<Form.Control
onChange={(newValue: any) => setDate(newValue.target.value)}
value={date}
/>
<div>YYYY-MM-DD</div>
</Form.Group>
<Form.Group controlId="rating">
<Form.Label>Rating</Form.Label>
<Form.Control
as="select"
onChange={(event: any) => setRating(parseInt(event.target.value, 10))}>
{ ["", 1, 2, 3, 4, 5].map(opt => (
<option selected={opt === rating} value={opt}>{opt}</option>
)) }
</Form.Control>
</Form.Group>
<Form.Group controlId="gallery">
<Form.Label>Gallery</Form.Label>
<ValidGalleriesSelect
sceneId={props.scene.id}
initialId={galleryId}
onSelectItem={(item) => setGalleryId(item ? item.id : undefined)}
/>
</Form.Group>
<Form.Group controlId="studio">
<Form.Label>Studio</Form.Label>
<FilterSelect
type="studios"
onSelectItem={(item) => setStudioId(item ? item.id : undefined)}
initialId={studioId}
/>
</Form.Group>
<Form.Group controlId="performers">
<Form.Label>Performers</Form.Label>
{renderMultiSelect("performers", performerIds)}
</Form.Group>
<Form.Group controlId="tags">
<Form.Label>Tags</Form.Label>
{renderMultiSelect("tags", tagIds)}
</Form.Group>
<div>
<label onClick={() => setIsCoverImageOpen(!isCoverImageOpen)}>
<FontAwesomeIcon icon={isCoverImageOpen ? "chevron-down" : "chevron-right"} />
<span>Cover Image</span>
</label>
<Collapse in={isCoverImageOpen}>
<div>
<img className="scene-cover" src={coverImagePreview} alt="" />
<Form.Group className="test" controlId="cover">
<Form.Control type="file" onChange={onCoverImageChange} accept=".jpg,.jpeg,.png" />
</Form.Group>
</div>
</Collapse>
</div>
</div>
<Button className="edit-button" variant="primary" onClick={onSave}>Save</Button>
<Button className="edit-button" variant="danger" onClick={() => setIsDeleteAlertOpen(true)}>Delete</Button>
{renderScraperMenu()}
</>
);
};

View file

@ -0,0 +1,137 @@
import { Table } from 'react-bootstrap';
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 (
<>
<Table>
<tbody>
{renderChecksum()}
{renderPath()}
{renderStream()}
{renderFileSize()}
{renderDuration()}
{renderDimensions()}
{renderFrameRate()}
{renderBitRate()}
{renderVideoCodec()}
{renderAudioCodec()}
{renderUrl()}
</tbody>
</Table>
</>
);
};

View file

@ -0,0 +1,267 @@
import { Badge, Button, Card, Collapse, Form as BootstrapForm } from 'react-bootstrap';
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) => (
<Badge key={tag.id} variant="secondary" className="tag-item">{tag.name}</Badge>
));
return (
<div key={marker.id}>
<hr />
<div>
<a onClick={() => onClickMarker(marker)}>{marker.title}</a>
{!isEditorOpen ? <a style={{float: "right"}} onClick={() => onOpenEditor(marker)}>Edit</a> : 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
variant="danger"
style={{float: "right", marginRight: "10px"}}
onClick={() => onDelete()}
>
Delete
</Button>
);
}
return (
<Form style={{marginTop: "10px"}}>
<div className="columns is-multiline is-gapless">
<BootstrapForm.Group>
<BootstrapForm.Label htmlFor="title">Scene Marker Title</BootstrapForm.Label>
<Field name="title" render={renderTitleField} />
</BootstrapForm.Group>
<BootstrapForm.Group>
<BootstrapForm.Label htmlFor="seconds">Time</BootstrapForm.Label>
<Field name="seconds" render={renderSecondsField} />
</BootstrapForm.Group>
<BootstrapForm.Group>
<BootstrapForm.Label htmlFor="primaryTagId">Primary Tag</BootstrapForm.Label>
<Field name="primaryTagId" render={renderPrimaryTagField} />
</BootstrapForm.Group>
<BootstrapForm.Group>
<BootstrapForm.Label htmlFor="tagIds">Tags</BootstrapForm.Label>
<Field name="tagIds" render={renderTagsField} />
</BootstrapForm.Group>
</div>
<div className="buttons-container">
<Button variant="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 in={isEditorOpen}>
<div className="">
<Formik
initialValues={initialValues}
onSubmit={onSubmit}
render={renderFormFields}
/>
</div>
</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();
};

View file

@ -0,0 +1,21 @@
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>
</>
);
};

View file

@ -0,0 +1,982 @@
import { Badge, Button, Card, Collapse, Dropdown, DropdownButton, Form, Table, Spinner } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import React, { useEffect, useState } from "react";
import { StashService } from "../../core/StashService";
import * as GQL from "../../core/generated-graphql";
import { SlimSceneDataFragment, Maybe } from "../../core/generated-graphql";
import { TextUtils } from "../../utils/text";
import _ from "lodash";
import { ToastUtils } from "../../utils/toasts";
import { ErrorUtils } from "../../utils/errors";
import { Pagination } from "../list/Pagination";
import { FilterMultiSelect } from "../select/FilterMultiSelect";
import { FilterSelect } from "../select/FilterSelect";
class ParserResult<T> {
public value: Maybe<T>;
public originalValue: Maybe<T>;
public set: boolean = false;
public setOriginalValue(v : Maybe<T>) {
this.originalValue = v;
this.value = v;
}
public setValue(v : Maybe<T>) {
if (!!v) {
this.value = v;
this.set = !_.isEqual(this.value, this.originalValue);
}
}
}
class ParserField {
public field : string;
public helperText? : string;
constructor(field: string, helperText?: string) {
this.field = field;
this.helperText = helperText;
}
public getFieldPattern() {
return "{" + this.field + "}";
}
static Title = new ParserField("title");
static Ext = new ParserField("ext", "File extension");
static I = new ParserField("i", "Matches any ignored word");
static D = new ParserField("d", "Matches any delimiter (.-_)");
static Performer = new ParserField("performer");
static Studio = new ParserField("studio");
static Tag = new ParserField("tag");
// date fields
static Date = new ParserField("date", "YYYY-MM-DD");
static YYYY = new ParserField("yyyy", "Year");
static YY = new ParserField("yy", "Year (20YY)");
static MM = new ParserField("mm", "Two digit month");
static DD = new ParserField("dd", "Two digit date");
static YYYYMMDD = new ParserField("yyyymmdd");
static YYMMDD = new ParserField("yymmdd");
static DDMMYYYY = new ParserField("ddmmyyyy");
static DDMMYY = new ParserField("ddmmyy");
static MMDDYYYY = new ParserField("mmddyyyy");
static MMDDYY = new ParserField("mmddyy");
static validFields = [
ParserField.Title,
ParserField.Ext,
ParserField.D,
ParserField.I,
ParserField.Performer,
ParserField.Studio,
ParserField.Tag,
ParserField.Date,
ParserField.YYYY,
ParserField.YY,
ParserField.MM,
ParserField.DD,
ParserField.YYYYMMDD,
ParserField.YYMMDD,
ParserField.DDMMYYYY,
ParserField.DDMMYY,
ParserField.MMDDYYYY,
ParserField.MMDDYY
]
static fullDateFields = [
ParserField.YYYYMMDD,
ParserField.YYMMDD,
ParserField.DDMMYYYY,
ParserField.DDMMYY,
ParserField.MMDDYYYY,
ParserField.MMDDYY
];
}
class SceneParserResult {
public id: string;
public filename: string;
public title: ParserResult<string> = new ParserResult();
public date: ParserResult<string> = new ParserResult();
public studio: ParserResult<GQL.SlimSceneDataStudio> = new ParserResult();
public studioId: ParserResult<string> = new ParserResult();
public tags: ParserResult<GQL.SlimSceneDataTags[]> = new ParserResult();
public tagIds: ParserResult<string[]> = new ParserResult();
public performers: ParserResult<GQL.SlimSceneDataPerformers[]> = new ParserResult();
public performerIds: ParserResult<string[]> = new ParserResult();
public scene : SlimSceneDataFragment;
constructor(result : GQL.ParseSceneFilenamesResults) {
this.scene = result.scene;
this.id = this.scene.id;
this.filename = TextUtils.fileNameFromPath(this.scene.path);
this.title.setOriginalValue(this.scene.title);
this.date.setOriginalValue(this.scene.date);
this.performerIds.setOriginalValue(this.scene.performers.map((p) => p.id));
this.performers.setOriginalValue(this.scene.performers);
this.tagIds.setOriginalValue(this.scene.tags.map((t) => t.id));
this.tags.setOriginalValue(this.scene.tags);
this.studioId.setOriginalValue(this.scene.studio ? this.scene.studio.id : undefined);
this.studio.setOriginalValue(this.scene.studio);
this.title.setValue(result.title);
this.date.setValue(result.date);
this.performerIds.setValue(result.performer_ids);
this.tagIds.setValue(result.tag_ids);
this.studioId.setValue(result.studio_id);
if (result.performer_ids) {
this.performers.setValue(result.performer_ids.map((p) => {
return {
id: p,
name: "",
favorite: false,
image_path: ""
};
}));
}
if (result.tag_ids) {
this.tags.setValue(result.tag_ids.map((t) => {
return {
id: t,
name: "",
};
}));
}
if (result.studio_id) {
this.studio.setValue({
id: result.studio_id,
name: "",
image_path: ""
});
}
}
private static setInput(object: any, key: string, parserResult : ParserResult<any>) {
if (parserResult.set) {
object[key] = parserResult.value;
}
}
// returns true if any of its fields have set == true
public isChanged() {
return this.title.set || this.date.set || this.performerIds.set || this.studioId.set || this.tagIds.set;
}
public toSceneUpdateInput() {
var ret = {
id: this.id,
title: this.scene.title,
details: this.scene.details,
url: this.scene.url,
date: this.scene.date,
rating: this.scene.rating,
gallery_id: this.scene.gallery ? this.scene.gallery.id : undefined,
studio_id: this.scene.studio ? this.scene.studio.id : undefined,
performer_ids: this.scene.performers.map((performer) => performer.id),
tag_ids: this.scene.tags.map((tag) => tag.id)
};
SceneParserResult.setInput(ret, "title", this.title);
SceneParserResult.setInput(ret, "date", this.date);
SceneParserResult.setInput(ret, "performer_ids", this.performerIds);
SceneParserResult.setInput(ret, "studio_id", this.studioId);
SceneParserResult.setInput(ret, "tag_ids", this.tagIds);
return ret;
}
};
interface IParserInput {
pattern: string,
ignoreWords: string[],
whitespaceCharacters: string,
capitalizeTitle: boolean,
page: number,
pageSize: number,
findClicked: boolean
}
interface IParserRecipe {
pattern: string,
ignoreWords: string[],
whitespaceCharacters: string,
capitalizeTitle: boolean,
description: string
}
const builtInRecipes = [
{
pattern: "{title}",
ignoreWords: [],
whitespaceCharacters: "",
capitalizeTitle: false,
description: "Filename"
},
{
pattern: "{title}.{ext}",
ignoreWords: [],
whitespaceCharacters: "",
capitalizeTitle: false,
description: "Without extension"
},
{
pattern: "{}.{yy}.{mm}.{dd}.{title}.XXX.{}.{ext}",
ignoreWords: [],
whitespaceCharacters: ".",
capitalizeTitle: true,
description: ""
},
{
pattern: "{}.{yy}.{mm}.{dd}.{title}.{ext}",
ignoreWords: [],
whitespaceCharacters: ".",
capitalizeTitle: true,
description: ""
},
{
pattern: "{title}.XXX.{}.{ext}",
ignoreWords: [],
whitespaceCharacters: ".",
capitalizeTitle: true,
description: ""
},
{
pattern: "{}.{yy}.{mm}.{dd}.{title}.{i}.{ext}",
ignoreWords: ["cz", "fr"],
whitespaceCharacters: ".",
capitalizeTitle: true,
description: "Foreign language"
}
];
export const SceneFilenameParser: React.FC = () => {
const [parserResult, setParserResult] = useState<SceneParserResult[]>([]);
const [parserInput, setParserInput] = useState<IParserInput>(initialParserInput());
const [allTitleSet, setAllTitleSet] = useState<boolean>(false);
const [allDateSet, setAllDateSet] = useState<boolean>(false);
const [allPerformerSet, setAllPerformerSet] = useState<boolean>(false);
const [allTagSet, setAllTagSet] = useState<boolean>(false);
const [allStudioSet, setAllStudioSet] = useState<boolean>(false);
const [showFields, setShowFields] = useState<Map<string, boolean>>(initialShowFieldsState());
const [totalItems, setTotalItems] = useState<number>(0);
// Network state
const [isLoading, setIsLoading] = useState(false);
const updateScenes = StashService.useScenesUpdate(getScenesUpdateData());
function initialParserInput() {
return {
pattern: "{title}.{ext}",
ignoreWords: [],
whitespaceCharacters: "._",
capitalizeTitle: true,
page: 1,
pageSize: 20,
findClicked: false
};
}
function initialShowFieldsState() {
return new Map<string, boolean>([
["Title", true],
["Date", true],
["Performers", true],
["Tags", true],
["Studio", true]
]);
}
function getParserFilter() {
return {
q: parserInput.pattern,
page: parserInput.page,
per_page: parserInput.pageSize,
sort: "path",
direction: GQL.SortDirectionEnum.Asc,
};
}
function getParserInput() {
return {
ignoreWords: parserInput.ignoreWords,
whitespaceCharacters: parserInput.whitespaceCharacters,
capitalizeTitle: parserInput.capitalizeTitle
};
}
async function onFind() {
setParserResult([]);
setIsLoading(true);
try {
const response = await StashService.queryParseSceneFilenames(getParserFilter(), getParserInput());
let result = response.data.parseSceneFilenames;
if (!!result) {
parseResults(result.results);
setTotalItems(result.count);
}
} catch (err) {
ErrorUtils.handle(err);
}
setIsLoading(false);
}
useEffect(() => {
if(parserInput.findClicked) {
onFind();
}
}, [parserInput]);
function onPageSizeChanged(newSize : number) {
var newInput = _.clone(parserInput);
newInput.page = 1;
newInput.pageSize = newSize;
setParserInput(newInput);
}
function onPageChanged(newPage : number) {
if (newPage !== parserInput.page) {
var newInput = _.clone(parserInput);
newInput.page = newPage;
setParserInput(newInput);
}
}
function onFindClicked(input : IParserInput) {
input.page = 1;
input.findClicked = true;
setParserInput(input);
setTotalItems(0);
}
function getScenesUpdateData() {
return parserResult.filter((result) => result.isChanged()).map((result) => result.toSceneUpdateInput());
}
async function onApply() {
setIsLoading(true);
try {
await updateScenes();
ToastUtils.success("Updated scenes");
} catch (e) {
ErrorUtils.handle(e);
}
setIsLoading(false);
}
function parseResults(results : GQL.ParseSceneFilenamesResults[]) {
if (results) {
var result = results.map((r) => {
return new SceneParserResult(r);
}).filter((r) => !!r) as SceneParserResult[];
setParserResult(result);
determineFieldsToHide();
}
}
function determineFieldsToHide() {
var pattern = parserInput.pattern;
var titleSet = pattern.includes("{title}");
var dateSet = pattern.includes("{date}") ||
pattern.includes("{dd}") || // don't worry about other partial date fields since this should be implied
ParserField.fullDateFields.some((f) => {
return pattern.includes("{" + f.field + "}");
});
var performerSet = pattern.includes("{performer}");
var tagSet = pattern.includes("{tag}");
var studioSet = pattern.includes("{studio}");
var showFieldsCopy = _.clone(showFields);
showFieldsCopy.set("Title", titleSet);
showFieldsCopy.set("Date", dateSet);
showFieldsCopy.set("Performers", performerSet);
showFieldsCopy.set("Tags", tagSet);
showFieldsCopy.set("Studio", studioSet);
setShowFields(showFieldsCopy);
}
useEffect(() => {
var newAllTitleSet = !parserResult.some((r) => {
return !r.title.set;
});
var newAllDateSet = !parserResult.some((r) => {
return !r.date.set;
});
var newAllPerformerSet = !parserResult.some((r) => {
return !r.performerIds.set;
});
var newAllTagSet = !parserResult.some((r) => {
return !r.tagIds.set;
});
var newAllStudioSet = !parserResult.some((r) => {
return !r.studioId.set;
});
if (newAllTitleSet != allTitleSet) {
setAllTitleSet(newAllTitleSet);
}
if (newAllDateSet != allDateSet) {
setAllDateSet(newAllDateSet);
}
if (newAllPerformerSet != allPerformerSet) {
setAllTagSet(newAllPerformerSet);
}
if (newAllTagSet != allTagSet) {
setAllTagSet(newAllTagSet);
}
if (newAllStudioSet != allStudioSet) {
setAllStudioSet(newAllStudioSet);
}
}, [parserResult]);
function onSelectAllTitleSet(selected : boolean) {
var newResult = [...parserResult];
newResult.forEach((r) => {
r.title.set = selected;
});
setParserResult(newResult);
setAllTitleSet(selected);
}
function onSelectAllDateSet(selected : boolean) {
var newResult = [...parserResult];
newResult.forEach((r) => {
r.date.set = selected;
});
setParserResult(newResult);
setAllDateSet(selected);
}
function onSelectAllPerformerSet(selected : boolean) {
var newResult = [...parserResult];
newResult.forEach((r) => {
r.performerIds.set = selected;
});
setParserResult(newResult);
setAllPerformerSet(selected);
}
function onSelectAllTagSet(selected : boolean) {
var newResult = [...parserResult];
newResult.forEach((r) => {
r.tagIds.set = selected;
});
setParserResult(newResult);
setAllTagSet(selected);
}
function onSelectAllStudioSet(selected : boolean) {
var newResult = [...parserResult];
newResult.forEach((r) => {
r.studioId.set = selected;
});
setParserResult(newResult);
setAllStudioSet(selected);
}
interface IShowFieldsProps {
fields: Map<string, boolean>
onShowFieldsChanged: (fields : Map<string, boolean>) => void
}
function ShowFields(props: IShowFieldsProps) {
const [open, setOpen] = useState(false);
function handleClick(label: string) {
const copy = new Map<string, boolean>(props.fields);
copy.set(label, !props.fields.get(label));
props.onShowFieldsChanged(copy);
}
const fieldRows = [...props.fields.entries()].map(([label, enabled]) => (
<div key={label} onClick={() => {handleClick(label)}}>
<FontAwesomeIcon icon={enabled ? "check" : "times" } />
<span>{label}</span>
</div>
));
return (
<div>
<div onClick={() => setOpen(!open)}>
<FontAwesomeIcon icon={open ? "chevron-down" : "chevron-right" } />
<span>Display fields</span>
</div>
<Collapse in={open}>
<div>
{fieldRows}
</div>
</Collapse>
</div>
);
}
interface IParserInputProps {
input: IParserInput,
onFind: (input : IParserInput) => void
}
function ParserInput(props : IParserInputProps) {
const [pattern, setPattern] = useState<string>(props.input.pattern);
const [ignoreWords, setIgnoreWords] = useState<string>(props.input.ignoreWords.join(" "));
const [whitespaceCharacters, setWhitespaceCharacters] = useState<string>(props.input.whitespaceCharacters);
const [capitalizeTitle, setCapitalizeTitle] = useState<boolean>(props.input.capitalizeTitle);
function onFind() {
props.onFind({
pattern: pattern,
ignoreWords: ignoreWords.split(" "),
whitespaceCharacters: whitespaceCharacters,
capitalizeTitle: capitalizeTitle,
page: 1,
pageSize: props.input.pageSize,
findClicked: props.input.findClicked
});
}
function setParserRecipe(recipe: IParserRecipe) {
setPattern(recipe.pattern);
setIgnoreWords(recipe.ignoreWords.join(" "));
setWhitespaceCharacters(recipe.whitespaceCharacters);
setCapitalizeTitle(recipe.capitalizeTitle);
}
const validFields = [new ParserField("", "Wildcard")].concat(ParserField.validFields);
function addParserField(field: ParserField) {
setPattern(pattern + field.getFieldPattern());
}
const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120"];
return (
<Form.Group>
<Form.Group>
<Form.Control
onChange={(newValue: any) => setPattern(newValue.target.value)}
value={pattern}
/>
<DropdownButton id="parser-field-select" title="Add Field">
{ validFields.map(item => (
<Dropdown.Item onSelect={() => addParserField(item)}>
<span>{item.field}</span><span className="ml-auto">{item.helperText}</span>
</Dropdown.Item>
))}
</DropdownButton>
<div>Use '\\' to escape literal {} characters</div>
</Form.Group>
<Form.Group>
<Form.Label>Ignored words::</Form.Label>
<Form.Control
onChange={(newValue: any) => setIgnoreWords(newValue.target.value)}
value={ignoreWords}
/>
<div>Matches with {"{i}"}</div>
</Form.Group>
<Form.Group>
<h5>Title</h5>
<Form.Label>Whitespace characters:</Form.Label>
<Form.Control
onChange={(newValue: any) => setWhitespaceCharacters(newValue.target.value)}
value={whitespaceCharacters}
/>
<Form.Group>
<Form.Label>Capitalize title</Form.Label>
<Form.Control
type="checkbox"
checked={capitalizeTitle}
onChange={() => setCapitalizeTitle(!capitalizeTitle)}
/>
</Form.Group>
<div>These characters will be replaced with whitespace in the title</div>
</Form.Group>
{/* TODO - mapping stuff will go here */}
<Form.Group>
<DropdownButton id="recipe-select" title="Select Parser Recipe">
{ builtInRecipes.map(item => (
<Dropdown.Item onSelect={() => setParserRecipe(item)}>
<span>{item.pattern}</span><span className="mr-auto">{item.description}</span>
</Dropdown.Item>
))}
</DropdownButton>
</Form.Group>
<Form.Group>
<ShowFields
fields={showFields}
onShowFieldsChanged={(fields) => setShowFields(fields)}
/>
</Form.Group>
<Form.Group>
<Button onClick={onFind}>Find</Button>
<Form.Control
as="select"
style={{flexBasis: "min-content"}}
options={PAGE_SIZE_OPTIONS}
onChange={(event: any) => onPageSizeChanged(parseInt(event.target.value))}
defaultValue={props.input.pageSize}
className="filter-item"
>
{ PAGE_SIZE_OPTIONS.map(val => <option value="val">{val}</option>) }
</Form.Control>
</Form.Group>
</Form.Group>
);
}
interface ISceneParserFieldProps {
parserResult : ParserResult<any>
className? : string
fieldName : string
onSetChanged : (set : boolean) => void
onValueChanged : (value : any) => void
originalParserResult? : ParserResult<any>
renderOriginalInputField: (props : ISceneParserFieldProps) => JSX.Element
renderNewInputField: (props : ISceneParserFieldProps, onChange : (event : any) => void) => JSX.Element
}
function SceneParserField(props : ISceneParserFieldProps) {
function maybeValueChanged(value : any) {
if (value !== props.parserResult.value) {
props.onValueChanged(value);
}
}
if (!showFields.get(props.fieldName)) {
return null;
}
return (
<>
<td>
<Form.Control
type="checkbox"
checked={props.parserResult.set}
onChange={() => {props.onSetChanged(!props.parserResult.set)}}
/>
</td>
<td>
<Form.Group>
{props.renderOriginalInputField(props)}
{props.renderNewInputField(props, (value) => maybeValueChanged(value))}
</Form.Group>
</td>
</>
);
}
function renderOriginalInputGroup(props: ISceneParserFieldProps) {
var parserResult = props.originalParserResult || props.parserResult;
return (
<Form.Control
disabled
className={props.className}
defaultValue={parserResult.originalValue || ""}
/>
);
}
interface IInputGroupWrapperProps {
parserResult: ParserResult<any>
onChange : (event : any) => void
className? : string
}
function InputGroupWrapper(props: IInputGroupWrapperProps) {
return (
<Form.Control
disabled={!props.parserResult.set}
className={props.className}
value={props.parserResult.value || ""}
onBlur={(event: any) => props.onChange(event.target.value)}
/>
);
}
function renderNewInputGroup(props : ISceneParserFieldProps, onChange : (value : any) => void) {
return (
<InputGroupWrapper
className={props.className}
onChange={(value : any) => {onChange(value)}}
parserResult={props.parserResult}
/>
);
}
interface HasName {
name: string
}
function renderOriginalSelect(props : ISceneParserFieldProps) {
const parserResult = props.originalParserResult || props.parserResult;
const elements = parserResult.originalValue
? Array.isArray(parserResult.originalValue)
? parserResult.originalValue.map((el:HasName) => el.name)
: parserResult.originalValue.name
: [];
return (
<div>
{ elements.map((name:string) => <Badge variant="secondary">{name}</Badge>) }
</div>
);
}
function renderNewMultiSelect(type: "performers" | "tags", props : ISceneParserFieldProps, onChange : (value : any) => void) {
return (
<FilterMultiSelect
className={props.className}
type={type}
onUpdate={(items) => {
const ids = items.map((i) => i.id);
onChange(ids);
}}
initialIds={props.parserResult.value}
/>
);
}
function renderNewPerformerSelect(props : ISceneParserFieldProps, onChange : (value : any) => void) {
return renderNewMultiSelect("performers", props, onChange);
}
function renderNewTagSelect(props : ISceneParserFieldProps, onChange : (value : any) => void) {
return renderNewMultiSelect("tags", props, onChange);
}
function renderNewStudioSelect(props : ISceneParserFieldProps, onChange : (value : any) => void) {
return (
<FilterSelect
type="studios"
noSelectionString=""
className={props.className}
onSelectItem={(item) => onChange(item ? item.id : undefined)}
initialId={props.parserResult.value}
/>
);
}
interface ISceneParserRowProps {
scene : SceneParserResult,
onChange: (changedScene : SceneParserResult) => void
}
function SceneParserRow(props : ISceneParserRowProps) {
function changeParser(result : ParserResult<any>, set : boolean, value : any) {
var newParser = _.clone(result);
newParser.set = set;
newParser.value = value;
return newParser;
}
function onTitleChanged(set : boolean, value: string | undefined) {
var newResult = _.clone(props.scene);
newResult.title = changeParser(newResult.title, set, value);
props.onChange(newResult);
}
function onDateChanged(set : boolean, value: string | undefined) {
var newResult = _.clone(props.scene);
newResult.date = changeParser(newResult.date, set, value);
props.onChange(newResult);
}
function onPerformerIdsChanged(set : boolean, value: string[] | undefined) {
var newResult = _.clone(props.scene);
newResult.performerIds = changeParser(newResult.performerIds, set, value);
props.onChange(newResult);
}
function onTagIdsChanged(set : boolean, value: string[] | undefined) {
var newResult = _.clone(props.scene);
newResult.tagIds = changeParser(newResult.tagIds, set, value);
props.onChange(newResult);
}
function onStudioIdChanged(set : boolean, value: string | undefined) {
var newResult = _.clone(props.scene);
newResult.studioId = changeParser(newResult.studioId, set, value);
props.onChange(newResult);
}
return (
<tr className="scene-parser-row">
<td style={{textAlign: "left"}}>
{props.scene.filename}
</td>
<SceneParserField
key="title"
fieldName="Title"
className="parser-field-title"
parserResult={props.scene.title}
onSetChanged={(set) => onTitleChanged(set, props.scene.title.value)}
onValueChanged={(value) => onTitleChanged(props.scene.title.set, value)}
renderOriginalInputField={renderOriginalInputGroup}
renderNewInputField={renderNewInputGroup}
/>
<SceneParserField
key="date"
fieldName="Date"
className="parser-field-date"
parserResult={props.scene.date}
onSetChanged={(set) => onDateChanged(set, props.scene.date.value)}
onValueChanged={(value) => onDateChanged(props.scene.date.set, value)}
renderOriginalInputField={renderOriginalInputGroup}
renderNewInputField={renderNewInputGroup}
/>
<SceneParserField
key="performers"
fieldName="Performers"
className="parser-field-performers"
parserResult={props.scene.performerIds}
originalParserResult={props.scene.performers}
onSetChanged={(set) => onPerformerIdsChanged(set, props.scene.performerIds.value)}
onValueChanged={(value) => onPerformerIdsChanged(props.scene.performerIds.set, value)}
renderOriginalInputField={renderOriginalSelect}
renderNewInputField={renderNewPerformerSelect}
/>
<SceneParserField
key="tags"
fieldName="Tags"
className="parser-field-tags"
parserResult={props.scene.tagIds}
originalParserResult={props.scene.tags}
onSetChanged={(set) => onTagIdsChanged(set, props.scene.tagIds.value)}
onValueChanged={(value) => onTagIdsChanged(props.scene.tagIds.set, value)}
renderOriginalInputField={renderOriginalSelect}
renderNewInputField={renderNewTagSelect}
/>
<SceneParserField
key="studio"
fieldName="Studio"
className="parser-field-studio"
parserResult={props.scene.studioId}
originalParserResult={props.scene.studio}
onSetChanged={(set) => onStudioIdChanged(set, props.scene.studioId.value)}
onValueChanged={(value) => onStudioIdChanged(props.scene.studioId.set, value)}
renderOriginalInputField={renderOriginalSelect}
renderNewInputField={renderNewStudioSelect}
/>
</tr>
)
}
function onChange(scene : SceneParserResult, changedScene : SceneParserResult) {
var newResult = [...parserResult];
var index = newResult.indexOf(scene);
newResult[index] = changedScene;
setParserResult(newResult);
}
function renderHeader(fieldName: string, allSet: boolean, onAllSet: (set: boolean) => void) {
if (!showFields.get(fieldName)) {
return null;
}
return (
<>
<td>
<Form.Control
type="checkbox"
checked={allSet}
onChange={() => {onAllSet(!allSet)}}
/>
</td>
<th>{fieldName}</th>
</>
)
}
function renderTable() {
if (parserResult.length == 0) { return undefined; }
return (
<>
<div>
<div className="scene-parser-results">
<Table>
<thead>
<tr className="scene-parser-row">
<th>Filename</th>
{renderHeader("Title", allTitleSet, onSelectAllTitleSet)}
{renderHeader("Date", allDateSet, onSelectAllDateSet)}
{renderHeader("Performers", allPerformerSet, onSelectAllPerformerSet)}
{renderHeader("Tags", allTagSet, onSelectAllTagSet)}
{renderHeader("Studio", allStudioSet, onSelectAllStudioSet)}
</tr>
</thead>
<tbody>
{parserResult.map((scene) =>
<SceneParserRow
scene={scene}
key={scene.id}
onChange={(changedScene) => onChange(scene, changedScene)}/>
)}
</tbody>
</Table>
</div>
<Pagination
currentPage={parserInput.page}
itemsPerPage={parserInput.pageSize}
totalItems={totalItems}
onChangePage={(page) => onPageChanged(page)}
/>
<Button variant="primary" onClick={onApply}>Apply</Button>
</div>
</>
)
}
return (
<Card id="parser-container">
<h4>Scene Filename Parser</h4>
<ParserInput
input={parserInput}
onFind={(input) => onFindClicked(input)}
/>
{isLoading ? <Spinner animation="border" variant="light" /> : undefined}
{renderTable()}
</Card>
);
};

View file

@ -0,0 +1,104 @@
import _ from "lodash";
import React 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 extends IBaseProps {}
export const SceneList: React.FC<ISceneListProps> = (props: ISceneListProps) => {
const otherOperations = [
{
text: "Play Random",
onClick: playRandom,
}
];
const listData = ListHook.useList({
filterMode: FilterMode.Scenes,
props,
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.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;
};

View file

@ -0,0 +1,121 @@
import React 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";
import { Table } from 'react-bootstrap';
interface ISceneListTableProps {
scenes: GQL.SlimSceneDataFragment[];
}
export const SceneListTable: React.FC<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 renderSceneRow(scene : GQL.SlimSceneDataFragment) {
return (
<>
<tr>
<td>
{renderSceneImage(scene)}
</td>
<td style={{textAlign: "left"}}>
<Link to={`/scenes/${scene.id}`}>
<h5 className="text-truncate">
{!!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>
</tr>
</>
)
}
return (
<>
<div className="grid">
<Table striped bordered>
<thead>
<tr>
<th></th>
<th>Title</th>
<th>Rating</th>
<th>Duration</th>
<th>Tags</th>
<th>Performers</th>
<th>Studio</th>
</tr>
</thead>
<tbody>
{props.scenes.map(renderSceneRow)}
</tbody>
</Table>
</div>
</>
);
};

View file

@ -0,0 +1,59 @@
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;
};

View file

@ -0,0 +1,196 @@
import React from "react";
import ReactJWPlayer from "react-jw-player";
import { HotKeys } from "react-hotkeys";
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;
}
const KeyMap = {
NUM0: "0",
NUM1: "1",
NUM2: "2",
SPACE: " "
}
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}
/>
);
}
onIncrease() {
const currentPlaybackRate = !!this.player ? this.player.getPlaybackRate() : 1;
this.player.setPlaybackRate(currentPlaybackRate + 0.5);
};
onDecrease() {
const currentPlaybackRate = !!this.player ? this.player.getPlaybackRate() : 1;
this.player.setPlaybackRate(currentPlaybackRate - 0.5);
};
onReset() { this.player.setPlaybackRate(1); };
onPause() { this.player.getState().paused ? this.player.play() : this.player.pause(); };
private KeyHandlers = {
NUM0: () => {this.onReset()},
NUM1: () => {this.onDecrease()},
NUM2: () => {this.onIncrease()},
SPACE: () => {this.onPause()}
}
public render() {
return (
<HotKeys keyMap={KeyMap} handlers={this.KeyHandlers}>
<div id="jwplayer-container">
{this.renderPlayer()}
<ScenePlayerScrubber
scene={this.props.scene}
position={this.state.scrubberPosition}
onSeek={this.onScrubberSeek}
onScrolled={this.onScrubberScrolled}
/>
</div>
</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: React.FC<IScenePlayerProps> = (props: IScenePlayerProps) => {
const config = StashService.useConfiguration();
return <ScenePlayerImpl {...props} config={config.data && config.data.configuration ? config.data.configuration.interface : undefined}/>
}

View file

@ -0,0 +1,128 @@
.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%;
}

View file

@ -0,0 +1,316 @@
import axios from "axios";
import React, { CSSProperties, FunctionComponent, RefObject, 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[]>([]);
const [delayedRender, setDelayedRender] = useState(false);
useEffect(() => {
if (!scrubberSliderEl.current) { return; }
scrubberSliderEl.current.style.transform = `translateX(${scrubberSliderEl.current.clientWidth / 2}px)`;
}, [scrubberSliderEl]);
useEffect(() => {
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(() => {
window.addEventListener("mouseup", onMouseUp, false);
return () => {
window.removeEventListener("mouseup", onMouseUp);
};
});
useEffect(() => {
if (!contentEl.current) { return; }
contentEl.current.addEventListener("mousedown", onMouseDown, false);
return () => {
if (!contentEl.current) { return; }
contentEl.current.removeEventListener("mousedown", onMouseDown);
};
});
useEffect(() => {
if (!contentEl.current) { return; }
contentEl.current.addEventListener("mousemove", onMouseMove, false);
return () => {
if (!contentEl.current) { return; }
contentEl.current.removeEventListener("mousemove", onMouseMove);
};
});
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);
}
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);
// TODO: Very hacky. Need to wait for the scroll width to update from the image loading.
setTimeout(() => {
setDelayedRender(true);
}, 100);
}
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">
<a className="scrubber-button" id="scrubber-back" onClick={() => goBack()}>&lt;</a>
<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>
<a className="scrubber-button" id="scrubber-forward" onClick={() => goForward()}>&gt;</a>
</div>
);
};

View file

@ -0,0 +1,301 @@
import _ from "lodash";
import { Button, ButtonGroup, Form, Spinner } from 'react-bootstrap';
import React, { useEffect, useState } from "react";
import { FilterSelect } from "../select/FilterSelect";
import { FilterMultiSelect } from "../select/FilterMultiSelect";
import { StashService } from "../../core/StashService";
import * as GQL from "../../core/generated-graphql";
import { ErrorUtils } from "../../utils/errors";
import { ToastUtils } from "../../utils/toasts";
interface IListOperationProps {
selected: GQL.SlimSceneDataFragment[],
onScenesUpdated: () => void;
}
export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IListOperationProps) => {
const [rating, setRating] = useState<string>("");
const [studioId, setStudioId] = useState<string | undefined>(undefined);
const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined);
const [tagIds, setTagIds] = useState<string[] | undefined>(undefined);
const updateScenes = StashService.useBulkSceneUpdate(getSceneInput());
// Network state
const [isLoading, setIsLoading] = useState(false);
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 (!performerIds || performerIds.length === 0) {
// and all scenes have the same ids,
if (aggregatePerformerIds.length > 0) {
// then unset the performerIds, otherwise ignore
sceneInput.performer_ids = performerIds;
}
} else {
// if performerIds non-empty, then we are setting them
sceneInput.performer_ids = performerIds;
}
// if tagIds non-empty, then we are setting them
if (!tagIds || tagIds.length === 0) {
// and all scenes have the same ids,
if (aggregateTagIds.length > 0) {
// then unset the tagIds, otherwise ignore
sceneInput.tag_ids = tagIds;
}
} else {
// if tagIds non-empty, then we are setting them
sceneInput.tag_ids = tagIds;
}
return sceneInput;
}
async function onSave() {
setIsLoading(true);
try {
const result = 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);
setPerformerIds(performerIds);
setTagIds(tagIds);
}
useEffect(() => {
updateScenesEditState(props.selected);
}, [props.selected]);
function renderMultiSelect(type: "performers" | "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 "tags": setTagIds(ids); break;
}
}}
initialIds={initialIds}
/>
);
}
function render() {
return (
<>
{isLoading ? <Spinner animation="border" variant="light" /> : undefined}
<div className="operation-container">
<Form.Group controlId="rating" className="operation-item">
<Form.Label>Rating</Form.Label>
<Form.Control
as="select"
onChange={(event: any) => setRating(event.target.value)}>
{ ["", 1, 2, 3, 4, 5].map(opt => (
<option selected={opt == rating} value={opt}>{opt}</option>
)) }
</Form.Control>
</Form.Group>
<Form.Group controlId="studio" className="operation-item">
<Form.Label>Studio</Form.Label>
<FilterSelect
type="studios"
onSelectItem={(item : any) => setStudioId(item ? item.id : undefined)}
initialId={studioId}
/>
</Form.Group>
<Form.Group className="opeation-item" controlId="performers">
<Form.Label>Performers</Form.Label>
{renderMultiSelect("performers", performerIds)}
</Form.Group>
<Form.Group className="operation-item" controlId="performers">
<Form.Label>Performers</Form.Label>
{renderMultiSelect("tags", tagIds)}
</Form.Group>
<ButtonGroup className="operation-item">
<Button
variant="primary"
onClick={onSave}>
Apply
</Button>
</ButtonGroup>
</div>
</>
);
}
return render();
};

View file

@ -0,0 +1,34 @@
import React, { } from "react";
import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql";
export class SceneHelpers {
public static maybeRenderStudio(
scene: GQL.SceneDataFragment | GQL.SlimSceneDataFragment,
height: number
) {
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 (
<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");
}
}

View file

@ -0,0 +1,15 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import { Scene } from "./SceneDetails/Scene";
import { SceneList } from "./SceneList";
import { SceneMarkerList } from "./SceneMarkerList";
const Scenes = () => (
<Switch>
<Route exact={true} path="/scenes" component={SceneList} />
<Route exact={true} path="/scenes/markers" component={SceneMarkerList} />
<Route path="/scenes/:id" component={Scene} />
</Switch>
);
export default Scenes;

View file

@ -0,0 +1,190 @@
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>();
type ValidTypes =
GQL.AllPerformersForFilterAllPerformers |
GQL.AllTagsForFilterAllTags |
GQL.AllStudiosForFilterAllStudios;
interface IProps extends HTMLInputProps, Partial<IMultiSelectProps<ValidTypes>> {
type: "performers" | "studios" | "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.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 "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 "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}
/>
);
};

View file

@ -0,0 +1,117 @@
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>();
type ValidTypes =
GQL.AllPerformersForFilterAllPerformers |
GQL.AllTagsForFilterAllTags |
GQL.AllStudiosForFilterAllStudios;
interface IProps extends HTMLInputProps {
type: "performers" | "studios" | "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 "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>
);
};

View file

@ -0,0 +1,61 @@
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"}}
/>
);
};

View file

@ -0,0 +1,74 @@
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.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"}}
/>
);
};

View file

@ -0,0 +1,75 @@
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>
);
};

View file

@ -0,0 +1,538 @@
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 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});
}
// 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 useValidGalleriesForScene(sceneId: string) {
return GQL.useValidGalleriesForScene({variables: {scene_id: sceneId}});
}
public static useStats() { return GQL.useStats(); }
public static useVersion() { return GQL.useVersion(); }
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(input: GQL.PerformerCreateInput) {
return GQL.usePerformerCreate({
variables: input,
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
});
}
public static usePerformerUpdate(input: GQL.PerformerUpdateInput) {
return GQL.usePerformerUpdate({
variables: input,
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
});
}
public static usePerformerDestroy(input: GQL.PerformerDestroyInput) {
return GQL.usePerformerDestroy({
variables: input,
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
});
}
private static sceneMutationImpactedQueries = [
"findPerformers",
"findScenes",
"findSceneMarkers",
"findStudios",
"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",
"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 useSceneDestroy(input: GQL.SceneDestroyInput) {
return GQL.useSceneDestroy({
variables: input,
update: () => StashService.invalidateQueries(StashService.sceneMutationImpactedQueries)
});
}
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 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 queryStopJob() {
return StashService.client.query<GQL.StopJobQuery>({
query: GQL.StopJobDocument,
fetchPolicy: "network-only",
});
}
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 queryMetadataScan(input: GQL.ScanMetadataInput) {
return StashService.client.query<GQL.MetadataScanQuery>({
query: GQL.MetadataScanDocument,
variables: { input },
fetchPolicy: "network-only",
});
}
public static queryMetadataAutoTag(input: GQL.AutoTagMetadataInput) {
return StashService.client.query<GQL.MetadataAutoTagQuery>({
query: GQL.MetadataAutoTagDocument,
variables: { input },
fetchPolicy: "network-only",
});
}
public static queryMetadataGenerate(input: GQL.GenerateMetadataInput) {
return StashService.client.query<GQL.MetadataGenerateQuery>({
query: GQL.MetadataGenerateDocument,
variables: { input },
fetchPolicy: "network-only",
});
}
public static queryMetadataClean() {
return StashService.client.query<GQL.MetadataCleanQuery>({
query: GQL.MetadataCleanDocument,
fetchPolicy: "network-only",
});
}
public static queryMetadataExport() {
return StashService.client.query<GQL.MetadataExportQuery>({
query: GQL.MetadataExportDocument,
fetchPolicy: "network-only",
});
}
public static queryMetadataImport() {
return StashService.client.query<GQL.MetadataImportQuery>({
query: GQL.MetadataImportDocument,
fetchPolicy: "network-only",
});
}
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",
});
}
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() {}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,310 @@
import { Spinner } from "@blueprintjs/core";
import _ from "lodash";
import queryString from "query-string";
import React, { useEffect, useState } 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";
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 IListHookOptions {
filterMode: FilterMode;
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;
}
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);
// Update the filter when the query parameters change
useEffect(() => {
const queryParams = queryString.parse(options.props.location.search);
const newFilter = _.cloneDeep(filter);
newFilter.configureFromQueryParameters(queryParams);
setFilter(newFilter);
// TODO: Need this side effect to update the query params properly
filter.configureFromQueryParameters(queryParams);
}, [options.props.location.search]);
let result: QueryHookResult<any, any>;
let getData: (filter : ListFilterModel) => QueryHookResult<any, any>;
let getItems: () => any[];
let getCount: () => number;
switch (options.filterMode) {
case FilterMode.Scenes: {
getData = (filter : ListFilterModel) => { return StashService.useFindScenes(filter); }
getItems = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.scenes : []; }
getCount = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0; }
break;
}
case FilterMode.SceneMarkers: {
getData = (filter : ListFilterModel) => { return StashService.useFindSceneMarkers(filter); }
getItems = () => { return !!result.data && !!result.data.findSceneMarkers ? result.data.findSceneMarkers.scene_markers : []; }
getCount = () => { return !!result.data && !!result.data.findSceneMarkers ? result.data.findSceneMarkers.count : 0; }
break;
}
case FilterMode.Galleries: {
getData = (filter : ListFilterModel) => { return StashService.useFindGalleries(filter); }
getItems = () => { return !!result.data && !!result.data.findGalleries ? result.data.findGalleries.galleries : []; }
getCount = () => { return !!result.data && !!result.data.findGalleries ? result.data.findGalleries.count : 0; }
break;
}
case FilterMode.Studios: {
getData = (filter : ListFilterModel) => { return StashService.useFindStudios(filter); }
getItems = () => { return !!result.data && !!result.data.findStudios ? result.data.findStudios.studios : []; }
getCount = () => { return !!result.data && !!result.data.findStudios ? result.data.findStudios.count : 0; }
break;
}
case FilterMode.Performers: {
getData = (filter : ListFilterModel) => { return StashService.useFindPerformers(filter); }
getItems = () => { return !!result.data && !!result.data.findPerformers ? result.data.findPerformers.performers : []; }
getCount = () => { return !!result.data && !!result.data.findPerformers ? result.data.findPerformers.count : 0; }
break;
}
default: {
console.error("REMOVE DEFAULT IN LIST HOOK");
getData = (filter : ListFilterModel) => { return StashService.useFindScenes(filter); }
getItems = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.scenes : []; }
getCount = () => { return !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0; }
break;
}
}
result = getData(filter);
useEffect(() => {
setTotalCount(getCount());
// select none when data changes
onSelectNone();
setLastClickedId(undefined);
}, [result.data])
// Update the query parameters when the data changes
useEffect(() => {
const location = Object.assign({}, options.props.history.location);
location.search = filter.makeQueryParameters();
options.props.history.replace(location);
}, [result.data, filter.displayMode]);
// Update the total count
useEffect(() => {
const newFilter = _.cloneDeep(filter);
newFilter.totalCount = totalCount;
setFilter(newFilter);
}, [totalCount]);
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 = getItems().findIndex((item) => {
return item.id === lastClickedId;
});
}
thisIndex = getItems().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 = getItems().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();
getItems().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;
const template = (
<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 ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
{result.error ? <h1>{result.error.message}</h1> : undefined}
{options.renderContent(result, filter, selectedIds, zoomIndex)}
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
onChangePage={onChangePage}
/>
</div>
);
return { filter, template, options, onSelectChange };
}
}

View file

@ -0,0 +1,66 @@
import localForage from "localforage";
import _ from "lodash";
import React from "react";
interface IInterfaceWallConfig {
}
export interface IInterfaceConfig {
wall: IInterfaceWallConfig;
}
type ValidTypes = IInterfaceConfig | undefined;
interface ILocalForage<T> {
data: T;
setData: React.Dispatch<React.SetStateAction<T>>;
error: Error | null;
}
export function useInterfaceLocalForage(): ILocalForage<IInterfaceConfig | undefined> {
const result = useLocalForage("interface");
// Set defaults
React.useEffect(() => {
if (result.data === undefined) {
result.setData({
wall: {
// nothing here currently
},
});
}
});
return result;
}
function useLocalForage(item: string): ILocalForage<ValidTypes> {
const [json, setJson] = React.useState<ValidTypes>(undefined);
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();
});
const [err, setErr] = React.useState(null);
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);
}
}
runAsync();
});
return {data: json, setData: setJson, error: err};
}

View file

@ -0,0 +1,72 @@
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;
}
}
}
}

519
ui/v2.5/src/index.scss Executable file
View file

@ -0,0 +1,519 @@
@import "~normalize.css";
@import "styles/form/grid";
@import "styles/shared/details";
@import "styles/blueprint-overrides";
@import "styles/scrollbars";
@import "styles/variables";
body {
margin: 0;
padding: $pt-navbar-height 0 0 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100vh;
background: $dark-gray2;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
.grid {
display: flex;
flex-flow: row wrap;
justify-content: center;
margin: $pt-grid-size $pt-grid-size 0 0;
padding: 0 100px;
&.wall {
padding: 0;
margin: 0;
}
& .bp3-button.favorite .bp3-icon {
color: #ff7373 !important
}
& .performer-list-thumbnail {
min-width: 50px;
height: 100px;
}
& .scene-list-thumbnail {
width: 150px;
min-height: 50px;
}
& table td {
text-align: center;
vertical-align: middle;
}
}
.grid-item {
// flex: auto;
width: 320px;
min-width: 185px;
margin: 0px 0 $pt-grid-size $pt-grid-size;
overflow: hidden;
&.wall {
width: calc(20%);
margin: 0;
}
&.zoom-0 {
width: 240px;
& .previewable {
max-height: 180px;
}
& .previewable.portrait {
height: 180px;
}
}
&.zoom-1 {
width: 320px;
& .previewable {
max-height: 240px;
}
& .previewable.portrait {
height: 240px;
}
}
&.zoom-2 {
width: 480px;
& .previewable {
max-height: 360px;
}
& .previewable.portrait {
height: 360px;
}
}
&.zoom-3 {
width: 640px;
& .previewable {
max-height: 480px;
}
& .previewable.portrait {
height: 480px;
}
}
}
.previewable {
display: block;
line-height: 0;
overflow: hidden;
width: calc(100% + 40px);
margin: -20px 0 0 -20px;
position: relative;
max-height: 240px;
}
.previewable.portrait {
height: 240px;
}
.grid-item label.card-select {
position: absolute;
padding-left: 15px;
margin-top: -12px;
z-index: 9;
opacity: 0.5;
}
.video-container {
width: 100%;
height: 100%;
}
video.preview {
// height: 225px; // slows down the page
width: 100%;
// width: calc(100% + 40px);
// margin: -20px 0 0 -20px;
object-fit: cover;
margin: 0 auto;
display: block;
}
video.preview.portrait {
height: 100%;
width: auto;
}
.filter-item, .operation-item {
margin: 0 10px;
}
.edit-button {
margin-right: 10px;
}
.tag-item {
margin: 5px;
a {
color: unset;
&:hover {
text-decoration: none;
color: unset;
}
}
}
.filter-container, .operation-container {
display: flex;
justify-content: center;
margin: 10px auto;
}
.card-section {
padding: 10px 0 0 0;
&.centered {
display: flex;
justify-content: center;
flex-flow: wrap;
}
}
.rating-5 { background: #FF2F39; }
.rating-4 { background: $red1; }
.rating-3 { background: $orange1; }
.rating-2 { background: $sepia1; }
.rating-1 { background: $dark-gray5; }
.rating-banner {
transform: rotate(-36deg);
display: block;
padding: 6px 45px;
font-weight: 400;
top: 14px;
position: absolute;
left: -46px;
color: #fff;
letter-spacing: 1px;
text-size-adjust: none;
font-size: .85714em;
line-height: 1.6em;
text-align: center;
}
.scene-specs-overlay {
display: block;
position: absolute;
bottom: 1em;
right: .7em;
font-weight: 400;
color: #f5f8fa;
letter-spacing: -.03em;
text-shadow: 0 0 3px #000;
}
.scene-studio-overlay {
display: block;
position: absolute;
top: .7em;
right: .7em;
font-weight: 900;
width: 40%;
height: 20%;
opacity: 0.75;
z-index: 9;
}
.scene-studio-overlay a {
width: 100%;
height: 100%;
background-size: contain;
display: inline-block;
background-position: right top;
background-repeat: no-repeat;
letter-spacing: -.03em;
text-shadow: 0 0 3px #000;
text-align: right;
text-decoration: none;
color: #f5f8fa;
}
.overlay-resolution {
font-weight: 900;
text-transform: uppercase;
margin-right:.3em;
}
.scene-card {
& .scene-specs-overlay, .rating-banner, .scene-studio-overlay {
transition: opacity 0.5s;
}
}
.scene-card:hover {
& .scene-specs-overlay, .rating-banner, .scene-studio-overlay {
opacity: 0;
transition: opacity 0.5s;
}
.scene-studio-overlay:hover {
opacity: 0.75;
transition: opacity 0.5s;
}
}
#jwplayer-container {
margin: 10px auto;
width: 75%;
}
.video-js {
width: 100%;
height: 90vh;
}
#details-container {
margin: 10px auto;
width: 75%;
}
.pre {
white-space: pre-line;
}
.logs {
white-space: pre-wrap;
word-break: break-all;
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
font-size: smaller;
padding-right: 10px;
overflow-y: auto;
max-height: 100vh;
width: 120ch;
.debug {
color: lightgreen;
font-weight: bold;
}
.info {
color: white;
font-weight: bold;
}
.warning {
color: orange;
font-weight: bold;
}
.error {
color: red;
font-weight: bold;
}
}
span.block {
display: block;
}
.performer.image {
height: 50vh;
min-height: 400px;
background-size: cover !important;
background-position: center !important;
background-repeat: no-repeat !important;
}
.performer-tag-container {
margin: 5px;
}
.performer-tag.image {
height: 150px;
width: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
margin: 0 auto;
}
.studio.image {
height: 100px;
background-size: contain !important;
background-position: center !important;
background-repeat: no-repeat !important;
}
.no-spacing {
padding: 0;
margin: 0;
}
.react-photo-gallery--gallery {
& img {
object-fit: contain;
}
}
#tag-list-container {
width: 50vw;
margin: 0 auto;
display: flex;
flex-direction: column;
& .tag-list-row {
margin: 10px;
cursor: pointer;
& .bp3-button {
margin: 0 10px;
}
}
& .tag-list-row:hover {
text-decoration: underline;
}
}
#parser-container {
margin: 10px auto;
width: 75%;
& .inputs label {
width: 12em;
}
& .inputs .bp3-input-group {
width: 80ch;
}
& .scene-parser-results {
overflow-x: auto;
}
& .scene-parser-row .bp3-checkbox {
margin: 0px -20px 0px 0px;
}
& .scene-parser-row .parser-field-title input {
width: 50ch;
}
& .scene-parser-row .parser-field-date input {
width: 13ch;
}
& .scene-parser-row .parser-field-performers input {
width: 20ch;
}
& .scene-parser-row .parser-field-tags input {
width: 20ch;
}
& .scene-parser-row .parser-field-studio input {
width: 15ch;
}
& .scene-parser-row input {
min-width: 10ch;
}
& .scene-parser-row .bp3-form-group {
margin-bottom: 0px;
}
& .scene-parser-row div:first-child > input {
margin-bottom: 5px;
}
}
#performer-details {
& td {
vertical-align: middle;
}
& td:first-child {
width: 30ch;
}
& #url-field {
line-height: 30px;
}
& #scrape-url-button {
float: right;
height: 30px;
}
}
.zoom-slider {
margin: auto 5px;
width: 100px;
& .bp3-slider {
min-width: 100%;
}
}
.aliases-field > label{
font-weight: 300;
}
.scene-cover {
display: block;
margin-top: 10px;
margin-bottom: 10px;
max-width: 100%;
}
.collapsible-label {
cursor: pointer;
}
.label-icon {
margin-right: 0.3em;
vertical-align: middle;
& +span {
vertical-align: middle;
}
}
.main {
color: #f5f8fa;
}
.table {
color: #f5f8fa;
width: inherit;
}
.table td {
border: none;
}
.table-striped tbody tr:nth-child(odd) td {
background:rgba(92, 112, 128, 0.15);
}
.tab-pane {
margin-top: 20px;
}
.card {
background-color: #30404d;
border-radius: 3px;
box-shadow: 0 0 0 1px rgba(16,22,26,.4), 0 0 0 rgba(16,22,26,0), 0 0 0 rgba(16,22,26,0);
padding: 20px;
}

21
ui/v2.5/src/index.tsx Executable file
View file

@ -0,0 +1,21 @@
import React from "react";
import { ApolloProvider } from "react-apollo-hooks";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { StashService } from "./core/StashService";
import "./index.scss";
import * as serviceWorker from "./serviceWorker";
ReactDOM.render((
<BrowserRouter>
<ApolloProvider client={StashService.initialize()!}>
<App />
</ApolloProvider>
</BrowserRouter>
), document.getElementById("root"));
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister();

View file

@ -0,0 +1,3 @@
import { RouteComponentProps } from "react-router";
export interface IBaseProps<M = any> extends RouteComponentProps<M> {}

View file

@ -0,0 +1,2 @@
export * from "./base-props";
export * from "./types";

View file

@ -0,0 +1,197 @@
import { isArray } from "util";
import { CriterionModifier } from "../../../core/generated-graphql";
import { ILabeledId, ILabeledValue } from "../types";
export type CriterionType =
"none" |
"rating" |
"resolution" |
"favorite" |
"hasMarkers" |
"isMissing" |
"tags" |
"sceneTags" |
"performers" |
"studios" |
"birth_year" |
"age" |
"ethnicity" |
"country" |
"eye_color" |
"height" |
"measurements" |
"fake_tits" |
"career_length" |
"tattoos" |
"piercings" |
"aliases";
export abstract class Criterion<Option = any, Value = any> {
public static getLabel(type: CriterionType = "none"): string {
switch (type) {
case "none": return "None";
case "rating": return "Rating";
case "resolution": return "Resolution";
case "favorite": return "Favorite";
case "hasMarkers": return "Has Markers";
case "isMissing": return "Is Missing";
case "tags": return "Tags";
case "sceneTags": return "Scene Tags";
case "performers": return "Performers";
case "studios": return "Studios";
case "birth_year": return "Birth Year";
case "age": return "Age";
case "ethnicity": return "Ethnicity";
case "country": return "Country";
case "eye_color": return "Eye Color";
case "height": return "Height";
case "measurements": return "Measurements";
case "fake_tits": return "Fake Tits";
case "career_length": return "Career Length";
case "tattoos": return "Tattoos";
case "piercings": return "Piercings";
case "aliases": return "Aliases";
}
}
public static getModifierOption(modifier: CriterionModifier = CriterionModifier.Equals): ILabeledValue {
switch (modifier) {
case CriterionModifier.Equals: return {value: CriterionModifier.Equals, label: "Equals"};
case CriterionModifier.NotEquals: return {value: CriterionModifier.NotEquals, label: "Not Equals"};
case CriterionModifier.GreaterThan: return {value: CriterionModifier.GreaterThan, label: "Greater Than"};
case CriterionModifier.LessThan: return {value: CriterionModifier.LessThan, label: "Less Than"};
case CriterionModifier.IsNull: return {value: CriterionModifier.IsNull, label: "Is NULL"};
case CriterionModifier.NotNull: return {value: CriterionModifier.NotNull, label: "Not NULL"};
case CriterionModifier.IncludesAll: return {value: CriterionModifier.IncludesAll, label: "Includes All"};
case CriterionModifier.Includes: return {value: CriterionModifier.Includes, label: "Includes"};
case CriterionModifier.Excludes: return {value: CriterionModifier.Excludes, label: "Excludes"};
}
}
public abstract type: CriterionType;
public abstract parameterName: string;
public abstract modifier: CriterionModifier;
public abstract modifierOptions: ILabeledValue[];
public abstract options: Option[] | undefined;
public abstract value: Value;
public inputType: "number" | "text" | undefined;
public getLabel(): string {
let modifierString: string;
switch (this.modifier) {
case CriterionModifier.Equals: modifierString = "is"; break;
case CriterionModifier.NotEquals: modifierString = "is not"; break;
case CriterionModifier.GreaterThan: modifierString = "is greater than"; break;
case CriterionModifier.LessThan: modifierString = "is less than"; break;
case CriterionModifier.IsNull: modifierString = "is null"; break;
case CriterionModifier.NotNull: modifierString = "is not null"; break;
case CriterionModifier.Includes: modifierString = "includes"; break;
case CriterionModifier.IncludesAll: modifierString = "includes all"; break;
case CriterionModifier.Excludes: modifierString = "excludes"; break;
default: modifierString = "";
}
let valueString: string;
if (this.modifier === CriterionModifier.IsNull || this.modifier === CriterionModifier.NotNull) {
valueString = "";
} else if (isArray(this.value) && this.value.length > 0) {
let items = this.value;
if ((this.value as ILabeledId[])[0].label) {
items = this.value.map((item) => item.label) as any;
}
valueString = items.join(", ");
} else if (typeof this.value === "string") {
valueString = this.value;
} else {
valueString = this.value.toString();
}
return `${Criterion.getLabel(this.type)} ${modifierString} ${valueString}`;
}
public getId(): string {
return `${this.parameterName}-${this.modifier.toString()}`; // TODO add values?
}
public set(modifier: CriterionModifier, value: Value) {
this.modifier = modifier;
if (isArray(this.value)) {
this.value.push(value);
} else {
this.value = value;
}
}
}
export interface ICriterionOption {
label: string;
value: CriterionType;
}
export class CriterionOption implements ICriterionOption {
public label: string;
public value: CriterionType;
constructor(label : string, value : CriterionType) {
this.label = label;
this.value = value;
}
}
export class StringCriterion extends Criterion<string, string> {
public type: CriterionType;
public parameterName: string;
public modifier = CriterionModifier.Equals;
public modifierOptions = [
Criterion.getModifierOption(CriterionModifier.Equals),
Criterion.getModifierOption(CriterionModifier.NotEquals),
Criterion.getModifierOption(CriterionModifier.IsNull),
Criterion.getModifierOption(CriterionModifier.NotNull),
];
public options: string[] | undefined;
public value: string = "";
constructor(type : CriterionType, parameterName?: string, options? : string[]) {
super();
this.type = type;
this.options = options;
this.inputType = "text";
if (!!parameterName) {
this.parameterName = parameterName;
} else {
this.parameterName = type;
}
}
}
export class NumberCriterion extends Criterion<number, number> {
public type: CriterionType;
public parameterName: string;
public modifier = CriterionModifier.Equals;
public modifierOptions = [
Criterion.getModifierOption(CriterionModifier.Equals),
Criterion.getModifierOption(CriterionModifier.NotEquals),
Criterion.getModifierOption(CriterionModifier.GreaterThan),
Criterion.getModifierOption(CriterionModifier.LessThan),
Criterion.getModifierOption(CriterionModifier.IsNull),
Criterion.getModifierOption(CriterionModifier.NotNull),
];
public options: number[] | undefined;
public value: number = 0;
constructor(type : CriterionType, parameterName?: string, options? : number[]) {
super();
this.type = type;
this.options = options;
this.inputType = "number";
if (!!parameterName) {
this.parameterName = parameterName;
} else {
this.parameterName = type;
}
}
}

View file

@ -0,0 +1,20 @@
import { CriterionModifier } from "../../../core/generated-graphql";
import {
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
export class FavoriteCriterion extends Criterion<string, string> {
public type: CriterionType = "favorite";
public parameterName: string = "filter_favorites";
public modifier = CriterionModifier.Equals;
public modifierOptions = [];
public options: string[] = [true.toString(), false.toString()];
public value: string = "";
}
export class FavoriteCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("favorite");
public value: CriterionType = "favorite";
}

View file

@ -0,0 +1,20 @@
import { CriterionModifier } from "../../../core/generated-graphql";
import {
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
export class HasMarkersCriterion extends Criterion<string, string> {
public type: CriterionType = "hasMarkers";
public parameterName: string = "has_markers";
public modifier = CriterionModifier.Equals;
public modifierOptions = [];
public options: string[] = [true.toString(), false.toString()];
public value: string = "";
}
export class HasMarkersCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("hasMarkers");
public value: CriterionType = "hasMarkers";
}

View file

@ -0,0 +1,20 @@
import { CriterionModifier } from "../../../core/generated-graphql";
import {
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
export class IsMissingCriterion extends Criterion<string, string> {
public type: CriterionType = "isMissing";
public parameterName: string = "is_missing";
public modifier = CriterionModifier.Equals;
public modifierOptions = [];
public options: string[] = ["title", "url", "date", "gallery", "studio", "performers"];
public value: string = "";
}
export class IsMissingCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("isMissing");
public value: CriterionType = "isMissing";
}

View file

@ -0,0 +1,20 @@
import { CriterionModifier } from "../../../core/generated-graphql";
import {
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
export class NoneCriterion extends Criterion<any, any> {
public type: CriterionType = "none";
public parameterName: string = "";
public modifier = CriterionModifier.Equals;
public modifierOptions = [];
public options: any;
public value: any;
}
export class NoneCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("none");
public value: CriterionType = "none";
}

View file

@ -0,0 +1,31 @@
import { CriterionModifier } from "../../../core/generated-graphql";
import { ILabeledId } from "../types";
import {
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
interface IOptionType {
id: string;
name?: string;
image_path?: string;
}
export class PerformersCriterion extends Criterion<IOptionType, ILabeledId[]> {
public type: CriterionType = "performers";
public parameterName: string = "performers";
public modifier = CriterionModifier.IncludesAll;
public modifierOptions = [
Criterion.getModifierOption(CriterionModifier.IncludesAll),
Criterion.getModifierOption(CriterionModifier.Includes),
Criterion.getModifierOption(CriterionModifier.Excludes),
];
public options: IOptionType[] = [];
public value: ILabeledId[] = [];
}
export class PerformersCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("performers");
public value: CriterionType = "performers";
}

View file

@ -0,0 +1,27 @@
import { CriterionModifier } from "../../../core/generated-graphql";
import {
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
export class RatingCriterion extends Criterion<number, number> { // TODO <number, number[]>
public type: CriterionType = "rating";
public parameterName: string = "rating";
public modifier = CriterionModifier.Equals;
public modifierOptions = [
Criterion.getModifierOption(CriterionModifier.Equals),
Criterion.getModifierOption(CriterionModifier.NotEquals),
Criterion.getModifierOption(CriterionModifier.GreaterThan),
Criterion.getModifierOption(CriterionModifier.LessThan),
Criterion.getModifierOption(CriterionModifier.IsNull),
Criterion.getModifierOption(CriterionModifier.NotNull),
];
public options: number[] = [1, 2, 3, 4, 5];
public value: number = 0;
}
export class RatingCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("rating");
public value: CriterionType = "rating";
}

Some files were not shown because too many files have changed in this diff Show more