Remove v2 UI (#613)

This commit is contained in:
WithoutPants 2020-06-17 11:02:47 +10:00 committed by GitHub
parent a7ac02fb50
commit 1ca5f357e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
142 changed files with 0 additions and 27537 deletions

1
.gitignore vendored
View file

@ -20,7 +20,6 @@
# GraphQL generated output # GraphQL generated output
pkg/models/generated_*.go pkg/models/generated_*.go
ui/v2/src/core/generated-*.tsx
ui/v2.5/src/core/generated-*.tsx ui/v2.5/src/core/generated-*.tsx
# packr generated files # packr generated files

View file

@ -1 +0,0 @@
BROWSER=none

23
ui/v2/.gitignore vendored
View file

@ -1,23 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View file

@ -1,18 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Chrome",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/src",
"sourceMapPathOverrides": {
"webpack:///src/*": "${webRoot}/*"
}
}
]
}

View file

@ -1,10 +0,0 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.tabSize": 2,
"editor.renderWhitespace": "boundary",
"editor.wordWrap": "bounded",
"javascript.preferences.importModuleSpecifier": "relative",
"typescript.preferences.importModuleSpecifier": "relative",
"editor.wordWrapColumn": 120,
"editor.rulers": [120]
}

View file

@ -1,47 +0,0 @@
* Install gulp `yarn global add gulp`
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.<br>
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br>
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.<br>
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.<br>
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you 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/).

View file

@ -1,18 +0,0 @@
overwrite: true
schema: "../../graphql/schema/**/*.graphql"
documents: "../../graphql/documents/**/*.graphql"
generates:
src/core/generated-graphql.tsx:
config:
noNamespaces: true
optionalType: "undefined"
noHOC: true
noComponents: true
withHooks: true
plugins:
- add: "/* tslint:disable */"
- add: "/* eslint-disable */"
- time
- "typescript-common"
- "typescript-client"
- "typescript-react-apollo"

View file

@ -1,67 +0,0 @@
{
"name": "stash",
"version": "0.1.0",
"private": true,
"dependencies": {
"@blueprintjs/core": "3.15.1",
"@blueprintjs/select": "3.8.0",
"@types/jest": "24.0.13",
"@types/lodash": "4.14.132",
"@types/node": "11.13.0",
"@types/query-string": "6.3.0",
"@types/react": "16.8.18",
"@types/react-dom": "16.8.4",
"@types/react-router-dom": "4.3.3",
"@types/video.js": "^7.2.11",
"apollo-boost": "0.4.0",
"apollo-link-ws": "^1.0.19",
"axios": "0.18.1",
"bulma": "0.7.5",
"formik": "1.5.7",
"graphql": "14.3.1",
"localforage": "1.7.3",
"lodash": "4.17.13",
"node-sass": "4.12.0",
"query-string": "6.5.0",
"react": "16.8.6",
"react-apollo": "2.5.6",
"react-apollo-hooks": "0.4.5",
"react-dom": "16.8.6",
"react-images": "0.5.19",
"react-jw-player": "1.19.0",
"react-photo-gallery": "7.0.2",
"react-router-dom": "5.0.0",
"react-scripts": "3.3.0",
"react-use": "9.1.2",
"subscriptions-transport-ws": "^0.9.16"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "tslint -c ./tslint.json 'src/**/*.{ts,tsx}'",
"lint:fix": "tslint --fix -c ./tslint.json 'src/**/*.{ts,tsx}'",
"gqlgen": "gql-gen --config codegen.yml"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
],
"devDependencies": {
"graphql-code-generator": "0.18.2",
"graphql-codegen-add": "0.18.2",
"graphql-codegen-time": "0.18.2",
"graphql-codegen-typescript-client": "0.18.2",
"graphql-codegen-typescript-common": "0.18.2",
"graphql-codegen-typescript-react-apollo": "0.18.2",
"tslint": "5.16.0",
"tslint-react": "4.0.0",
"typescript": "3.4.5"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -1,41 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Stash</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,92 +0,0 @@
JW Player version 8.11.5
Copyright (c) 2019, JW Player, All Rights Reserved
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
https://www.jwplayer.com/tos/
This product includes portions of other software. For the full text of licenses, see below:
JW Player Third Party Software Notices and/or Additional Terms and Conditions
**************************************************************************************************
The following software is used under Apache License 2.0
**************************************************************************************************
vtt.js v0.13.0
Copyright (c) 2019 Mozilla (http://mozilla.org)
https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
* * *
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and
limitations under the License.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
**************************************************************************************************
The following software is used under MIT license
**************************************************************************************************
Underscore.js v1.6.0
Copyright (c) 2009-2014 Jeremy Ashkenas, DocumentCloud and Investigative
https://github.com/jashkenas/underscore/blob/1.6.0/LICENSE
Backbone backbone.events.js v1.1.2
Copyright (c) 2010-2014 Jeremy Ashkenas, DocumentCloud
https://github.com/jashkenas/backbone/blob/1.1.2/LICENSE
Promise Polyfill v7.1.1
Copyright (c) 2014 Taylor Hakes and Forbes Lindesay
https://github.com/taylorhakes/promise-polyfill/blob/v7.1.1/LICENSE
can-autoplay.js v3.0.0
Copyright (c) 2017 video-dev
https://github.com/video-dev/can-autoplay/blob/v3.0.0/LICENSE
* * *
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
**************************************************************************************************
The following software is used under W3C license
**************************************************************************************************
Intersection Observer v0.5.0
Copyright (c) 2016 Google Inc. (http://google.com)
https://github.com/w3c/IntersectionObserver/blob/v0.5.0/LICENSE.md
* * *
W3C SOFTWARE AND DOCUMENT NOTICE AND LICENSE
Status: This license takes effect 13 May, 2015.
This work is being provided by the copyright holders under the following license.
License
By obtaining and/or copying this work, you (the licensee) agree that you have read, understood, and will comply with the following terms and conditions.
Permission to copy, modify, and distribute this work, with or without modification, for any purpose and without fee or royalty is hereby granted, provided that you include the following on ALL copies of the work or portions thereof, including modifications:
The full text of this NOTICE in a location viewable to users of the redistributed or derivative work.
Any pre-existing intellectual property disclaimers, notices, or terms and conditions. If none exist, the W3C Software and Document Short Notice should be included.
Notice of any changes or modifications, through a copyright statement on the new code or document such as "This software or document includes material copied from or derived from [title and URI of the W3C document]. Copyright © [YEAR] W3C® (MIT, ERCIM, Keio, Beihang)."
Disclaimers
THIS WORK IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE OR DOCUMENT WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENT.
The name and trademarks of copyright holders may NOT be used in advertising or publicity pertaining to the work without specific, written prior permission. Title to copyright in this work will at all times remain with copyright holders.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,95 +0,0 @@
/*!
JW Player version 8.11.5
Copyright (c) 2019, JW Player, All Rights Reserved
https://github.com/jwplayer/jwplayer/blob/v8.11.5/README.md
This source code and its use and distribution is subject to the terms and conditions of the applicable license agreement.
https://www.jwplayer.com/tos/
This product includes portions of other software. For the full text of licenses, see below:
JW Player Third Party Software Notices and/or Additional Terms and Conditions
**************************************************************************************************
The following software is used under Apache License 2.0
**************************************************************************************************
vtt.js v0.13.0
Copyright (c) 2019 Mozilla (http://mozilla.org)
https://github.com/mozilla/vtt.js/blob/v0.13.0/LICENSE
* * *
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and
limitations under the License.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
**************************************************************************************************
The following software is used under MIT license
**************************************************************************************************
Underscore.js v1.6.0
Copyright (c) 2009-2014 Jeremy Ashkenas, DocumentCloud and Investigative
https://github.com/jashkenas/underscore/blob/1.6.0/LICENSE
Backbone backbone.events.js v1.1.2
Copyright (c) 2010-2014 Jeremy Ashkenas, DocumentCloud
https://github.com/jashkenas/backbone/blob/1.1.2/LICENSE
Promise Polyfill v7.1.1
Copyright (c) 2014 Taylor Hakes and Forbes Lindesay
https://github.com/taylorhakes/promise-polyfill/blob/v7.1.1/LICENSE
can-autoplay.js v3.0.0
Copyright (c) 2017 video-dev
https://github.com/video-dev/can-autoplay/blob/v3.0.0/LICENSE
* * *
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
**************************************************************************************************
The following software is used under W3C license
**************************************************************************************************
Intersection Observer v0.5.0
Copyright (c) 2016 Google Inc. (http://google.com)
https://github.com/w3c/IntersectionObserver/blob/v0.5.0/LICENSE.md
* * *
W3C SOFTWARE AND DOCUMENT NOTICE AND LICENSE
Status: This license takes effect 13 May, 2015.
This work is being provided by the copyright holders under the following license.
License
By obtaining and/or copying this work, you (the licensee) agree that you have read, understood, and will comply with the following terms and conditions.
Permission to copy, modify, and distribute this work, with or without modification, for any purpose and without fee or royalty is hereby granted, provided that you include the following on ALL copies of the work or portions thereof, including modifications:
The full text of this NOTICE in a location viewable to users of the redistributed or derivative work.
Any pre-existing intellectual property disclaimers, notices, or terms and conditions. If none exist, the W3C Software and Document Short Notice should be included.
Notice of any changes or modifications, through a copyright statement on the new code or document such as "This software or document includes material copied from or derived from [title and URI of the W3C document]. Copyright © [YEAR] W3C® (MIT, ERCIM, Keio, Beihang)."
Disclaimers
THIS WORK IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE OR DOCUMENT WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENT.
The name and trademarks of copyright holders may NOT be used in advertising or publicity pertaining to the work without specific, written prior permission. Title to copyright in this work will at all times remain with copyright holders.
*/
(window.webpackJsonpjwplayer=window.webpackJsonpjwplayer||[]).push([[10],{97:function(t,e,r){"use strict";r.r(e);var n=r(42),i=r(67),s=/^(\d+):(\d{2})(:\d{2})?\.(\d{3})/,a=/^-?\d+$/,u=/\r\n|\n/,o=/^NOTE($|[ \t])/,c=/^[^\sa-zA-Z-]+/,l=/:/,f=/\s/,h=/^\s+/,g=/-->/,d=/^WEBVTT([ \t].*)?$/,p=function(t,e){this.window=t,this.state="INITIAL",this.buffer="",this.decoder=e||new b,this.regionList=[],this.maxCueBatch=1e3};function b(){return{decode:function(t){if(!t)return"";if("string"!=typeof t)throw new Error("Error - expected string data.");return decodeURIComponent(encodeURIComponent(t))}}}function v(){this.values=Object.create(null)}v.prototype={set:function(t,e){this.get(t)||""===e||(this.values[t]=e)},get:function(t,e,r){return r?this.has(t)?this.values[t]:e[r]:this.has(t)?this.values[t]:e},has:function(t){return t in this.values},alt:function(t,e,r){for(var n=0;n<r.length;++n)if(e===r[n]){this.set(t,e);break}},integer:function(t,e){a.test(e)&&this.set(t,parseInt(e,10))},percent:function(t,e){return(e=parseFloat(e))>=0&&e<=100&&(this.set(t,e),!0)}};var E=new i.a(0,0,0),w="middle"===E.align?"middle":"center";function T(t,e,r){var n=t;function i(){var e=function(t){function e(t,e,r,n){return 3600*(0|t)+60*(0|e)+(0|r)+(0|n)/1e3}var r=t.match(s);return r?r[3]?e(r[1],r[2],r[3].replace(":",""),r[4]):r[1]>59?e(r[1],r[2],0,r[4]):e(0,r[1],r[2],r[4]):null}(t);if(null===e)throw new Error("Malformed timestamp: "+n);return t=t.replace(c,""),e}function a(){t=t.replace(h,"")}if(a(),e.startTime=i(),a(),"--\x3e"!==t.substr(0,3))throw new Error("Malformed time stamp (time stamps must be separated by '--\x3e'): "+n);t=t.substr(3),a(),e.endTime=i(),a(),function(t,e){var n=new v;!function(t,e,r,n){for(var i=n?t.split(n):[t],s=0;s<=i.length;s+=1)if("string"==typeof i[s]){var a=i[s].split(r);if(2===a.length)e(a[0],a[1])}}(t,(function(t,e){switch(t){case"region":for(var i=r.length-1;i>=0;i--)if(r[i].id===e){n.set(t,r[i].region);break}break;case"vertical":n.alt(t,e,["rl","lr"]);break;case"line":var s=e.split(","),a=s[0];n.integer(t,a),n.percent(t,a)&&n.set("snapToLines",!1),n.alt(t,a,["auto"]),2===s.length&&n.alt("lineAlign",s[1],["start",w,"end"]);break;case"position":var u=e.split(",");n.percent(t,u[0]),2===u.length&&n.alt("positionAlign",u[1],["start",w,"end","line-left","line-right","auto"]);break;case"size":n.percent(t,e);break;case"align":n.alt(t,e,["start",w,"end","left","right"])}}),l,f),e.region=n.get("region",null),e.vertical=n.get("vertical","");var i=n.get("line","auto");"auto"===i&&-1===E.line&&(i=-1),e.line=i,e.lineAlign=n.get("lineAlign","start"),e.snapToLines=n.get("snapToLines",!0),e.size=n.get("size",100),e.align=n.get("align",w);var s=n.get("position","auto");"auto"===s&&50===E.position&&(s="start"===e.align||"left"===e.align?0:"end"===e.align||"right"===e.align?100:50),e.position=s}(t,e)}p.prototype={parse:function(t,e){var r,s=this;function a(){for(var t=s.buffer,e=0;e<t.length&&"\r"!==t[e]&&"\n"!==t[e];)++e;var r=t.substr(0,e);return"\r"===t[e]&&++e,"\n"===t[e]&&++e,s.buffer=t.substr(e),r}function c(){"CUETEXT"===s.state&&s.cue&&s.oncue&&s.oncue(s.cue),s.cue=null,s.state="INITIAL"===s.state?"BADWEBVTT":"BADCUE"}t&&(s.buffer+=s.decoder.decode(t,{stream:!0}));try{if("INITIAL"===s.state){if(!u.test(s.buffer))return this;var f=(r=a()).match(d);if(!f||!f[0])throw new Error("Malformed WebVTT signature.");s.state="HEADER"}}catch(t){return c(),this}var h=!1,p=0;!function t(){try{for(;s.buffer&&p<=s.maxCueBatch;){if(!u.test(s.buffer))return s.flush(),this;switch(h?h=!1:r=a(),s.state){case"HEADER":l.test(r)||r||(s.state="ID");break;case"NOTE":r||(s.state="ID");break;case"ID":if(o.test(r)){s.state="NOTE";break}if(!r)break;if(s.cue=new i.a(0,0,""),s.state="CUE",!g.test(r)){s.cue.id=r;break}case"CUE":try{T(r,s.cue,s.regionList)}catch(t){s.cue=null,s.state="BADCUE";break}s.state="CUETEXT";break;case"CUETEXT":var f=g.test(r);if(!r||f&&(h=!0)){s.oncue&&(p+=1,s.oncue(s.cue)),s.cue=null,s.state="ID";break}s.cue.text&&(s.cue.text+="\n"),s.cue.text+=r;break;case"BADCUE":r||(s.state="ID")}}if(p=0,s.buffer)Object(n.b)(t);else if(!e)return s.flush(),this}catch(t){return c(),this}}()},flush:function(){try{if(this.buffer+=this.decoder.decode(),(this.cue||"HEADER"===this.state)&&(this.buffer+="\n\n",this.parse(void 0,!0)),"INITIAL"===this.state)throw new Error("Malformed WebVTT signature.")}catch(t){throw t}return this.onflush&&this.onflush(),this}},e.default=p}}]);

View file

@ -1,15 +0,0 @@
{
"short_name": "Stash",
"name": "Stash: Porn Organizer",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -1,98 +0,0 @@
import React, { FunctionComponent, useState } from "react";
import { Route, Switch } from "react-router-dom";
import { ErrorBoundary } from "./components/ErrorBoundary";
import Galleries from "./components/Galleries/Galleries";
import { MainNavbar } from "./components/MainNavbar";
import { PageNotFound } from "./components/PageNotFound";
import Performers from "./components/performers/performers";
import Scenes from "./components/scenes/scenes";
import { Settings } from "./components/Settings/Settings";
import { Stats } from "./components/Stats";
import Studios from "./components/Studios/Studios";
import Movies from "./components/Movies/Movies";
import Tags from "./components/Tags/Tags";
import { SceneFilenameParser } from "./components/scenes/SceneFilenameParser";
import { Sidebar } from "./components/Sidebar";
import { IconName } from "@blueprintjs/core";
export interface IMenuItem {
icon: IconName
text: string
href: string
}
interface IProps {}
export const App: FunctionComponent<IProps> = (props: IProps) => {
const [menuOpen, setMenuOpen] = useState<boolean>(false);
function getSidebarClosedClass() {
if (!menuOpen) {
return " sidebar-closed";
}
return "";
}
const menuItems: IMenuItem[] = [
{
icon: "video",
text: "Scenes",
href: "/scenes"
},
{
href: "/movies",
icon: "film",
text: "Movies"
},
{
href: "/scenes/markers",
icon: "map-marker",
text: "Markers"
},
{
href: "/galleries",
icon: "media",
text: "Galleries"
},
{
href: "/performers",
icon: "person",
text: "Performers"
},
{
href: "/studios",
icon: "mobile-video",
text: "Studios"
},
{
href: "/tags",
icon: "tag",
text: "Tags"
}
];
return (
<div className="bp3-dark">
<ErrorBoundary>
<MainNavbar onMenuToggle={() => setMenuOpen(!menuOpen)} menuItems={menuItems}/>
<Sidebar className={getSidebarClosedClass()} menuItems={menuItems}/>
<div className={"main" + getSidebarClosedClass()}>
<Switch>
<Route exact={true} path="/" component={Stats} />
<Route path="/scenes" component={Scenes} />
{/* <Route path="/scenes/:id" component={Scene} /> */}
<Route path="/galleries" component={Galleries} />
<Route path="/performers" component={Performers} />
<Route path="/tags" component={Tags} />
<Route path="/studios" component={Studios} />
<Route path="/movies" component={Movies} />
<Route path="/settings" component={Settings} />
<Route path="/sceneFilenameParser" component={SceneFilenameParser} />
<Route component={PageNotFound} />
</Switch>
</div>
</ErrorBoundary>
</div>
);
};

View file

@ -1,34 +0,0 @@
import React from "react";
export class ErrorBoundary extends React.Component<any, any> {
constructor(props: any) {
super(props);
this.state = { error: null, errorInfo: null };
}
public componentDidCatch(error: any, errorInfo: any) {
this.setState({
error,
errorInfo,
});
}
public render() {
if (this.state.errorInfo) {
// Error path
return (
<div>
<h2>Something went wrong.</h2>
<details style={{ whiteSpace: "pre-wrap" }}>
{this.state.error && this.state.error.toString()}
<br />
{this.state.errorInfo.componentStack}
</details>
</div>
);
}
// Normally, just render children
return this.props.children;
}
}

View file

@ -1,13 +0,0 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import { Gallery } from "./Gallery";
import { GalleryList } from "./GalleryList";
const Galleries = () => (
<Switch>
<Route exact={true} path="/galleries" component={GalleryList} />
<Route path="/galleries/:id" component={Gallery} />
</Switch>
);
export default Galleries;

View file

@ -1,31 +0,0 @@
import {
Spinner,
} from "@blueprintjs/core";
import React, { FunctionComponent, useEffect, useState } from "react";
import * as GQL from "../../core/generated-graphql";
import { StashService } from "../../core/StashService";
import { IBaseProps } from "../../models";
import { GalleryViewer } from "./GalleryViewer";
interface IProps extends IBaseProps {}
export const Gallery: FunctionComponent<IProps> = (props: IProps) => {
const [gallery, setGallery] = useState<Partial<GQL.GalleryDataFragment>>({});
const [isLoading, setIsLoading] = useState(false);
const { data, error, loading } = StashService.useFindGallery(props.match.params.id);
useEffect(() => {
setIsLoading(loading);
if (!data || !data.findGallery || !!error) { return; }
setGallery(data.findGallery);
}, [data, loading, error]);
if (!data || !data.findGallery || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
if (!!error) { return <>{error.message}</>; }
return (
<div style={{width: "75vw", margin: "0 auto"}}>
<GalleryViewer gallery={gallery as any} />
</div>
);
};

View file

@ -1,53 +0,0 @@
import { HTMLTable } from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import { QueryHookResult } from "react-apollo-hooks";
import { Link } from "react-router-dom";
import { FindGalleriesQuery, FindGalleriesVariables } from "../../core/generated-graphql";
import { ListHook } from "../../hooks/ListHook";
import { IBaseProps } from "../../models/base-props";
import { ListFilterModel } from "../../models/list-filter/filter";
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
interface IProps extends IBaseProps {}
export const GalleryList: FunctionComponent<IProps> = (props: IProps) => {
const listData = ListHook.useList({
filterMode: FilterMode.Galleries,
props,
renderContent,
});
function renderContent(result: QueryHookResult<FindGalleriesQuery, FindGalleriesVariables>, filter: ListFilterModel) {
if (!result.data || !result.data.findGalleries) { return; }
if (filter.displayMode === DisplayMode.Grid) {
return <h1>TODO</h1>;
} else if (filter.displayMode === DisplayMode.List) {
return (
<HTMLTable style={{margin: "0 auto"}}>
<thead>
<tr>
<th>Preview</th>
<th>Path</th>
</tr>
</thead>
<tbody>
{result.data.findGalleries.galleries.map((gallery) => (
<tr key={gallery.id}>
<td>
<Link to={`/galleries/${gallery.id}`}>
{gallery.files.length > 0 ? <img alt={gallery.title} src={`${gallery.files[0].path}?thumb=true`} /> : undefined}
</Link>
</td>
<td><Link to={`/galleries/${gallery.id}`}>{gallery.path} ({gallery.files.length} {gallery.files.length === 1 ? 'image' : 'images'})</Link></td>
</tr>
))}
</tbody>
</HTMLTable>
);
} else if (filter.displayMode === DisplayMode.Wall) {
return <h1>TODO</h1>;
}
}
return listData.template;
};

View file

@ -1,46 +0,0 @@
import React, { FunctionComponent, useState } from "react";
import Lightbox from "react-images";
import Gallery from "react-photo-gallery";
import * as GQL from "../../core/generated-graphql";
interface IProps {
gallery: GQL.GalleryDataFragment;
}
export const GalleryViewer: FunctionComponent<IProps> = (props: IProps) => {
const [currentImage, setCurrentImage] = useState<number>(0);
const [lightboxIsOpen, setLightboxIsOpen] = useState<boolean>(false);
function openLightbox(event: any, obj: any) {
setCurrentImage(obj.index);
setLightboxIsOpen(true);
}
function closeLightbox() {
setCurrentImage(0);
setLightboxIsOpen(false);
}
function gotoPrevious() {
setCurrentImage(currentImage - 1);
}
function gotoNext() {
setCurrentImage(currentImage + 1);
}
const photos = props.gallery.files.map((file) => ({src: file.path || "", caption: file.name}));
const thumbs = props.gallery.files.map((file) => ({src: `${file.path}?thumb=true` || "", width: 1, height: 1}));
return (
<div>
<Gallery photos={thumbs} columns={15} onClick={openLightbox} />
<Lightbox
images={photos}
onClose={closeLightbox}
onClickPrev={gotoPrevious}
onClickNext={gotoNext}
currentImage={currentImage}
isOpen={lightboxIsOpen}
onClickImage={() => window.open(photos[currentImage].src, "_blank")}
width={9999}
/>
</div>
);
};

View file

@ -1,92 +0,0 @@
import {
Navbar,
NavbarDivider,
NavbarGroup,
NavbarHeading,
Button,
} from "@blueprintjs/core";
import React, { FunctionComponent, useEffect, useState } from "react";
import { Link, NavLink } from "react-router-dom";
import useLocation from "react-use/lib/useLocation";
import { IMenuItem } from "../App";
interface IProps {
onMenuToggle() : void
menuItems: IMenuItem[]
}
export const MainNavbar: FunctionComponent<IProps> = (props) => {
const [newButtonPath, setNewButtonPath] = useState<string | undefined>(undefined);
const locationState = useLocation();
useEffect(() => {
switch (window.location.pathname) {
case "/performers": {
setNewButtonPath("/performers/new");
break;
}
case "/studios": {
setNewButtonPath("/studios/new");
break;
}
case "/movies": {
setNewButtonPath("/movies/new");
break;
}
default: {
setNewButtonPath(undefined);
}
}
}, [locationState.pathname]);
function renderNewButton() {
if (!newButtonPath) { return; }
return (
<>
<NavLink
to={newButtonPath}
className="bp3-button bp3-intent-primary"
>
New
</NavLink>
<NavbarDivider />
</>
);
}
return (
<>
<Navbar fixedToTop={true}>
<div>
<NavbarGroup align="left">
<Button className="menu-button" icon="menu" onClick={() => props.onMenuToggle()}/>
<NavbarHeading><Link to="/" className="bp3-button bp3-minimal">Stash</Link></NavbarHeading>
<NavbarDivider />
{props.menuItems.map((i) => {
return (
<NavLink
exact={true}
to={i.href}
className={"bp3-button bp3-minimal collapsible-navlink bp3-icon-" + i.icon}
activeClassName="bp3-active"
>
{i.text}
</NavLink>
);
})}
</NavbarGroup>
<NavbarGroup align="right">
{renderNewButton()}
<NavLink
exact={true}
to="/settings"
className="bp3-button bp3-minimal bp3-icon-cog"
activeClassName="bp3-active"
/>
</NavbarGroup>
</div>
</Navbar>
</>
);
};

View file

@ -1,67 +0,0 @@
import {
Card,
Elevation,
H4,
} from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql";
import { ColorUtils } from "../../utils/color";
interface IProps {
movie: GQL.MovieDataFragment;
sceneIndex?: string;
// scene: GQL.SceneDataFragment;
}
export const MovieCard: FunctionComponent<IProps> = (props: IProps) => {
function maybeRenderRatingBanner() {
if (!props.movie.rating) { return; }
return (
<div className={`rating-banner ${ColorUtils.classForRating(parseInt(props.movie.rating,10))}`}>
RATING: {props.movie.rating}
</div>
);
}
function maybeRenderSceneNumber() {
if (!props.sceneIndex) {
return (
<div className="card-section">
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
{props.movie.name}
</H4>
<span className="bp3-text-muted block">{props.movie.scene_count} scenes.</span>
</div>
);
} else {
return (
<div className="card-section">
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
{props.movie.name}
</H4>
<span className="bp3-text-muted block">Scene number: {props.sceneIndex}</span>
</div>
);
}
}
return (
<Card
className="grid-item"
elevation={Elevation.ONE}
>
<Link
to={`/movies/${props.movie.id}`}
className="movie previewable image"
style={{backgroundImage: `url(${props.movie.front_image_path})`}}
>
{maybeRenderRatingBanner()}
</Link>
{maybeRenderSceneNumber()}
</Card>
);
};

View file

@ -1,205 +0,0 @@
import {
EditableText,
HTMLTable,
Spinner,
} from "@blueprintjs/core";
import React, { FunctionComponent, useEffect, useState } from "react";
import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
import { IBaseProps } from "../../../models";
import { ErrorUtils } from "../../../utils/errors";
import { TableUtils } from "../../../utils/table";
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
import { ImageUtils } from "../../../utils/image";
interface IProps extends IBaseProps {}
export const Movie: FunctionComponent<IProps> = (props: IProps) => {
const isNew = props.match.params.id === "new";
// Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew);
// Editing movie state
const [front_image, setFrontImage] = useState<string | undefined>(undefined);
const [back_image, setBackImage] = useState<string | undefined>(undefined);
const [name, setName] = useState<string | undefined>(undefined);
const [aliases, setAliases] = useState<string | undefined>(undefined);
const [duration, setDuration] = useState<string | undefined>(undefined);
const [date, setDate] = useState<string | undefined>(undefined);
const [rating, setRating] = useState<string | undefined>(undefined);
const [director, setDirector] = useState<string | undefined>(undefined);
const [synopsis, setSynopsis] = useState<string | undefined>(undefined);
const [url, setUrl] = useState<string | undefined>(undefined);
// Movie state
const [movie, setMovie] = useState<Partial<GQL.MovieDataFragment>>({});
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined);
const [backimagePreview, setBackImagePreview] = useState<string | undefined>(undefined);
// Network state
const [isLoading, setIsLoading] = useState(false);
const { data, error, loading } = StashService.useFindMovie(props.match.params.id);
const updateMovie = StashService.useMovieUpdate(getMovieInput() as GQL.MovieUpdateInput);
const createMovie = StashService.useMovieCreate(getMovieInput() as GQL.MovieCreateInput);
const deleteMovie = StashService.useMovieDestroy(getMovieInput() as GQL.MovieDestroyInput);
function updateMovieEditState(state: Partial<GQL.MovieDataFragment>) {
setName(state.name);
setAliases(state.aliases);
setDuration(state.duration);
setDate(state.date);
setRating(state.rating);
setDirector(state.director);
setSynopsis(state.synopsis);
setUrl(state.url);
}
useEffect(() => {
setIsLoading(loading);
if (!data || !data.findMovie || !!error) { return; }
setMovie(data.findMovie);
}, [data, loading, error]);
useEffect(() => {
setImagePreview(movie.front_image_path);
setBackImagePreview(movie.back_image_path);
setFrontImage(undefined);
setBackImage(undefined);
updateMovieEditState(movie);
if (!isNew) {
setIsEditing(false);
}
}, [movie, isNew]);
function onImageLoad(this: FileReader) {
setImagePreview(this.result as string);
setFrontImage(this.result as string);
}
function onBackImageLoad(this: FileReader) {
setBackImagePreview(this.result as string);
setBackImage(this.result as string);
}
ImageUtils.addPasteImageHook(onImageLoad);
ImageUtils.addPasteImageHook(onBackImageLoad);
if (!isNew && !isEditing) {
if (!data || !data.findMovie || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
if (!!error) { return <>error...</>; }
}
function getMovieInput() {
const input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
name,
aliases,
duration,
date,
rating,
director,
synopsis,
url,
front_image,
back_image
};
if (!isNew) {
(input as GQL.MovieUpdateInput).id = props.match.params.id;
}
return input;
}
async function onSave() {
setIsLoading(true);
try {
if (!isNew) {
const result = await updateMovie();
setMovie(result.data.movieUpdate);
} else {
const result = await createMovie();
setMovie(result.data.movieCreate);
props.history.push(`/movies/${result.data.movieCreate.id}`);
}
} catch (e) {
ErrorUtils.handle(e);
}
setIsLoading(false);
}
async function onDelete() {
setIsLoading(true);
try {
await deleteMovie();
} catch (e) {
ErrorUtils.handle(e);
}
setIsLoading(false);
// redirect to movies page
props.history.push(`/movies`);
}
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
function onBackImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onBackImageLoad);
}
// TODO: CSS class
return (
<>
<div className="columns is-multiline no-spacing">
<div className="column is-half details-image-container">
<img alt={name} className="movie" src={imagePreview} />
<img alt={name} className="movie" src={backimagePreview} />
</div>
<div className="column is-half details-detail-container">
<DetailsEditNavbar
movie={movie}
isNew={isNew}
isEditing={isEditing}
onToggleEdit={() => { setIsEditing(!isEditing); updateMovieEditState(movie); }}
onSave={onSave}
onDelete={onDelete}
onImageChange={onImageChange}
onBackImageChange={onBackImageChange}
/>
<h1 className="bp3-heading">
<EditableText
disabled={!isEditing}
value={name}
placeholder="Name"
onChange={(value) => setName(value)}
/>
</h1>
<HTMLTable style={{width: "100%"}}>
<tbody>
{TableUtils.renderInputGroup({title: "Aliases", value: aliases, isEditing, onChange: setAliases})}
{TableUtils.renderInputGroup({title: "Duration", value: duration, isEditing, onChange: setDuration})}
{TableUtils.renderInputGroup({title: "Date (YYYY-MM-DD)", value: date, isEditing, onChange: setDate})}
{TableUtils.renderInputGroup({title: "Director", value: director, isEditing, onChange: setDirector})}
{TableUtils.renderHtmlSelect({
title: "Rating",
value: rating,
isEditing,
onChange: (value: string) => setRating(value),
selectOptions: ["","1","2","3","4","5"]
})}
{TableUtils.renderInputGroup({title: "URL", value: url, isEditing, onChange: setUrl})}
{TableUtils.renderTextArea({title: "Synopsis", value: synopsis, isEditing, onChange: setSynopsis})}
</tbody>
</HTMLTable>
</div>
</div>
</>
);
};

View file

@ -1,33 +0,0 @@
import React, { FunctionComponent } from "react";
import { QueryHookResult } from "react-apollo-hooks";
import { FindMoviesQuery, FindMoviesVariables } from "../../core/generated-graphql";
import { ListHook } from "../../hooks/ListHook";
import { IBaseProps } from "../../models/base-props";
import { ListFilterModel } from "../../models/list-filter/filter";
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
import { MovieCard } from "./MovieCard";
interface IProps extends IBaseProps {}
export const MovieList: FunctionComponent<IProps> = (props: IProps) => {
const listData = ListHook.useList({
filterMode: FilterMode.Movies,
props,
renderContent,
});
function renderContent(result: QueryHookResult<FindMoviesQuery, FindMoviesVariables>, filter: ListFilterModel) {
if (!result.data || !result.data.findMovies) { return; }
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="grid">
{result.data.findMovies.movies.map((movie) => (<MovieCard key={movie.id} movie={movie}/>))}
</div>
);
} else if (filter.displayMode === DisplayMode.List) {
return <h1>TODO</h1>;
}
}
return listData.template;
};

View file

@ -1,13 +0,0 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import { Movie } from "./MovieDetails/Movie";
import { MovieList } from "./MovieList";
const Movies = () => (
<Switch>
<Route exact={true} path="/movies" component={MovieList} />
<Route path="/movies/:id" component={Movie} />
</Switch>
);
export default Movies;

View file

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

View file

@ -1,50 +0,0 @@
import {
Card,
Tab,
Tabs,
} from "@blueprintjs/core";
import queryString from "query-string";
import React, { FunctionComponent, useEffect, useState } from "react";
import { IBaseProps } from "../../models";
import { SettingsAboutPanel } from "./SettingsAboutPanel";
import { SettingsConfigurationPanel } from "./SettingsConfigurationPanel";
import { SettingsInterfacePanel } from "./SettingsInterfacePanel";
import { SettingsLogsPanel } from "./SettingsLogsPanel";
import { SettingsTasksPanel } from "./SettingsTasksPanel/SettingsTasksPanel";
interface IProps extends IBaseProps {}
type TabId = "configuration" | "tasks" | "logs" | "about";
export const Settings: FunctionComponent<IProps> = (props: IProps) => {
const [tabId, setTabId] = useState<TabId>(getTabId());
useEffect(() => {
const location = Object.assign({}, props.history.location);
location.search = queryString.stringify({tab: tabId}, {encode: false});
props.history.replace(location);
}, [tabId, props.history]);
function getTabId(): TabId {
const queryParams = queryString.parse(props.location.search);
if (!queryParams.tab || typeof queryParams.tab !== "string") { return "tasks"; }
return queryParams.tab as TabId;
}
return (
<Card id="details-container">
<Tabs
renderActiveTabPanelOnly={true}
vertical={true}
onChange={(newId) => setTabId(newId as TabId)}
defaultSelectedTabId={getTabId()}
>
<Tab id="configuration" title="Configuration" panel={<SettingsConfigurationPanel />} />
<Tab id="interface" title="Interface Configuration" panel={<SettingsInterfacePanel />} />
<Tab id="tasks" title="Tasks" panel={<SettingsTasksPanel />} />
<Tab id="logs" title="Logs" panel={<SettingsLogsPanel />} />
<Tab id="about" title="About" panel={<SettingsAboutPanel />} />
</Tabs>
</Card>
);
};

View file

@ -1,110 +0,0 @@
import {
Button,
H4,
HTMLTable,
Spinner,
} from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import { StashService } from "../../core/StashService";
interface IProps { }
export const SettingsAboutPanel: FunctionComponent<IProps> = (props: IProps) => {
const { data, error, loading } = StashService.useVersion();
const { data: dataLatest, error: errorLatest, loading: loadingLatest, refetch, networkStatus } = StashService.useLatestVersion();
function maybeRenderTag() {
if (!data || !data.version || !data.version.version) { return; }
return (
<tr>
<td>Version:</td>
<td>{data.version.version}</td>
</tr>
);
}
function maybeRenderLatestVersion() {
if (!dataLatest || !dataLatest.latestversion || !dataLatest.latestversion.shorthash || !dataLatest.latestversion.url) { return; }
if (!data || !data.version || !data.version.hash) {
return (
<>{dataLatest.latestversion.shorthash}</>
);
}
if (data.version.hash !== dataLatest.latestversion.shorthash) {
return (
<>
<strong>{dataLatest.latestversion.shorthash} [NEW] </strong><a href={dataLatest.latestversion.url}>Download</a>
</>
);
}
return (
<>{dataLatest.latestversion.shorthash}</>
);
}
function renderLatestVersion() {
if (!data || !data.version || !data.version.version) { return; } //if there is no "version" latest version check is obviously not supported
return (
<HTMLTable>
<tbody>
<tr>
<td>Latest Version Build Hash: </td>
<td>{maybeRenderLatestVersion()} </td>
</tr>
<tr>
<td><Button onClick={() => refetch()} text="Check for new version" /></td>
</tr>
</tbody>
</HTMLTable>
);
}
function renderVersion() {
if (!data || !data.version) { return; }
return (
<>
<HTMLTable>
<tbody>
{maybeRenderTag()}
<tr>
<td>Build hash:</td>
<td>{data.version.hash}</td>
</tr>
<tr>
<td>Build time:</td>
<td>{data.version.build_time}</td>
</tr>
</tbody>
</HTMLTable>
</>
);
}
return (
<>
<H4>About</H4>
<HTMLTable>
<tbody>
<tr>
<td>Stash home at <a href="https://github.com/stashapp/stash" target="_blank">Github</a></td>
</tr>
<tr>
<td>Stash <a href="https://github.com/stashapp/stash/wiki" target="_blank">Wiki</a> page</td>
</tr>
<tr>
<td>Join our <a href="https://discord.gg/2TsNFKt" target="_blank">Discord</a> channel</td>
</tr>
<tr>
<td>Support us through <a href="https://opencollective.com/stashapp" target="_blank">Open Collective</a></td>
</tr>
</tbody>
</HTMLTable>
{!data || loading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
{!!error ? <span>{error.message}</span> : undefined}
{!!errorLatest ? <span>{errorLatest.message}</span> : undefined}
{renderVersion()}
{!dataLatest || loadingLatest || networkStatus === 4 ? <Spinner size={Spinner.SIZE_SMALL} /> : <>{renderLatestVersion()}</>}
</>
);
};

View file

@ -1,327 +0,0 @@
import {
AnchorButton,
Button,
Divider,
FormGroup,
H4,
InputGroup,
Spinner,
Checkbox,
HTMLSelect,
} from "@blueprintjs/core";
import React, { FunctionComponent, useEffect, useState } from "react";
import * as GQL from "../../core/generated-graphql";
import { StashService } from "../../core/StashService";
import { ErrorUtils } from "../../utils/errors";
import { ToastUtils } from "../../utils/toasts";
import { FolderSelect } from "../Shared/FolderSelect/FolderSelect";
interface IProps { }
export const SettingsConfigurationPanel: FunctionComponent<IProps> = (props: IProps) => {
// Editing config state
const [stashes, setStashes] = useState<string[]>([]);
const [databasePath, setDatabasePath] = useState<string | undefined>(undefined);
const [generatedPath, setGeneratedPath] = useState<string | undefined>(undefined);
const [maxTranscodeSize, setMaxTranscodeSize] = useState<GQL.StreamingResolutionEnum | undefined>(undefined);
const [maxStreamingTranscodeSize, setMaxStreamingTranscodeSize] = useState<GQL.StreamingResolutionEnum | undefined>(undefined);
const [forceMkv, setForceMkv] = useState<boolean>(false);
const [forceHevc, setForceHevc] = useState<boolean>(false);
const [username, setUsername] = useState<string | undefined>(undefined);
const [password, setPassword] = useState<string | undefined>(undefined);
const [logFile, setLogFile] = useState<string | undefined>();
const [logOut, setLogOut] = useState<boolean>(true);
const [logLevel, setLogLevel] = useState<string>("Info");
const [logAccess, setLogAccess] = useState<boolean>(true);
const [excludes, setExcludes] = useState<(string)[]>([]);
const [scraperUserAgent, setScraperUserAgent] = useState<string | undefined>(undefined);
const { data, error, loading } = StashService.useConfiguration();
const updateGeneralConfig = StashService.useConfigureGeneral({
stashes,
databasePath,
generatedPath,
maxTranscodeSize,
maxStreamingTranscodeSize,
forceMkv,
forceHevc,
username,
password,
logFile,
logOut,
logLevel,
logAccess,
excludes,
scraperUserAgent,
});
useEffect(() => {
if (!data || !data.configuration || !!error) { return; }
const conf = StashService.nullToUndefined(data.configuration) as GQL.ConfigDataFragment;
if (!!conf.general) {
setStashes(conf.general.stashes || []);
setDatabasePath(conf.general.databasePath);
setGeneratedPath(conf.general.generatedPath);
setMaxTranscodeSize(conf.general.maxTranscodeSize);
setMaxStreamingTranscodeSize(conf.general.maxStreamingTranscodeSize);
setForceMkv(conf.general.forceMkv);
setForceHevc(conf.general.forceHevc);
setUsername(conf.general.username);
setPassword(conf.general.password);
setLogFile(conf.general.logFile);
setLogOut(conf.general.logOut);
setLogLevel(conf.general.logLevel);
setLogAccess(conf.general.logAccess);
setExcludes(conf.general.excludes);
setScraperUserAgent(conf.general.scraperUserAgent);
}
}, [data, error]);
function onStashesChanged(directories: string[]) {
setStashes(directories);
}
function excludeRegexChanged(idx: number, value: string) {
const newExcludes = excludes.map((regex, i) => {
const ret = (idx !== i) ? regex : value;
return ret
})
setExcludes(newExcludes);
}
function excludeRemoveRegex(idx: number) {
const newExcludes = excludes.filter((regex, i) => i !== idx);
setExcludes(newExcludes);
}
function excludeAddRegex() {
const demo = "sample\\.mp4$"
const newExcludes = excludes.concat(demo);
setExcludes(newExcludes);
}
async function onSave() {
try {
const result = await updateGeneralConfig();
console.log(result);
ToastUtils.success("Updated config");
} catch (e) {
ErrorUtils.handle(e);
}
}
const transcodeQualities = [
GQL.StreamingResolutionEnum.Low,
GQL.StreamingResolutionEnum.Standard,
GQL.StreamingResolutionEnum.StandardHd,
GQL.StreamingResolutionEnum.FullHd,
GQL.StreamingResolutionEnum.FourK,
GQL.StreamingResolutionEnum.Original
].map(resolutionToString);
function resolutionToString(r: GQL.StreamingResolutionEnum | undefined) {
switch (r) {
case GQL.StreamingResolutionEnum.Low: return "240p";
case GQL.StreamingResolutionEnum.Standard: return "480p";
case GQL.StreamingResolutionEnum.StandardHd: return "720p";
case GQL.StreamingResolutionEnum.FullHd: return "1080p";
case GQL.StreamingResolutionEnum.FourK: return "4k";
case GQL.StreamingResolutionEnum.Original: return "Original";
}
return "Original";
}
function translateQuality(quality: string) {
switch (quality) {
case "240p": return GQL.StreamingResolutionEnum.Low;
case "480p": return GQL.StreamingResolutionEnum.Standard;
case "720p": return GQL.StreamingResolutionEnum.StandardHd;
case "1080p": return GQL.StreamingResolutionEnum.FullHd;
case "4k": return GQL.StreamingResolutionEnum.FourK;
case "Original": return GQL.StreamingResolutionEnum.Original;
}
return GQL.StreamingResolutionEnum.Original;
}
return (
<>
{!!error ? <h1>{error.message}</h1> : undefined}
{(!data || !data.configuration || loading) ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
<H4>Library</H4>
<FormGroup>
<FormGroup>
<FormGroup
label="Stashes"
helperText="Directory locations to your content"
>
<FolderSelect
directories={stashes}
onDirectoriesChanged={onStashesChanged}
/>
</FormGroup>
</FormGroup>
<FormGroup
label="Database Path"
helperText="File location for the SQLite database (requires restart)"
>
<InputGroup value={databasePath} onChange={(e: any) => setDatabasePath(e.target.value)} />
</FormGroup>
<FormGroup
label="Generated Path"
helperText="Directory location for the generated files (scene markers, scene previews, sprites, etc)"
>
<InputGroup value={generatedPath} onChange={(e: any) => setGeneratedPath(e.target.value)} />
</FormGroup>
<FormGroup
label="Excluded Patterns"
>
{(excludes) ? excludes.map((regexp, i) => {
return (
<InputGroup
value={regexp}
onChange={(e: any) => excludeRegexChanged(i, e.target.value)}
rightElement={<Button icon="minus" minimal={true} intent="danger" onClick={(e: any) => excludeRemoveRegex(i)} />}
/>
);
}) : null
}
<Button icon="plus" minimal={true} onClick={(e: any) => excludeAddRegex()} />
<div>
<p>
<AnchorButton
href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration"
rightIcon="help"
text="Regexps of files/paths to exclude from Scan and add to Clean"
minimal={true}
target="_blank"
/>
</p>
</div>
</FormGroup>
</FormGroup>
<Divider />
<FormGroup>
<H4>Video</H4>
<FormGroup
label="Maximum transcode size"
helperText="Maximum size for generated transcodes"
>
<HTMLSelect
options={transcodeQualities}
onChange={(event) => setMaxTranscodeSize(translateQuality(event.target.value))}
value={resolutionToString(maxTranscodeSize)}
/>
</FormGroup>
<FormGroup
label="Maximum streaming transcode size"
helperText="Maximum size for transcoded streams"
>
<HTMLSelect
options={transcodeQualities}
onChange={(event) => setMaxStreamingTranscodeSize(translateQuality(event.target.value))}
value={resolutionToString(maxStreamingTranscodeSize)}
/>
</FormGroup>
<FormGroup
helperText="Treat Matroska (MKV) as a supported container. Recommended for Chromium based browsers"
>
<Checkbox
checked={forceMkv}
label="Force Matroska as supported"
onChange={() => setForceMkv(!forceMkv)}
/>
</FormGroup>
<FormGroup
helperText="Treat HEVC as a supported codec. Recommended for Safari or some Android based browsers"
>
<Checkbox
checked={forceHevc}
label="Force HEVC as supported"
onChange={() => setForceHevc(!forceHevc)}
/>
</FormGroup>
</FormGroup>
<Divider />
<FormGroup>
<H4>Scraping</H4>
<FormGroup
label="Scraper User-Agent string"
helperText="User-Agent string used during scrape http requests"
>
<InputGroup value={scraperUserAgent} onChange={(e: any) => setScraperUserAgent(e.target.value)} />
</FormGroup>
</FormGroup>
<Divider />
<FormGroup>
<H4>Authentication</H4>
<FormGroup
label="Username"
helperText="Username to access Stash. Leave blank to disable user authentication"
>
<InputGroup value={username} onChange={(e: any) => setUsername(e.target.value)} />
</FormGroup>
<FormGroup
label="Password"
helperText="Password to access Stash. Leave blank to disable user authentication"
>
<InputGroup type="password" value={password} onChange={(e: any) => setPassword(e.target.value)} />
</FormGroup>
</FormGroup>
<Divider />
<H4>Logging</H4>
<FormGroup
label="Log file"
helperText="Path to the file to output logging to. Blank to disable file logging. Requires restart."
>
<InputGroup value={logFile} onChange={(e: any) => setLogFile(e.target.value)} />
</FormGroup>
<FormGroup
helperText="Logs to the terminal in addition to a file. Always true if file logging is disabled. Requires restart."
>
<Checkbox
checked={logOut}
label="Log to terminal"
onChange={() => setLogOut(!logOut)}
/>
</FormGroup>
<FormGroup inline={true} label="Log Level">
<HTMLSelect
options={["Debug", "Info", "Warning", "Error"]}
onChange={(event) => setLogLevel(event.target.value)}
value={logLevel}
/>
</FormGroup>
<FormGroup
helperText="Logs http access to the terminal. Requires restart."
>
<Checkbox
checked={logAccess}
label="Log http access"
onChange={() => setLogAccess(!logAccess)}
/>
</FormGroup>
<Divider />
<Button intent="primary" onClick={() => onSave()}>Save</Button>
</>
);
};

View file

@ -1,144 +0,0 @@
import {
Button,
Checkbox,
Divider,
FormGroup,
H4,
Spinner,
TextArea,
NumericInput
} from "@blueprintjs/core";
import React, { FunctionComponent, useEffect, useState } from "react";
import { StashService } from "../../core/StashService";
import { ErrorUtils } from "../../utils/errors";
import { ToastUtils } from "../../utils/toasts";
interface IProps {}
export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
const config = StashService.useConfiguration();
const [soundOnPreview, setSoundOnPreview] = useState<boolean>();
const [wallShowTitle, setWallShowTitle] = useState<boolean>();
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
const [autostartVideo, setAutostartVideo] = useState<boolean>();
const [showStudioAsText, setShowStudioAsText] = useState<boolean>();
const [css, setCSS] = useState<string>();
const [cssEnabled, setCSSEnabled] = useState<boolean>();
const updateInterfaceConfig = StashService.useConfigureInterface({
soundOnPreview,
wallShowTitle,
maximumLoopDuration,
autostartVideo,
showStudioAsText,
css,
cssEnabled
});
useEffect(() => {
if (!config.data || !config.data.configuration || !!config.error) { return; }
if (!!config.data.configuration.interface) {
let iCfg = config.data.configuration.interface;
setSoundOnPreview(iCfg.soundOnPreview !== undefined ? iCfg.soundOnPreview : true);
setWallShowTitle(iCfg.wallShowTitle !== undefined ? iCfg.wallShowTitle : true);
setMaximumLoopDuration(iCfg.maximumLoopDuration || 0);
setAutostartVideo(iCfg.autostartVideo !== undefined ? iCfg.autostartVideo : false);
setShowStudioAsText(iCfg.showStudioAsText !== undefined ? iCfg.showStudioAsText : false);
setCSS(config.data.configuration.interface.css || "");
setCSSEnabled(config.data.configuration.interface.cssEnabled || false);
}
}, [config.data, config.error]);
async function onSave() {
try {
const result = await updateInterfaceConfig();
console.log(result);
ToastUtils.success("Updated config");
} catch (e) {
ErrorUtils.handle(e);
}
}
return (
<>
{!!config.error ? <h1>{config.error.message}</h1> : undefined}
{(!config.data || !config.data.configuration || config.loading) ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
<H4>User Interface</H4>
<FormGroup
label="Scene / Marker Wall"
helperText="Configuration for wall items"
>
<Checkbox
checked={wallShowTitle}
label="Display title and tags"
onChange={() => setWallShowTitle(!wallShowTitle)}
/>
<Checkbox
checked={soundOnPreview}
label="Enable sound"
onChange={() => setSoundOnPreview(!soundOnPreview)}
/>
</FormGroup>
<FormGroup
label="Scene List"
>
<Checkbox
checked={showStudioAsText}
label="Show Studios as text"
onChange={() => {
setShowStudioAsText(!showStudioAsText)
}}
/>
</FormGroup>
<FormGroup
label="Scene Player"
>
<Checkbox
checked={autostartVideo}
label="Auto-start video"
onChange={() => {
setAutostartVideo(!autostartVideo)
}}
/>
<FormGroup
label="Maximum loop duration"
helperText="Maximum scene duration - in seconds - where scene player will loop the video - 0 to disable"
>
<NumericInput
value={maximumLoopDuration}
type="number"
onValueChange={(value: number) => setMaximumLoopDuration(value)}
min={0}
minorStepSize={1}
/>
</FormGroup>
</FormGroup>
<FormGroup
label="Custom CSS"
helperText="Page must be reloaded for changes to take effect."
>
<Checkbox
checked={cssEnabled}
label="Custom CSS enabled"
onChange={() => {
setCSSEnabled(!cssEnabled)
}}
/>
<TextArea
value={css}
onChange={(e: any) => setCSS(e.target.value)}
fill={true}
rows={16}>
</TextArea>
</FormGroup>
<Divider />
<Button intent="primary" onClick={() => onSave()}>Save</Button>
</>
);
};

View file

@ -1,188 +0,0 @@
import {
H4, FormGroup, HTMLSelect,
} from "@blueprintjs/core";
import React, { FunctionComponent, useState, useEffect, useRef } from "react";
import * as GQL from "../../core/generated-graphql";
import { StashService } from "../../core/StashService";
interface IProps {}
function convertTime(logEntry : GQL.LogEntryDataFragment) {
function pad(val : number) {
var ret = val.toString();
if (val <= 9) {
ret = "0" + ret;
}
return ret;
}
var date = new Date(logEntry.time);
var month = date.getMonth() + 1;
var day = date.getDate();
var dateStr = date.getFullYear() + "-" + pad(month) + "-" + pad(day);
dateStr += " " + pad(date.getHours()) + ":" + pad(date.getMinutes()) + ":" + pad(date.getSeconds());
return dateStr;
}
class LogEntry {
public time: string;
public level: string;
public message: string;
public id: string;
private static nextId: number = 0;
public constructor(logEntry: GQL.LogEntryDataFragment) {
this.time = convertTime(logEntry);
this.level = logEntry.level;
this.message = logEntry.message;
var id = LogEntry.nextId++;
this.id = id.toString();
}
}
export const SettingsLogsPanel: FunctionComponent<IProps> = (props: IProps) => {
const { data, error } = StashService.useLoggingSubscribe();
const { data: existingData } = StashService.useLogs();
const logEntries = useRef<LogEntry[]>([]);
const [logLevel, setLogLevel] = useState<string>("Info");
const [filteredLogEntries, setFilteredLogEntries] = useState<LogEntry[]>([]);
const lastUpdate = useRef<number>(0);
const updateTimeout = useRef<NodeJS.Timeout>();
// maximum number of log entries to display. Subsequent entries will truncate
// the list, dropping off the oldest entries first.
const MAX_LOG_ENTRIES = 200;
function truncateLogEntries(entries : LogEntry[]) {
entries.length = Math.min(entries.length, MAX_LOG_ENTRIES);
}
function prependLogEntries(toPrepend : LogEntry[]) {
var newLogEntries = toPrepend.concat(logEntries.current);
truncateLogEntries(newLogEntries);
logEntries.current = newLogEntries;
}
function appendLogEntries(toAppend : LogEntry[]) {
var newLogEntries = logEntries.current.concat(toAppend);
truncateLogEntries(newLogEntries);
logEntries.current = newLogEntries;
}
useEffect(() => {
if (!data) { return; }
// append data to the logEntries
var convertedData = data.loggingSubscribe.map(convertLogEntry);
// filter subscribed data as it comes in, otherwise we'll end up
// truncating stuff that wasn't filtered out
convertedData = convertedData.filter(filterByLogLevel)
// put newest entries at the top
convertedData.reverse();
prependLogEntries(convertedData);
updateFilteredEntries();
}, [data]);
useEffect(() => {
if (!existingData || !existingData.logs) { return; }
var convertedData = existingData.logs.map(convertLogEntry);
appendLogEntries(convertedData);
updateFilteredEntries();
}, [existingData]);
function updateFilteredEntries() {
if (!updateTimeout.current) {
console.log("Updating after timeout");
}
updateTimeout.current = undefined;
var filteredEntries = logEntries.current.filter(filterByLogLevel);
setFilteredLogEntries(filteredEntries);
lastUpdate.current = new Date().getTime();
}
useEffect(() => {
updateFilteredEntries();
}, [logLevel]);
function convertLogEntry(logEntry : GQL.LogEntryDataFragment) {
return new LogEntry(logEntry);
}
function levelClass(level : string) {
return level.toLowerCase().trim();
}
interface ILogElementProps {
logEntry : LogEntry
}
function LogElement(props : ILogElementProps) {
// pad to maximum length of level enum
var level = props.logEntry.level.padEnd(GQL.LogLevel.Progress.length);
return (
<>
<span>{props.logEntry.time}</span>&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

@ -1,53 +0,0 @@
import {
Button,
Checkbox,
FormGroup,
} from "@blueprintjs/core";
import React, { FunctionComponent, useState } from "react";
import { StashService } from "../../../core/StashService";
import { ErrorUtils } from "../../../utils/errors";
import { ToastUtils } from "../../../utils/toasts";
interface IProps {}
export const GenerateButton: FunctionComponent<IProps> = () => {
const [sprites, setSprites] = useState<boolean>(true);
const [previews, setPreviews] = useState<boolean>(true);
const [markers, setMarkers] = useState<boolean>(true);
const [transcodes, setTranscodes] = useState<boolean>(true);
async function onGenerate() {
try {
await StashService.mutateMetadataGenerate({sprites, previews, markers, transcodes});
ToastUtils.success("Started generating");
} catch (e) {
ErrorUtils.handle(e);
}
}
return (
<FormGroup
helperText="Generate supporting image, sprite, video, vtt and other files."
labelFor="generate"
inline={true}
>
<Checkbox checked={sprites} label="Sprites (for the scene scrubber)" onChange={() => setSprites(!sprites)} />
<Checkbox
checked={previews}
label="Previews (video previews which play when hovering over a scene)"
onChange={() => setPreviews(!previews)}
/>
<Checkbox
checked={markers}
label="Markers (20 second videos which begin at the given timecode)"
onChange={() => setMarkers(!markers)}
/>
<Checkbox
checked={transcodes}
label="Transcodes (MP4 conversions of unsupported video formats)"
onChange={() => setTranscodes(!transcodes)}
/>
<Button id="generate" text="Generate" onClick={() => onGenerate()} />
</FormGroup>
);
};

View file

@ -1,271 +0,0 @@
import {
Alert,
Button,
Checkbox,
Divider,
FormGroup,
H4,
ProgressBar,
H5,
} from "@blueprintjs/core";
import React, { FunctionComponent, useState, useEffect } from "react";
import { StashService } from "../../../core/StashService";
import { ErrorUtils } from "../../../utils/errors";
import { ToastUtils } from "../../../utils/toasts";
import { GenerateButton } from "./GenerateButton";
import { Link } from "react-router-dom";
interface IProps {}
export const SettingsTasksPanel: FunctionComponent<IProps> = (props: IProps) => {
const [isImportAlertOpen, setIsImportAlertOpen] = useState<boolean>(false);
const [isCleanAlertOpen, setIsCleanAlertOpen] = useState<boolean>(false);
const [useFileMetadata, setUseFileMetadata] = useState<boolean>(false);
const [status, setStatus] = useState<string>("");
const [progress, setProgress] = useState<number | undefined>(undefined);
const [autoTagPerformers, setAutoTagPerformers] = useState<boolean>(true);
const [autoTagStudios, setAutoTagStudios] = useState<boolean>(true);
const [autoTagTags, setAutoTagTags] = useState<boolean>(true);
const jobStatus = StashService.useJobStatus();
const metadataUpdate = StashService.useMetadataUpdate();
function statusToText(status : string) {
switch(status) {
case "Idle":
return "Idle";
case "Scan":
return "Scanning for new content";
case "Generate":
return "Generating supporting files";
case "Clean":
return "Cleaning the database";
case "Export":
return "Exporting to JSON";
case "Import":
return "Importing from JSON";
case "Auto Tag":
return "Auto tagging scenes";
}
return "Idle";
}
useEffect(() => {
if (!!jobStatus.data && !!jobStatus.data.jobStatus) {
setStatus(statusToText(jobStatus.data.jobStatus.status));
var newProgress = jobStatus.data.jobStatus.progress;
if (newProgress < 0) {
setProgress(undefined);
} else {
setProgress(newProgress);
}
}
}, [jobStatus.data]);
useEffect(() => {
if (!!metadataUpdate.data && !!metadataUpdate.data.metadataUpdate) {
setStatus(statusToText(metadataUpdate.data.metadataUpdate.status));
var newProgress = metadataUpdate.data.metadataUpdate.progress;
if (newProgress < 0) {
setProgress(undefined);
} else {
setProgress(newProgress);
}
}
}, [metadataUpdate.data]);
function onImport() {
setIsImportAlertOpen(false);
StashService.mutateMetadataImport().then(() => { jobStatus.refetch()});
}
function renderImportAlert() {
return (
<Alert
cancelButtonText="Cancel"
confirmButtonText="Import"
icon="trash"
intent="danger"
isOpen={isImportAlertOpen}
onCancel={() => setIsImportAlertOpen(false)}
onConfirm={() => onImport()}
>
<p>
Are you sure you want to import? This will delete the database and re-import from
your exported metadata.
</p>
</Alert>
);
}
function onClean() {
setIsCleanAlertOpen(false);
StashService.mutateMetadataClean().then(() => { jobStatus.refetch()});
}
function renderCleanAlert() {
return (
<Alert
cancelButtonText="Cancel"
confirmButtonText="Clean"
icon="trash"
intent="danger"
isOpen={isCleanAlertOpen}
onCancel={() => setIsCleanAlertOpen(false)}
onConfirm={() => onClean()}
>
<p>
Are you sure you want to Clean?
This will delete db information and generated content
for all scenes that are no longer found in the filesystem.
</p>
</Alert>
);
}
async function onScan() {
try {
await StashService.mutateMetadataScan({useFileMetadata: useFileMetadata});
ToastUtils.success("Started scan");
jobStatus.refetch();
} catch (e) {
ErrorUtils.handle(e);
}
}
function getAutoTagInput() {
var wildcard = ["*"];
return {
performers: autoTagPerformers ? wildcard : [],
studios: autoTagStudios ? wildcard : [],
tags: autoTagTags ? wildcard : []
}
}
async function onAutoTag() {
try {
await StashService.mutateMetadataAutoTag(getAutoTagInput());
ToastUtils.success("Started auto tagging");
jobStatus.refetch();
} catch (e) {
ErrorUtils.handle(e);
}
}
function maybeRenderStop() {
if (!status || status === "Idle") {
return undefined;
}
return (
<>
<FormGroup>
<Button id="stop" text="Stop" intent="danger" onClick={() => StashService.mutateStopJob().then(() => jobStatus.refetch())} />
</FormGroup>
</>
);
}
function renderJobStatus() {
return (
<>
<FormGroup>
<H5>Status: {status}</H5>
{!!status && status !== "Idle" ? <ProgressBar value={progress}/> : undefined}
</FormGroup>
{maybeRenderStop()}
</>
);
}
return (
<>
{renderImportAlert()}
{renderCleanAlert()}
<H4>Running Jobs</H4>
{renderJobStatus()}
<Divider/>
<H4>Library</H4>
<FormGroup
helperText="Scan for new content and add it to the database."
labelFor="scan"
inline={true}
>
<Checkbox
checked={useFileMetadata}
label="Set name, date, details from metadata (if present)"
onChange={() => setUseFileMetadata(!useFileMetadata)}
/>
<Button id="scan" text="Scan" onClick={() => onScan()} />
</FormGroup>
<Divider />
<H4>Auto Tagging</H4>
<FormGroup
helperText="Auto-tag content based on filenames."
labelFor="autoTag"
inline={true}
>
<Checkbox
checked={autoTagPerformers}
label="Performers"
onChange={() => setAutoTagPerformers(!autoTagPerformers)}
/>
<Checkbox
checked={autoTagStudios}
label="Studios"
onChange={() => setAutoTagStudios(!autoTagStudios)}
/>
<Checkbox
checked={autoTagTags}
label="Tags"
onChange={() => setAutoTagTags(!autoTagTags)}
/>
<Button id="autoTag" text="Auto Tag" onClick={() => onAutoTag()} />
</FormGroup>
<FormGroup>
<Link className="bp3-button" to={"/sceneFilenameParser"}>
Scene Filename Parser
</Link>
</FormGroup>
<Divider />
<H4>Generated Content</H4>
<GenerateButton />
<FormGroup
helperText="Check for missing files and remove them from the database. This is a destructive action."
labelFor="clean"
inline={true}
>
<Button id="clean" text="Clean" intent="danger" onClick={() => setIsCleanAlertOpen(true)} />
</FormGroup>
<Divider />
<H4>Metadata</H4>
<FormGroup
helperText="Export the database content into JSON format"
labelFor="export"
inline={true}
>
<Button id="export" text="Export" onClick={() => StashService.mutateMetadataExport().then(() => { jobStatus.refetch()})} />
</FormGroup>
<FormGroup
helperText="Import from exported JSON. This is a destructive action."
labelFor="import"
inline={true}
>
<Button id="import" text="Import" intent="danger" onClick={() => setIsImportAlertOpen(true)} />
</FormGroup>
</>
);
};

View file

@ -1,169 +0,0 @@
import {
Alert,
Button,
FileInput,
Menu,
MenuItem,
Navbar,
NavbarDivider,
Popover,
} from "@blueprintjs/core";
import React, { FunctionComponent, useState } from "react";
import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql";
import { NavigationUtils } from "../../utils/navigation";
interface IProps {
performer?: Partial<GQL.PerformerDataFragment>;
studio?: Partial<GQL.StudioDataFragment>;
movie?: Partial<GQL.MovieDataFragment>;
isNew: boolean;
isEditing: boolean;
onToggleEdit: () => void;
onSave: () => void;
onDelete: () => void;
onAutoTag?: () => void;
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
onBackImageChange?: (event: React.FormEvent<HTMLInputElement>) => void;
// TODO: only for performers. make generic
scrapers?: GQL.ListPerformerScrapersListPerformerScrapers[];
onDisplayScraperDialog?: (scraper: GQL.ListPerformerScrapersListPerformerScrapers) => void;
}
export const DetailsEditNavbar: FunctionComponent<IProps> = (props: IProps) => {
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
function renderEditButton() {
if (props.isNew) { return; }
return (
<Button
intent="primary"
text={props.isEditing ? "Cancel" : "Edit"}
onClick={() => props.onToggleEdit()}
/>
);
}
function renderSaveButton() {
if (!props.isEditing) { return; }
return <Button intent="success" text="Save" onClick={() => props.onSave()} />;
}
function renderDeleteButton() {
if (props.isNew || props.isEditing) { return; }
return <Button intent="danger" text="Delete" onClick={() => setIsDeleteAlertOpen(true)} />;
}
function renderImageInput() {
if (!props.isEditing) { return; }
return <FileInput text="Choose image..." onInputChange={props.onImageChange} inputProps={{accept: ".jpg,.jpeg"}} />;
}
function renderBackImageInput() {
if (!props.movie) { return; }
if (!props.isEditing) { return; }
return <FileInput text="Choose back image..." onInputChange={props.onBackImageChange} inputProps={{accept: ".jpg,.jpeg"}} />;
}
function renderScraperMenuItem(scraper : GQL.ListPerformerScrapersListPerformerScrapers) {
return (
<MenuItem
text={scraper.name}
onClick={() => { if (props.onDisplayScraperDialog) { props.onDisplayScraperDialog(scraper); }}}
/>
);
}
function renderScraperMenu() {
if (!props.performer) { return; }
if (!props.isEditing) { return; }
const scraperMenu = (
<Menu>
{props.scrapers ? props.scrapers.map((s) => renderScraperMenuItem(s)) : undefined}
</Menu>
);
return (
<Popover content={scraperMenu} position="bottom">
<Button text="Scrape with..."/>
</Popover>
);
}
function renderAutoTagButton() {
if (props.isNew || props.isEditing) { return; }
if (!!props.onAutoTag) {
return (<Button text="Auto Tag" onClick={() => {
if (props.onAutoTag) { props.onAutoTag() }
}}></Button>)
}
}
function renderScenesButton() {
if (props.isEditing) { return; }
let linkSrc: string = "#";
if (!!props.performer) {
linkSrc = NavigationUtils.makePerformerScenesUrl(props.performer);
} else if (!!props.studio) {
linkSrc = NavigationUtils.makeStudioScenesUrl(props.studio);
} else if (!!props.movie) {
linkSrc = NavigationUtils.makeMovieScenesUrl(props.movie);
}
return (
<Link className="bp3-button" to={linkSrc}>
Scenes
</Link>
);
}
function renderDeleteAlert() {
var name;
if (props.performer) {
name = props.performer.name;
}
if (props.studio) {
name = props.studio.name;
}
if (props.movie) {
name = props.movie.name;
}
return (
<Alert
cancelButtonText="Cancel"
confirmButtonText="Delete"
icon="trash"
intent="danger"
isOpen={isDeleteAlertOpen}
onCancel={() => setIsDeleteAlertOpen(false)}
onConfirm={() => props.onDelete()}
>
<p>
Are you sure you want to delete {name}?
</p>
</Alert>
);
}
return (
<>
{renderDeleteAlert()}
<Navbar>
<Navbar.Group>
{renderEditButton()}
{props.isEditing && !props.isNew ? <NavbarDivider /> : undefined}
{renderScraperMenu()}
{renderImageInput()}
{renderBackImageInput()}
{renderSaveButton()}
{renderAutoTagButton()}
{renderScenesButton()}
{renderDeleteButton()}
</Navbar.Group>
</Navbar>
</>
);
};

View file

@ -1,82 +0,0 @@
import React, { FunctionComponent, useState, useEffect } from "react";
import { InputGroup, ButtonGroup, Button, HTMLInputProps, ControlGroup } from "@blueprintjs/core";
import { DurationUtils } from "../../utils/duration";
import { FIXED, NUMERIC_INPUT } from "@blueprintjs/core/lib/esm/common/classes";
interface IProps {
disabled?: boolean
numericValue: number
onValueChange(valueAsNumber: number): void
onReset?(): void
}
export const DurationInput: FunctionComponent<HTMLInputProps & IProps> = (props: IProps) => {
const [value, setValue] = useState<string>(DurationUtils.secondsToString(props.numericValue));
useEffect(() => {
setValue(DurationUtils.secondsToString(props.numericValue));
}, [props.numericValue]);
function increment() {
let seconds = DurationUtils.stringToSeconds(value);
seconds += 1;
props.onValueChange(seconds);
}
function decrement() {
let seconds = DurationUtils.stringToSeconds(value);
seconds -= 1;
props.onValueChange(seconds);
}
function renderButtons() {
return (
<ButtonGroup
vertical={true}
className={FIXED}
>
<Button
icon="chevron-up"
disabled={props.disabled}
onClick={() => increment()}
/>
<Button
icon="chevron-down"
disabled={props.disabled}
onClick={() => decrement()}
/>
</ButtonGroup>
)
}
function onReset() {
if (props.onReset) {
props.onReset();
}
}
function maybeRenderReset() {
if (props.onReset) {
return (
<Button
icon="time"
onClick={() => onReset()}
/>
)
}
}
return (
<ControlGroup className={NUMERIC_INPUT}>
<InputGroup
disabled={props.disabled}
value={value}
onChange={(e : any) => setValue(e.target.value)}
onBlur={() => props.onValueChange(DurationUtils.stringToSeconds(value))}
placeholder="hh:mm:ss"
rightElement={maybeRenderReset()}
/>
{renderButtons()}
</ControlGroup>
)
};

View file

@ -1,88 +0,0 @@
import {
Button,
Classes,
Dialog,
InputGroup,
Spinner,
FormGroup,
} from "@blueprintjs/core";
import React, { FunctionComponent, useEffect, useState } from "react";
import { StashService } from "../../../core/StashService";
interface IProps {
directories: string[];
onDirectoriesChanged: (directories: string[]) => void;
}
export const FolderSelect: FunctionComponent<IProps> = (props: IProps) => {
const [currentDirectory, setCurrentDirectory] = useState<string>("");
const [isDisplayingDialog, setIsDisplayingDialog] = useState<boolean>(false);
const [selectableDirectories, setSelectableDirectories] = useState<string[]>([]);
const [selectedDirectories, setSelectedDirectories] = useState<string[]>([]);
const { data, error, loading } = StashService.useDirectories(currentDirectory);
useEffect(() => {
setSelectedDirectories(props.directories);
}, [props.directories]);
useEffect(() => {
if (!data || !data.directories || !!error) { return; }
setSelectableDirectories(StashService.nullToUndefined(data.directories));
}, [data, error]);
function onSelectDirectory() {
selectedDirectories.push(currentDirectory);
setSelectedDirectories(selectedDirectories);
setCurrentDirectory("");
setIsDisplayingDialog(false);
props.onDirectoriesChanged(selectedDirectories);
}
function onRemoveDirectory(directory: string) {
const newSelectedDirectories = selectedDirectories.filter((dir) => dir !== directory);
setSelectedDirectories(newSelectedDirectories);
props.onDirectoriesChanged(newSelectedDirectories);
}
function renderDialog() {
return (
<Dialog
isOpen={isDisplayingDialog}
onClose={() => setIsDisplayingDialog(false)}
title="Select Directory"
>
<div className="dialog-content">
<InputGroup
large={true}
placeholder="File path"
onChange={(e: any) => setCurrentDirectory(e.target.value)}
value={currentDirectory}
rightElement={(!data || !data.directories || loading) ? <Spinner size={Spinner.SIZE_SMALL} /> : undefined}
/>
{selectableDirectories.map((path) => {
return <div key={path} onClick={() => setCurrentDirectory(path)}>{path}</div>;
})}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={() => onSelectDirectory()}>Add</Button>
</div>
</div>
</Dialog>
);
}
return (
<>
{!!error ? <h1>{error.message}</h1> : undefined}
{renderDialog()}
<FormGroup>
{selectedDirectories.map((path) => {
return <div key={path}>{path} <button className="button-link" onClick={() => onRemoveDirectory(path)}>Remove</button></div>;
})}
</FormGroup>
<Button small={true} onClick={() => setIsDisplayingDialog(true)}>Add Directory</Button>
</>
);
};

View file

@ -1,42 +0,0 @@
import {
ITagProps,
Tag,
} from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import { Link } from "react-router-dom";
import { MovieDataFragment,PerformerDataFragment, SceneMarkerDataFragment, TagDataFragment } from "../../core/generated-graphql";
import { NavigationUtils } from "../../utils/navigation";
import { TextUtils } from "../../utils/text";
interface IProps extends ITagProps {
tag?: Partial<TagDataFragment>;
performer?: Partial<PerformerDataFragment>;
movie?: Partial<MovieDataFragment>;
marker?: Partial<SceneMarkerDataFragment>;
}
export const TagLink: FunctionComponent<IProps> = (props: IProps) => {
let link: string = "#";
let title: string = "";
if (!!props.tag) {
link = NavigationUtils.makeTagScenesUrl(props.tag);
title = props.tag.name || "";
} else if (!!props.performer) {
link = NavigationUtils.makePerformerScenesUrl(props.performer);
title = props.performer.name || "";
} else if (!!props.movie) {
link = NavigationUtils.makeMovieScenesUrl(props.movie);
title = props.movie.name || "";
} else if (!!props.marker) {
link = NavigationUtils.makeSceneMarkerUrl(props.marker);
title = `${props.marker.title} - ${TextUtils.secondsToTimestamp(props.marker.seconds || 0)}`;
}
return (
<Tag
className="tag-item"
interactive={true}
>
<Link to={link}>{title}</Link>
</Tag>
);
};

View file

@ -1,31 +0,0 @@
import {
MenuItem,
Menu,
} from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import { IMenuItem } from "../App";
interface IProps {
className: string
menuItems: IMenuItem[]
}
export const Sidebar: FunctionComponent<IProps> = (props) => {
return (
<>
<div className={"sidebar" + props.className}>
<Menu large={true}>
{props.menuItems.map((i) => {
return (
<MenuItem
icon={i.icon}
text={i.text}
href={i.href}
/>
)
})}
</Menu>
</div>
</>
);
};

View file

@ -1,65 +0,0 @@
import { Spinner } from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import { StashService } from "../core/StashService";
export const Stats: FunctionComponent = () => {
const { data, error, loading } = StashService.useStats();
function renderStats() {
if (!data || !data.stats) { return; }
return (
<nav id="details-container" className="level stats">
<div className="level-item has-text-centered">
<div>
<p className="title">{data.stats.scene_size_count}</p>
<p className="heading">Library size</p>
</div>
</div>
<div className="level-item has-text-centered">
<div>
<p className="title">{data.stats.scene_count}</p>
<p className="heading">Scenes</p>
</div>
</div>
<div className="level-item has-text-centered">
<div>
<p className="title">{data.stats.movie_count}</p>
<p className="heading">Movies</p>
</div>
</div>
<div className="level-item has-text-centered">
<div>
<p className="title">{data.stats.gallery_count}</p>
<p className="heading">Galleries</p>
</div>
</div>
<div className="level-item has-text-centered">
<div>
<p className="title">{data.stats.performer_count}</p>
<p className="heading">Performers</p>
</div>
</div>
<div className="level-item has-text-centered">
<div>
<p className="title">{data.stats.studio_count}</p>
<p className="heading">Studios</p>
</div>
</div>
<div className="level-item has-text-centered">
<div>
<p className="title">{data.stats.tag_count}</p>
<p className="heading">Tags</p>
</div>
</div>
</nav>
);
}
return (
<div id="details-container">
{!data || loading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
{!!error ? <span>error.message</span> : undefined}
{renderStats()}
</div>
);
};

View file

@ -1,33 +0,0 @@
import {
Card,
Elevation,
H4,
} from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql";
interface IProps {
studio: GQL.StudioDataFragment;
}
export const StudioCard: FunctionComponent<IProps> = (props: IProps) => {
return (
<Card
className="grid-item"
elevation={Elevation.ONE}
>
<Link
to={`/studios/${props.studio.id}`}
className="studio previewable image"
style={{backgroundImage: `url(${props.studio.image_path})`}}
/>
<div className="card-section">
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
{props.studio.name}
</H4>
<span className="bp3-text-muted block">{props.studio.scene_count} scenes.</span>
</div>
</Card>
);
};

View file

@ -1,172 +0,0 @@
import {
EditableText,
HTMLTable,
Spinner,
} from "@blueprintjs/core";
import React, { FunctionComponent, useEffect, useState } from "react";
import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
import { IBaseProps } from "../../../models";
import { ErrorUtils } from "../../../utils/errors";
import { TableUtils } from "../../../utils/table";
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
import { ToastUtils } from "../../../utils/toasts";
import { ImageUtils } from "../../../utils/image";
interface IProps extends IBaseProps {}
export const Studio: FunctionComponent<IProps> = (props: IProps) => {
const isNew = props.match.params.id === "new";
// Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew);
// Editing studio state
const [image, setImage] = useState<string | undefined>(undefined);
const [name, setName] = useState<string | undefined>(undefined);
const [url, setUrl] = useState<string | undefined>(undefined);
// Studio state
const [studio, setStudio] = useState<Partial<GQL.StudioDataFragment>>({});
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined);
// Network state
const [isLoading, setIsLoading] = useState(false);
const { data, error, loading } = StashService.useFindStudio(props.match.params.id);
const updateStudio = StashService.useStudioUpdate(getStudioInput() as GQL.StudioUpdateInput);
const createStudio = StashService.useStudioCreate(getStudioInput() as GQL.StudioCreateInput);
const deleteStudio = StashService.useStudioDestroy(getStudioInput() as GQL.StudioDestroyInput);
function updateStudioEditState(state: Partial<GQL.StudioDataFragment>) {
setName(state.name);
setUrl(state.url);
}
useEffect(() => {
setIsLoading(loading);
if (!data || !data.findStudio || !!error) { return; }
setStudio(data.findStudio);
}, [data, loading, error]);
useEffect(() => {
setImagePreview(studio.image_path);
setImage(undefined);
updateStudioEditState(studio);
if (!isNew) {
setIsEditing(false);
}
}, [studio, isNew]);
function onImageLoad(this: FileReader) {
setImagePreview(this.result as string);
setImage(this.result as string);
}
ImageUtils.addPasteImageHook(onImageLoad);
if (!isNew && !isEditing) {
if (!data || !data.findStudio || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
if (!!error) { return <>error...</>; }
}
function getStudioInput() {
const input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput> = {
name,
url,
image,
};
if (!isNew) {
(input as GQL.StudioUpdateInput).id = props.match.params.id;
}
return input;
}
async function onSave() {
setIsLoading(true);
try {
if (!isNew) {
const result = await updateStudio();
if (image) {
// Refetch image to bust browser cache
await fetch(`/studio/${result.data.studioUpdate.id}/image`, { cache: "reload" });
}
setStudio(result.data.studioUpdate);
} else {
const result = await createStudio();
setStudio(result.data.studioCreate);
props.history.push(`/studios/${result.data.studioCreate.id}`);
}
} catch (e) {
ErrorUtils.handle(e);
}
setIsLoading(false);
}
async function onAutoTag() {
if (!studio || !studio.id) {
return;
}
try {
await StashService.mutateMetadataAutoTag({ studios: [studio.id]});
ToastUtils.success("Started auto tagging");
} catch (e) {
ErrorUtils.handle(e);
}
}
async function onDelete() {
setIsLoading(true);
try {
await deleteStudio();
} catch (e) {
ErrorUtils.handle(e);
}
setIsLoading(false);
// redirect to studios page
props.history.push(`/studios`);
}
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
// TODO: CSS class
return (
<>
<div className="columns is-multiline no-spacing">
<div className="column is-half details-image-container">
<img alt={name} className="studio" src={imagePreview} />
</div>
<div className="column is-half details-detail-container">
<DetailsEditNavbar
studio={studio}
isNew={isNew}
isEditing={isEditing}
onToggleEdit={() => { setIsEditing(!isEditing); updateStudioEditState(studio); }}
onSave={onSave}
onDelete={onDelete}
onAutoTag={onAutoTag}
onImageChange={onImageChange}
/>
<h1 className="bp3-heading">
<EditableText
disabled={!isEditing}
value={name}
placeholder="Name"
onChange={(value) => setName(value)}
/>
</h1>
<HTMLTable style={{width: "100%"}}>
<tbody>
{TableUtils.renderInputGroup({title: "URL", value: url, isEditing, onChange: setUrl})}
</tbody>
</HTMLTable>
</div>
</div>
</>
);
};

View file

@ -1,35 +0,0 @@
import React, { FunctionComponent } from "react";
import { QueryHookResult } from "react-apollo-hooks";
import { FindStudiosQuery, FindStudiosVariables } from "../../core/generated-graphql";
import { ListHook } from "../../hooks/ListHook";
import { IBaseProps } from "../../models/base-props";
import { ListFilterModel } from "../../models/list-filter/filter";
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
import { StudioCard } from "./StudioCard";
interface IProps extends IBaseProps {}
export const StudioList: FunctionComponent<IProps> = (props: IProps) => {
const listData = ListHook.useList({
filterMode: FilterMode.Studios,
props,
renderContent,
});
function renderContent(result: QueryHookResult<FindStudiosQuery, FindStudiosVariables>, filter: ListFilterModel) {
if (!result.data || !result.data.findStudios) { return; }
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="grid">
{result.data.findStudios.studios.map((studio) => (<StudioCard key={studio.id} studio={studio} />))}
</div>
);
} else if (filter.displayMode === DisplayMode.List) {
return <h1>TODO</h1>;
} else if (filter.displayMode === DisplayMode.Wall) {
return <h1>TODO</h1>;
}
}
return listData.template;
};

View file

@ -1,13 +0,0 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import { Studio } from "./StudioDetails/Studio";
import { StudioList } from "./StudioList";
const Studios = () => (
<Switch>
<Route exact={true} path="/studios" component={StudioList} />
<Route path="/studios/:id" component={Studio} />
</Switch>
);
export default Studios;

View file

@ -1,162 +0,0 @@
import { Alert, Button, Classes, Dialog, FormGroup, InputGroup, Spinner } from "@blueprintjs/core";
import React, { FunctionComponent, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql";
import { StashService } from "../../core/StashService";
import { IBaseProps } from "../../models/base-props";
import { ErrorUtils } from "../../utils/errors";
import { NavigationUtils } from "../../utils/navigation";
import { ToastUtils } from "../../utils/toasts";
interface IProps extends IBaseProps {}
export const TagList: FunctionComponent<IProps> = (props: IProps) => {
const [tags, setTags] = useState<GQL.AllTagsAllTags[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Editing / New state
const [editingTag, setEditingTag] = useState<Partial<GQL.TagDataFragment> | undefined>(undefined);
const [deletingTag, setDeletingTag] = useState<Partial<GQL.TagDataFragment> | undefined>(undefined);
const [name, setName] = useState<string>("");
const { data, error, loading } = StashService.useAllTags();
const updateTag = StashService.useTagUpdate(getTagInput() as GQL.TagUpdateInput);
const createTag = StashService.useTagCreate(getTagInput() as GQL.TagCreateInput);
const deleteTag = StashService.useTagDestroy(getDeleteTagInput() as GQL.TagDestroyInput);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
useEffect(() => {
setIsLoading(loading);
if (!data || !data.allTags || !!error) { return; }
setTags(data.allTags);
}, [data, loading, error]);
useEffect(() => {
if (!!editingTag) {
setName(editingTag.name || "");
} else {
setName("");
}
}, [editingTag]);
useEffect(() => {
setIsDeleteAlertOpen(!!deletingTag);
}, [deletingTag]);
function getTagInput() {
const tagInput: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { name };
if (!!editingTag) { (tagInput as Partial<GQL.TagUpdateInput>).id = editingTag.id; }
return tagInput;
}
function getDeleteTagInput() {
const tagInput: Partial<GQL.TagDestroyInput> = {};
if (!!deletingTag) { tagInput.id = deletingTag.id; }
return tagInput;
}
async function onEdit() {
try {
if (!!editingTag && !!editingTag.id) {
await updateTag();
ToastUtils.success("Updated tag");
} else {
await createTag();
ToastUtils.success("Created tag");
}
setEditingTag(undefined);
} catch (e) {
ErrorUtils.handle(e);
}
}
async function onAutoTag(tag : GQL.TagDataFragment) {
if (!tag) {
return;
}
try {
await StashService.mutateMetadataAutoTag({ tags: [tag.id]});
ToastUtils.success("Started auto tagging");
} catch (e) {
ErrorUtils.handle(e);
}
}
async function onDelete() {
try {
await deleteTag();
ToastUtils.success("Deleted tag");
setDeletingTag(undefined);
} catch (e) {
ErrorUtils.handle(e);
}
}
function renderDeleteAlert() {
return (
<Alert
cancelButtonText="Cancel"
confirmButtonText="Delete"
icon="trash"
intent="danger"
isOpen={isDeleteAlertOpen}
onCancel={() => setDeletingTag(undefined)}
onConfirm={() => onDelete()}
>
<p>
Are you sure you want to delete {deletingTag && deletingTag.name}?
</p>
</Alert>
);
}
if (!data || !data.allTags || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
if (!!error) { return <>{error.message}</>; }
const tagElements = tags.map((tag) => {
return (
<>
{renderDeleteAlert()}
<div key={tag.id} className="tag-list-row">
<span onClick={() => setEditingTag(tag)}>{tag.name}</span>
<div style={{float: "right"}}>
<Button text="Auto Tag" onClick={() => onAutoTag(tag)}></Button>
<Link className="bp3-button" to={NavigationUtils.makeTagScenesUrl(tag)}>Scenes: {tag.scene_count}</Link>
<Link className="bp3-button" to={NavigationUtils.makeTagSceneMarkersUrl(tag)}>
Markers: {tag.scene_marker_count}
</Link>
<span>Total: {(tag.scene_count || 0) + (tag.scene_marker_count || 0)}</span>
<Button intent="danger" icon="trash" onClick={() => setDeletingTag(tag)}></Button>
</div>
</div>
</>
);
});
return (
<div id="tag-list-container">
<Button intent="primary" style={{marginTop: "20px"}} onClick={() => setEditingTag({})}>New Tag</Button>
<Dialog
isOpen={!!editingTag}
onClose={() => setEditingTag(undefined)}
title={!!editingTag && !!editingTag.id ? "Edit Tag" : "New Tag"}
>
<div className="dialog-content">
<FormGroup label="Name">
<InputGroup
onChange={(newValue: any) => setName(newValue.target.value)}
value={name}
/>
</FormGroup>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={() => onEdit()}>{!!editingTag && !!editingTag.id ? "Update" : "Create"}</Button>
</div>
</div>
</Dialog>
{tagElements}
</div>
);
};

View file

@ -1,11 +0,0 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import { TagList } from "./TagList";
const Tags = () => (
<Switch>
<Route exact={true} path="/tags" component={TagList} />
</Switch>
);
export default Tags;

View file

@ -1,99 +0,0 @@
.wall-overlay {
background-color: rgba(0,0,0,.8);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
pointer-events: none;
transition: transform .5s ease-in-out;
}
.visible {
opacity: 1;
transition: opacity .5s ease-in-out;
}
.hidden {
opacity: 0;
transition: opacity .5s ease-in-out;
}
.visible-unanimated {
opacity: 1;
}
.hidden-unanimated {
opacity: 0;
}
.double-scale {
position: absolute;
z-index: 2;
transform: scale(2);
background-color: black;
}
.double-scale img {
opacity: 0;
}
.scene-wall-item-container {
display: flex;
justify-content: center;
// align-items: center;
// overflow: hidden; // Commented out since it shows gaps in the wall
position: relative;
width: 100%;
height: 100%;
transition: transform .5s;
max-height: 253px;
}
.scene-wall-item-container video {
position: absolute;
width: 100%;
height: 100%;
z-index: -1;
}
.scene-wall-item-text-container {
position: absolute;
font-weight: 700;
color: #444;
padding: 5px;
width: 100%;
bottom: 0;
background: linear-gradient(rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0.65));
overflow: hidden;
text-align: center;
& span {
line-height: 1;
font-weight: 400;
font-size: 10px;
margin: 0 3px;
}
}
.scene-wall-item-blur {
position: absolute;
top: -5px;
left: -5px;
right: -5px;
bottom: -5px;
/*background-color: rgba(255, 255, 255, 0.75);*/
/*backdrop-filter: blur(5px);*/
z-index: -1;
}
.wall.grid-item video, .wall.grid-item img {
width: 100%;
height: 100%;
object-fit: contain;
}
.wall.grid-item {
padding: 0 !important;
line-height: 0;
overflow: visible;
position: relative;
}

View file

@ -1,132 +0,0 @@
import _ from "lodash";
import React, { FunctionComponent, useRef, useState, useEffect } from "react";
import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql";
import { VideoHoverHook } from "../../hooks/VideoHover";
import { TextUtils } from "../../utils/text";
import { NavigationUtils } from "../../utils/navigation";
import { StashService } from "../../core/StashService";
interface IWallItemProps {
scene?: GQL.SlimSceneDataFragment;
sceneMarker?: GQL.SceneMarkerDataFragment;
origin?: string;
onOverlay: (show: boolean) => void;
clickHandler?: (item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment) => void;
}
export const WallItem: FunctionComponent<IWallItemProps> = (props: IWallItemProps) => {
const [videoPath, setVideoPath] = useState<string | undefined>(undefined);
const [previewPath, setPreviewPath] = useState<string>("");
const [screenshotPath, setScreenshotPath] = useState<string>("");
const [title, setTitle] = useState<string>("");
const [tags, setTags] = useState<JSX.Element[]>([]);
const config = StashService.useConfiguration();
const videoHoverHook = VideoHoverHook.useVideoHover({resetOnMouseLeave: true});
const showTextContainer = !!config.data && !!config.data.configuration ? config.data.configuration.interface.wallShowTitle : true;
function onMouseEnter() {
VideoHoverHook.onMouseEnter(videoHoverHook);
if (!videoPath || videoPath === "") {
if (!!props.sceneMarker) {
setVideoPath(props.sceneMarker.stream || "");
} else if (!!props.scene) {
setVideoPath(props.scene.paths.preview || "");
}
}
props.onOverlay(true);
}
const debouncedOnMouseEnter = useRef(_.debounce(onMouseEnter, 500));
function onMouseLeave() {
VideoHoverHook.onMouseLeave(videoHoverHook);
setVideoPath("");
debouncedOnMouseEnter.current.cancel();
props.onOverlay(false);
}
function onClick() {
if (props.clickHandler === undefined) { return; }
if (props.scene !== undefined) {
props.clickHandler(props.scene);
} else if (props.sceneMarker !== undefined) {
props.clickHandler(props.sceneMarker);
}
}
let linkSrc: string = "#";
if (props.clickHandler === undefined) {
if (props.scene !== undefined) {
linkSrc = `/scenes/${props.scene.id}`;
} else if (props.sceneMarker !== undefined) {
linkSrc = NavigationUtils.makeSceneMarkerUrl(props.sceneMarker);
}
}
function onTransitionEnd(event: React.TransitionEvent<HTMLDivElement>) {
const target = (event.target as any);
if (target.classList.contains("double-scale")) {
target.parentElement.style.zIndex = 10;
} else {
target.parentElement.style.zIndex = null;
}
}
useEffect(() => {
if (!!props.sceneMarker) {
setPreviewPath(props.sceneMarker.preview);
setTitle(`${props.sceneMarker!.title} - ${TextUtils.secondsToTimestamp(props.sceneMarker.seconds)}`);
const thisTags = props.sceneMarker.tags.map((tag) => (<span key={tag.id}>{tag.name}</span>));
thisTags.unshift(<span key={props.sceneMarker.primary_tag.id}>{props.sceneMarker.primary_tag.name}</span>);
setTags(thisTags);
} else if (!!props.scene) {
setPreviewPath(props.scene.paths.webp || "");
setScreenshotPath(props.scene.paths.screenshot || "");
setTitle(props.scene.title || "");
// tags = props.scene.tags.map((tag) => (<span key={tag.id}>{tag.name}</span>));
}
}, [props.sceneMarker, props.scene]);
function previewNotFound() {
if (previewPath !== screenshotPath) {
setPreviewPath(screenshotPath);
}
}
const className = ["scene-wall-item-container"];
if (videoHoverHook.isHovering.current) { className.push("double-scale"); }
const style: React.CSSProperties = {};
if (!!props.origin) { style.transformOrigin = props.origin; }
return (
<div className="wall grid-item">
<div
className={className.join(" ")}
style={style}
onTransitionEnd={onTransitionEnd}
onMouseEnter={() => debouncedOnMouseEnter.current()}
onMouseMove={() => debouncedOnMouseEnter.current()}
onMouseLeave={onMouseLeave}
>
<Link onClick={() => onClick()} to={linkSrc}>
<video
src={videoPath}
poster={screenshotPath}
style={videoHoverHook.isHovering.current ? {} : {display: "none"}}
autoPlay={true}
loop={true}
ref={videoHoverHook.videoEl}
/>
<img alt={title} src={previewPath || screenshotPath} onError={() => previewNotFound()} />
{showTextContainer ?
<div className="scene-wall-item-text-container">
<div style={{lineHeight: 1}}>
{title}
</div>
{tags}
</div> : undefined
}
</Link>
</div>
</div>
);
};

View file

@ -1,88 +0,0 @@
import React, { FunctionComponent, useState } from "react";
import * as GQL from "../../core/generated-graphql";
import "./Wall.scss";
import { WallItem } from "./WallItem";
interface IWallPanelProps {
scenes?: GQL.SlimSceneDataFragment[];
sceneMarkers?: GQL.SceneMarkerDataFragment[];
clickHandler?: (item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment) => void;
}
export const WallPanel: FunctionComponent<IWallPanelProps> = (props: IWallPanelProps) => {
const [showOverlay, setShowOverlay] = useState<boolean>(false);
function onOverlay(show: boolean) {
setShowOverlay(show);
}
function getOrigin(index: number, rowSize: number, total: number): string {
const isAtStart = index % rowSize === 0;
const isAtEnd = index % rowSize === rowSize - 1;
const endRemaining = total % rowSize;
// First row
if (total === 1) { return "top"; }
if (index === 0) { return "top left"; }
if (index === rowSize - 1 || (total < rowSize && index === total - 1)) { return "top right"; }
if (index < rowSize) { return "top"; }
// Bottom row
if (isAtEnd && index === total - 1) { return "bottom right"; }
if (isAtStart && index === total - rowSize) { return "bottom left"; }
if (endRemaining !== 0 && index >= total - endRemaining) { return "bottom"; }
if (endRemaining === 0 && index >= total - rowSize) { return "bottom"; }
// Everything else
if (isAtStart) { return "center left"; }
if (isAtEnd) { return "center right"; }
return "center";
}
function maybeRenderScenes() {
if (props.scenes === undefined) { return; }
return props.scenes.map((scene, index) => {
const origin = getOrigin(index, 5, props.scenes!.length);
return (
<WallItem
key={scene.id}
scene={scene}
onOverlay={onOverlay}
clickHandler={props.clickHandler}
origin={origin}
/>
);
});
}
function maybeRenderSceneMarkers() {
if (props.sceneMarkers === undefined) { return; }
return props.sceneMarkers.map((marker, index) => {
const origin = getOrigin(index, 5, props.sceneMarkers!.length);
return (
<WallItem
key={marker.id}
sceneMarker={marker}
onOverlay={onOverlay}
clickHandler={props.clickHandler}
origin={origin}
/>
);
});
}
function render() {
const overlayClassName = showOverlay ? "visible" : "hidden";
return (
<>
<div className={`wall-overlay ${overlayClassName}`} />
<div className="wall grid">
{maybeRenderScenes()}
{maybeRenderSceneMarkers()}
</div>
</>
);
}
return render();
};

View file

@ -1,235 +0,0 @@
import {
Button,
Classes,
Dialog,
FormGroup,
HTMLSelect,
InputGroup,
Tooltip,
} from "@blueprintjs/core";
import _ from "lodash";
import React, { FunctionComponent, useEffect, useRef, useState } from "react";
import { isArray } from "util";
import { CriterionModifier } from "../../core/generated-graphql";
import { Criterion, CriterionType, DurationCriterion } from "../../models/list-filter/criteria/criterion";
import { NoneCriterion } from "../../models/list-filter/criteria/none";
import { PerformersCriterion } from "../../models/list-filter/criteria/performers";
import { StudiosCriterion } from "../../models/list-filter/criteria/studios";
import { MoviesCriterion } from "../../models/list-filter/criteria/movies";
import { TagsCriterion } from "../../models/list-filter/criteria/tags";
import { makeCriteria } from "../../models/list-filter/criteria/utils";
import { ListFilterModel } from "../../models/list-filter/filter";
import { FilterMultiSelect } from "../select/FilterMultiSelect";
import { DurationInput } from "../Shared/DurationInput";
interface IAddFilterProps {
onAddCriterion: (criterion: Criterion, oldId?: string) => void;
onCancel: () => void;
filter: ListFilterModel;
editingCriterion?: Criterion;
}
export const AddFilter: FunctionComponent<IAddFilterProps> = (props: IAddFilterProps) => {
const singleValueSelect = useRef<HTMLSelect>(null);
const [isOpen, setIsOpen] = useState(false);
const [criterion, setCriterion] = useState<Criterion<any, any>>(new NoneCriterion());
const valueStage = useRef<any>(criterion.value);
// Configure if we are editing an existing criterion
useEffect(() => {
if (!props.editingCriterion) { return; }
setIsOpen(true);
setCriterion(props.editingCriterion);
}, [props.editingCriterion]);
function onChangedCriteriaType(event: React.ChangeEvent<HTMLSelectElement>) {
const newCriterionType = event.target.value as CriterionType;
const newCriterion = makeCriteria(newCriterionType);
setCriterion(newCriterion);
}
function onChangedModifierSelect(event: React.ChangeEvent<HTMLSelectElement>) {
const newCriterion = _.cloneDeep(criterion);
newCriterion.modifier = event.target.value as any;
setCriterion(newCriterion);
}
function onChangedSingleSelect(event: React.ChangeEvent<HTMLSelectElement>) {
const newCriterion = _.cloneDeep(criterion);
newCriterion.value = event.target.value;
setCriterion(newCriterion);
}
function onChangedInput(event: React.ChangeEvent<HTMLInputElement>) {
valueStage.current = event.target.value;
}
function onChangedDuration(valueAsNumber: number) {
valueStage.current = valueAsNumber;
onBlurInput();
}
function onBlurInput() {
const newCriterion = _.cloneDeep(criterion);
newCriterion.value = valueStage.current;
setCriterion(newCriterion);
}
function onAddFilter() {
if (!isArray(criterion.value) && !!singleValueSelect.current) {
const value = singleValueSelect.current.props.defaultValue;
if (criterion.options && (value === undefined || value === "" || typeof value === "number")) {
criterion.value = criterion.options[0];
} else if (typeof value === "number" && value === undefined) {
criterion.value = 0;
} else if (value === undefined) {
criterion.value = "";
}
}
const oldId = !!props.editingCriterion ? props.editingCriterion.getId() : undefined;
props.onAddCriterion(criterion, oldId);
onToggle();
}
function onToggle() {
if (isOpen) {
props.onCancel();
}
setIsOpen(!isOpen);
setCriterion(makeCriteria());
}
const maybeRenderFilterPopoverContents = () => {
if (criterion.type === "none") { return; }
function renderModifier() {
if (criterion.modifierOptions.length === 0) { return; }
return (
<div>
<HTMLSelect
options={criterion.modifierOptions}
onChange={onChangedModifierSelect}
defaultValue={criterion.modifier}
/>
</div>
);
}
function renderSelect() {
// Hide the value select if the modifier is "IsNull" or "NotNull"
if (criterion.modifier === CriterionModifier.IsNull || criterion.modifier === CriterionModifier.NotNull) {
return;
}
if (isArray(criterion.value)) {
let type: "performers" | "studios" | "movies" | "tags" | "" = "";
if (criterion instanceof PerformersCriterion) {
type = "performers";
} else if (criterion instanceof StudiosCriterion) {
type = "studios";
} else if (criterion instanceof MoviesCriterion) {
type = "movies";
} else if (criterion instanceof TagsCriterion) {
type = "tags";
}
if (type === "") {
return (<>todo</>);
} else {
return (
<FilterMultiSelect
type={type}
onUpdate={(items) => criterion.value = items.map((i) => ({id: i.id, label: i.name!}))}
openOnKeyDown={true}
initialIds={criterion.value.map((labeled: any) => labeled.id)}
/>
);
}
} else {
if (criterion.options) {
return (
<HTMLSelect
ref={singleValueSelect}
options={criterion.options}
onChange={onChangedSingleSelect}
defaultValue={criterion.value}
/>
);
} else if (criterion instanceof DurationCriterion) {
// render duration control
return (
<DurationInput
numericValue={criterion.value ? criterion.value : 0}
onValueChange={onChangedDuration}
/>
)
} else {
return (
<InputGroup
type={criterion.inputType}
onChange={onChangedInput}
onBlur={onBlurInput}
defaultValue={criterion.value ? criterion.value : ""}
/>
)
}
}
}
return (
<>
<FormGroup>
{renderModifier()}
</FormGroup>
<FormGroup>
{renderSelect()}
</FormGroup>
</>
);
};
function maybeRenderFilterSelect() {
if (!!props.editingCriterion) { return; }
return (
<FormGroup label="Filter">
<HTMLSelect
style={{flexBasis: "min-content"}}
options={props.filter.criterionOptions}
onChange={onChangedCriteriaType}
defaultValue={criterion.type}
/>
</FormGroup>
);
}
const title = !props.editingCriterion ? "Add Filter" : "Update Filter";
return (
<>
<Tooltip
hoverOpenDelay={200}
content="Filter"
>
<Button
icon="filter"
onClick={() => onToggle()}
active={isOpen}
large={true}
>
</Button>
</Tooltip>
<Dialog isOpen={isOpen} onClose={() => onToggle()} title={title}>
<div className="dialog-content">
{maybeRenderFilterSelect()}
{maybeRenderFilterPopoverContents()}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={onAddFilter} disabled={criterion.type === "none"}>{title}</Button>
</div>
</div>
</Dialog>
</>
);
};

View file

@ -1,286 +0,0 @@
import {
Button,
ButtonGroup,
HTMLSelect,
InputGroup,
Menu,
MenuItem,
Popover,
Tag,
Tooltip,
Slider,
} from "@blueprintjs/core";
import { debounce } from "lodash";
import React, { FunctionComponent, SyntheticEvent, useState } from "react";
import { Criterion } from "../../models/list-filter/criteria/criterion";
import { ListFilterModel } from "../../models/list-filter/filter";
import { DisplayMode } from "../../models/list-filter/types";
import { AddFilter } from "./AddFilter";
interface IListFilterOperation {
text: string;
onClick: () => void;
}
interface IListFilterProps {
onChangePageSize: (pageSize: number) => void;
onChangeQuery: (query: string) => void;
onChangeSortDirection: (sortDirection: "asc" | "desc") => void;
onChangeSortBy: (sortBy: string) => void;
onChangeDisplayMode: (displayMode: DisplayMode) => void;
onAddCriterion: (criterion: Criterion, oldId?: string) => void;
onRemoveCriterion: (criterion: Criterion) => void;
zoomIndex?: number;
onChangeZoom?: (zoomIndex: number) => void;
onSelectAll?: () => void;
onSelectNone?: () => void;
otherOperations?: IListFilterOperation[];
filter: ListFilterModel;
}
const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120"];
export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilterProps) => {
const [editingCriterion, setEditingCriterion] = useState<Criterion | undefined>(undefined);
function onChangePageSize(event: SyntheticEvent<HTMLSelectElement>) {
const val = event!.currentTarget!.value;
props.onChangePageSize(parseInt(val, 10));
}
function onChangeQuery(event: SyntheticEvent<HTMLInputElement>) {
let searchCallback = debounce((event: any) => {
props.onChangeQuery(event.target.value);
}, 500);
event.persist();
searchCallback(event);
}
function onChangeSortDirection(_: any) {
if (props.filter.sortDirection === "asc") {
props.onChangeSortDirection("desc");
} else {
props.onChangeSortDirection("asc");
}
}
function onChangeSortBy(event: React.MouseEvent<any>) {
props.onChangeSortBy(event.currentTarget.text);
}
function onChangeDisplayMode(displayMode: DisplayMode) {
props.onChangeDisplayMode(displayMode);
}
function onAddCriterion(criterion: Criterion, oldId?: string) {
props.onAddCriterion(criterion, oldId);
}
function onCancelAddCriterion() {
setEditingCriterion(undefined);
}
let removedCriterionId = "";
function onRemoveCriterionTag(criterion?: Criterion) {
if (!criterion) { return; }
setEditingCriterion(undefined);
removedCriterionId = criterion.getId();
props.onRemoveCriterion(criterion);
}
function onClickCriterionTag(criterion?: Criterion) {
if (!criterion || removedCriterionId !== "") { return; }
setEditingCriterion(criterion);
}
function renderSortByOptions() {
return props.filter.sortByOptions.map((option) => (
<MenuItem onClick={onChangeSortBy} text={option} key={option} />
));
}
function renderDisplayModeOptions() {
function getIcon(option: DisplayMode) {
switch (option) {
case DisplayMode.Grid: return "grid-view";
case DisplayMode.List: return "list";
case DisplayMode.Wall: return "symbol-square";
}
}
function getLabel(option: DisplayMode) {
switch (option) {
case DisplayMode.Grid: return "Grid";
case DisplayMode.List: return "List";
case DisplayMode.Wall: return "Wall";
}
}
return props.filter.displayModeOptions.map((option) => (
<Tooltip content={getLabel(option)} hoverOpenDelay={200}>
<Button
key={option}
active={props.filter.displayMode === option}
onClick={() => onChangeDisplayMode(option)}
icon={getIcon(option)}
/>
</Tooltip>
));
}
function renderFilterTags() {
return props.filter.criteria.map((criterion) => (
<Tag
key={criterion.getId()}
className="tag-item"
itemID={criterion.getId()}
interactive={true}
onRemove={() => onRemoveCriterionTag(criterion)}
onClick={() => onClickCriterionTag(criterion)}
>
{criterion.getLabel()}
</Tag>
));
}
function onSelectAll() {
if (props.onSelectAll) {
props.onSelectAll();
}
}
function onSelectNone() {
if (props.onSelectNone) {
props.onSelectNone();
}
}
function renderSelectAll() {
if (props.onSelectAll) {
return <MenuItem onClick={() => onSelectAll()} text="Select All" />;
}
}
function renderSelectNone() {
if (props.onSelectNone) {
return <MenuItem onClick={() => onSelectNone()} text="Select None" />;
}
}
function renderMore() {
let options = [];
options.push(renderSelectAll());
options.push(renderSelectNone());
if (props.otherOperations) {
props.otherOperations.forEach((o) => {
options.push(<MenuItem onClick={o.onClick} text={o.text} />);
});
}
options = options.filter((o) => !!o);
let menuItems = options as JSX.Element[];
function renderMoreOptions() {
return (
<>
{menuItems}
</>
)
}
if (menuItems.length > 0) {
return (
<Popover position="bottom">
<Button icon="more"/>
<Menu>{renderMoreOptions()}</Menu>
</Popover>
);
}
}
function onChangeZoom(v : number) {
if (props.onChangeZoom) {
props.onChangeZoom(v);
}
}
function maybeRenderZoom() {
if (props.onChangeZoom) {
return (
<span className="zoom-slider">
<Slider
min={0}
value={props.zoomIndex}
initialValue={props.zoomIndex}
max={3}
labelRenderer={false}
onChange={(v) => onChangeZoom(v)}
/>
</span>
);
}
}
function render() {
return (
<>
<div className="filter-container">
<InputGroup
large={true}
placeholder="Search..."
defaultValue={props.filter.searchTerm}
onChange={onChangeQuery}
className="filter-item"
/>
<HTMLSelect
large={true}
style={{flexBasis: "min-content"}}
options={PAGE_SIZE_OPTIONS}
onChange={onChangePageSize}
value={props.filter.itemsPerPage}
className="filter-item"
/>
<ButtonGroup className="filter-item">
<Popover position="bottom">
<Button large={true}>{props.filter.sortBy}</Button>
<Menu>{renderSortByOptions()}</Menu>
</Popover>
<Tooltip
content={props.filter.sortDirection === "asc" ? "Ascending" : "Descending"}
hoverOpenDelay={200}
>
<Button
rightIcon={props.filter.sortDirection === "asc" ? "caret-up" : "caret-down"}
onClick={onChangeSortDirection}
/>
</Tooltip>
</ButtonGroup>
<AddFilter
filter={props.filter}
onAddCriterion={onAddCriterion}
onCancel={onCancelAddCriterion}
editingCriterion={editingCriterion}
/>
<ButtonGroup className="filter-item">
{renderDisplayModeOptions()}
</ButtonGroup>
{maybeRenderZoom()}
<ButtonGroup className="filter-item">
{renderMore()}
</ButtonGroup>
</div>
<div style={{display: "flex", justifyContent: "center", margin: "10px auto"}}>
{renderFilterTags()}
</div>
</>
);
}
return render();
};

View file

@ -1,120 +0,0 @@
import { Button, ButtonGroup } from "@blueprintjs/core";
import React from "react";
interface IPaginationProps {
itemsPerPage: number;
currentPage: number;
totalItems: number;
onChangePage: (page: number) => void;
}
interface IPaginationState {
pages: number[];
totalPages: number;
}
export class Pagination extends React.Component<IPaginationProps, IPaginationState> {
constructor(props: IPaginationProps) {
super(props);
this.state = {
pages: [],
totalPages: Number.MAX_SAFE_INTEGER,
};
}
public componentWillMount() {
this.setPage(this.props.currentPage, false);
}
public componentDidUpdate(prevProps: IPaginationProps) {
if (this.props.totalItems !== prevProps.totalItems || this.props.itemsPerPage !== prevProps.itemsPerPage) {
this.setPage(this.props.currentPage);
}
}
public render() {
if (!this.state || !this.state.pages || this.state.pages.length <= 1) { return null; }
return (
<ButtonGroup large={true} className="filter-container">
<Button
text="First"
disabled={this.props.currentPage === 1}
onClick={() => this.setPage(1)}
/>
<Button
text="Previous"
disabled={this.props.currentPage === 1}
onClick={() => this.setPage(this.props.currentPage - 1)}
/>
{this.renderPageButtons()}
<Button
text="Next"
disabled={this.props.currentPage === this.state.totalPages}
onClick={() => this.setPage(this.props.currentPage + 1)}
/>
<Button
text="Last"
disabled={this.props.currentPage === this.state.totalPages}
onClick={() => this.setPage(this.state.totalPages)}
/>
</ButtonGroup>
);
}
private renderPageButtons() {
return this.state.pages.map((page: number, index: number) => (
<Button
key={index}
text={page}
active={this.props.currentPage === page}
onClick={() => this.setPage(page)}
/>
));
}
private setPage(page?: number, propagate: boolean = true) {
if (page === undefined) { return; }
const pagerState = this.getPagerState(this.props.totalItems, page, this.props.itemsPerPage);
// rearranged this so that the minimum page number is 1, not 0
if (page > pagerState.totalPages) { page = pagerState.totalPages; }
if (page < 1) { page = 1; }
this.setState(pagerState);
if (propagate) { this.props.onChangePage(page); }
}
private getPagerState(totalItems: number, currentPage: number, pageSize: number) {
const totalPages = Math.ceil(totalItems / pageSize);
let startPage: number;
let endPage: number;
if (totalPages <= 10) {
// less than 10 total pages so show all
startPage = 1;
endPage = totalPages;
} else {
// more than 10 total pages so calculate start and end pages
if (currentPage <= 6) {
startPage = 1;
endPage = 10;
} else if (currentPage + 4 >= totalPages) {
startPage = totalPages - 9;
endPage = totalPages;
} else {
startPage = currentPage - 5;
endPage = currentPage + 4;
}
}
// create an array of pages numbers
const pages = [...Array((endPage + 1) - startPage).keys()].map((i) => startPage + i);
return {
pages,
totalPages,
};
}
}

View file

@ -1,52 +0,0 @@
import {
Card,
Elevation,
H4,
} from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql";
import { TextUtils } from "../../utils/text";
import { NavigationUtils } from "../../utils/navigation";
interface IPerformerCardProps {
performer: GQL.PerformerDataFragment;
ageFromDate?: string;
}
export const PerformerCard: FunctionComponent<IPerformerCardProps> = (props: IPerformerCardProps) => {
const age = TextUtils.age(props.performer.birthdate, props.ageFromDate);
const ageString = `${age} years old${!!props.ageFromDate ? " in this scene." : "."}`;
function maybeRenderFavoriteBanner() {
if (props.performer.favorite === false) { return; }
return (
<div className={`rating-banner rating-5`}>
FAVORITE
</div>
);
}
return (
<Card
className="grid-item"
elevation={Elevation.ONE}
>
<Link
to={`/performers/${props.performer.id}`}
className="performer previewable image"
style={{backgroundImage: `url(${props.performer.image_path})`}}
>
{maybeRenderFavoriteBanner()}
</Link>
<div className="card-section">
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
{props.performer.name}
</H4>
{age !== 0 ? <span className="bp3-text-muted block">{ageString}</span> : undefined}
<span className="bp3-text-muted block">Stars in {props.performer.scene_count} <Link to={NavigationUtils.makePerformerScenesUrl(props.performer)}>scenes</Link>.
</span>
</div>
</Card>
);
};

View file

@ -1,251 +0,0 @@
import {
Spinner,
Tabs,
Tab,
Button,
AnchorButton,
IconName,
} from "@blueprintjs/core";
import React, { FunctionComponent, useEffect, useState } from "react";
import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
import { IBaseProps } from "../../../models";
import { ErrorUtils } from "../../../utils/errors";
import { PerformerDetailsPanel } from "./PerformerDetailsPanel";
import { PerformerOperationsPanel } from "./PerformerOperationsPanel";
import { PerformerScenesPanel } from "./PerformerScenesPanel";
import { TextUtils } from "../../../utils/text";
import Lightbox from "react-images";
interface IPerformerProps extends IBaseProps {}
export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerProps) => {
const isNew = props.match.params.id === "new";
// Performer state
const [performer, setPerformer] = useState<Partial<GQL.PerformerDataFragment>>({});
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined);
const [lightboxIsOpen, setLightboxIsOpen] = useState<boolean>(false);
// Network state
const [isLoading, setIsLoading] = useState(false);
const { data, error, loading } = StashService.useFindPerformer(props.match.params.id);
const updatePerformer = StashService.usePerformerUpdate();
const createPerformer = StashService.usePerformerCreate();
const deletePerformer = StashService.usePerformerDestroy();
useEffect(() => {
setIsLoading(loading);
if (!data || !data.findPerformer || !!error) { return; }
setPerformer(data.findPerformer);
}, [data, error, loading]);
useEffect(() => {
setImagePreview(performer.image_path);
}, [performer]);
function onImageChange(image: string) {
setImagePreview(image);
}
if ((!isNew && (!data || !data.findPerformer)) || isLoading) {
return <Spinner size={Spinner.SIZE_LARGE} />;
}
if (!!error) { return <>error...</>; }
async function onSave(performer : Partial<GQL.PerformerCreateInput> | Partial<GQL.PerformerUpdateInput>) {
setIsLoading(true);
try {
if (!isNew) {
const result = await updatePerformer({variables: performer as GQL.PerformerUpdateInput});
if (performer.image) {
// Refetch image to bust browser cache
await fetch(`/performer/${result.data.performerUpdate.id}/image`, { cache: "reload" });
}
setPerformer(result.data.performerUpdate);
} else {
const result = await createPerformer({variables: performer as GQL.PerformerCreateInput});
setPerformer(result.data.performerCreate);
props.history.push(`/performers/${result.data.performerCreate.id}`);
}
} catch (e) {
ErrorUtils.handle(e);
}
setIsLoading(false);
}
async function onDelete() {
setIsLoading(true);
try {
await deletePerformer({variables: {id: props.match.params.id}});
} catch (e) {
ErrorUtils.handle(e);
}
setIsLoading(false);
// redirect to performers page
props.history.push(`/performers`);
}
function renderTabs() {
function renderEditPanel() {
return (
<PerformerDetailsPanel
performer={performer}
isEditing={true}
isNew={isNew}
onDelete={onDelete}
onSave={onSave}
onImageChange={onImageChange}
/>
);
}
// render tabs if not new
if (!isNew) {
return (
<>
<Tabs
renderActiveTabPanelOnly={true}
large={true}
>
<Tab id="performer-details-panel" title="Details" panel={<PerformerDetailsPanel performer={performer} isEditing={false}/>} />
<Tab id="performer-scenes-panel" title="Scenes" panel={<PerformerScenesPanel performer={performer} base={props} />} />
<Tab id="performer-edit-panel" title="Edit" panel={renderEditPanel()} />
<Tab id="performer-operations-panel" title="Operations" panel={<PerformerOperationsPanel performer={performer} />} />
</Tabs>
</>
);
} else {
return renderEditPanel();
}
}
function maybeRenderAge() {
if (performer && performer.birthdate) {
// calculate the age from birthdate. In future, this should probably be
// provided by the server
return (
<>
<div>
<span className="age">{TextUtils.age(performer.birthdate)}</span>
<span className="age-tail"> years old</span>
</div>
</>
);
}
}
function maybeRenderAliases() {
if (performer && performer.aliases) {
return (
<>
<div>
<span className="alias-head">Also known as </span>
<span className="alias">{performer.aliases}</span>
</div>
</>
);
}
}
function setFavorite(v : boolean) {
performer.favorite = v;
onSave(performer);
}
function renderIcons() {
function maybeRenderURL(url?: string, icon?: IconName) {
if (performer.url) {
if (!icon) {
icon = "link";
}
return (
<>
<AnchorButton
icon={icon}
href={performer.url}
minimal={true}
/>
</>
)
}
}
return (
<>
<span className="name-icons">
<Button
icon="heart"
className={performer.favorite ? "favorite" : "not-favorite"}
onClick={() => setFavorite(!performer.favorite)}
minimal={true}
/>
{maybeRenderURL(performer.url)}
{/* TODO - render instagram and twitter links with icons */}
</span>
</>
);
}
function renderNewView() {
return (
<div className="columns is-multiline no-spacing">
<div className="column is-half details-image-container">
{!imagePreview ? undefined : <img alt="Performer" className="performer" src={imagePreview} />}
</div>
<div className="column is-half details-detail-container">
{renderTabs()}
</div>
</div>
);
}
const photos = [{src: imagePreview || "", caption: "Image"}];
function openLightbox() {
setLightboxIsOpen(true);
}
function closeLightbox() {
setLightboxIsOpen(false);
}
if (isNew) {
return renderNewView();
}
return (
<>
<div id="performer-page">
<div className="details-image-container">
<img alt={performer.name} className="performer" src={imagePreview} onClick={openLightbox} />
</div>
<div className="performer-head">
<h1 className="bp3-heading">
{performer.name}
{renderIcons()}
</h1>
{maybeRenderAliases()}
{maybeRenderAge()}
</div>
<div className="performer-body">
<div className="details-detail-container">
{renderTabs()}
</div>
</div>
</div>
<Lightbox
images={photos}
onClose={closeLightbox}
currentImage={0}
isOpen={lightboxIsOpen}
onClickImage={() => window.open(imagePreview, "_blank")}
width={9999}
/>
</>
);
};

View file

@ -1,433 +0,0 @@
import {
Button,
Classes,
Dialog,
HTMLTable,
Spinner,
Menu,
MenuItem,
Popover,
Alert,
FileInput,
} from "@blueprintjs/core";
import _ from "lodash";
import React, { FunctionComponent, useEffect, useState } from "react";
import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
import { ErrorUtils } from "../../../utils/errors";
import { TableUtils } from "../../../utils/table";
import { ScrapePerformerSuggest } from "../../select/ScrapePerformerSuggest";
import { EditableTextUtils } from "../../../utils/editabletext";
import { ImageUtils } from "../../../utils/image";
interface IPerformerDetailsProps {
performer: Partial<GQL.PerformerDataFragment>
isNew?: boolean
isEditing?: boolean
onSave? : (performer : Partial<GQL.PerformerCreateInput> | Partial<GQL.PerformerUpdateInput>) => void
onDelete? : () => void
onImageChange? : (image: string) => void
}
export const PerformerDetailsPanel: FunctionComponent<IPerformerDetailsProps> = (props: IPerformerDetailsProps) => {
// Editing state
const [isDisplayingScraperDialog, setIsDisplayingScraperDialog] = useState<GQL.ListPerformerScrapersListPerformerScrapers | undefined>(undefined);
const [scrapePerformerDetails, setScrapePerformerDetails] = useState<GQL.ScrapePerformerListScrapePerformerList | undefined>(undefined);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Editing performer state
const [image, setImage] = useState<string | undefined>(undefined);
const [name, setName] = useState<string | undefined>(undefined);
const [aliases, setAliases] = useState<string | undefined>(undefined);
const [favorite, setFavorite] = useState<boolean | undefined>(undefined);
const [birthdate, setBirthdate] = useState<string | undefined>(undefined);
const [ethnicity, setEthnicity] = useState<string | undefined>(undefined);
const [country, setCountry] = useState<string | undefined>(undefined);
const [eyeColor, setEyeColor] = useState<string | undefined>(undefined);
const [height, setHeight] = useState<string | undefined>(undefined);
const [measurements, setMeasurements] = useState<string | undefined>(undefined);
const [fakeTits, setFakeTits] = useState<string | undefined>(undefined);
const [careerLength, setCareerLength] = useState<string | undefined>(undefined);
const [tattoos, setTattoos] = useState<string | undefined>(undefined);
const [piercings, setPiercings] = useState<string | undefined>(undefined);
const [url, setUrl] = useState<string | undefined>(undefined);
const [twitter, setTwitter] = useState<string | undefined>(undefined);
const [instagram, setInstagram] = useState<string | undefined>(undefined);
const [gender, setGender] = useState<string | undefined>(undefined);
// Network state
const [isLoading, setIsLoading] = useState(false);
const Scrapers = StashService.useListPerformerScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.ListPerformerScrapersListPerformerScrapers[]>([]);
function updatePerformerEditState(state: Partial<GQL.PerformerDataFragment | GQL.ScrapedPerformerDataFragment | GQL.ScrapeFreeonesScrapeFreeones>) {
if ((state as GQL.PerformerDataFragment).favorite !== undefined) {
setFavorite((state as GQL.PerformerDataFragment).favorite);
}
setName(state.name);
setAliases(state.aliases);
setBirthdate(state.birthdate);
setEthnicity(state.ethnicity);
setCountry(state.country);
setEyeColor(state.eye_color);
setHeight(state.height);
setMeasurements(state.measurements);
setFakeTits(state.fake_tits);
setCareerLength(state.career_length);
setTattoos(state.tattoos);
setPiercings(state.piercings);
setUrl(state.url);
setTwitter(state.twitter);
setInstagram(state.instagram);
setGender(StashService.genderToString((state as GQL.PerformerDataFragment).gender));
}
function updatePerformerEditStateFromScraper(state: Partial<GQL.ScrapedPerformerDataFragment | GQL.ScrapeFreeonesScrapeFreeones>) {
updatePerformerEditState(state);
// image is a base64 string
if ((state as GQL.ScrapedPerformerDataFragment).image !== undefined) {
let imageStr = (state as GQL.ScrapedPerformerDataFragment).image;
setImage(imageStr);
if (props.onImageChange) {
props.onImageChange(imageStr!);
}
}
}
useEffect(() => {
setImage(undefined);
updatePerformerEditState(props.performer);
}, [props.performer]);
function onImageLoad(this: FileReader) {
setImage(this.result as string);
if (props.onImageChange) {
props.onImageChange(this.result as string);
}
}
if (props.isEditing) {
ImageUtils.addPasteImageHook(onImageLoad);
}
useEffect(() => {
var newQueryableScrapers : GQL.ListPerformerScrapersListPerformerScrapers[] = [];
if (!!Scrapers.data && Scrapers.data.listPerformerScrapers) {
newQueryableScrapers = Scrapers.data.listPerformerScrapers.filter((s) => {
return s.performer && s.performer.supported_scrapes.includes(GQL.ScrapeType.Name);
});
}
setQueryableScrapers(newQueryableScrapers);
}, [Scrapers.data]);
if (isLoading) {
return <Spinner size={Spinner.SIZE_LARGE} />;
}
function getPerformerInput() {
const performerInput: Partial<GQL.PerformerCreateInput | GQL.PerformerUpdateInput> = {
name,
aliases,
favorite,
birthdate,
ethnicity,
country,
eye_color: eyeColor,
height,
measurements,
fake_tits: fakeTits,
career_length: careerLength,
tattoos,
piercings,
url,
twitter,
instagram,
image,
gender: StashService.stringToGender(gender)
};
if (!props.isNew) {
(performerInput as GQL.PerformerUpdateInput).id = props.performer.id!;
}
return performerInput;
}
function onSave() {
if (props.onSave) {
props.onSave(getPerformerInput());
}
}
function onDelete() {
if (props.onDelete) {
props.onDelete();
}
}
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
function onDisplayFreeOnesDialog(scraper: GQL.ListPerformerScrapersListPerformerScrapers) {
setIsDisplayingScraperDialog(scraper);
}
function getQueryScraperPerformerInput() {
if (!scrapePerformerDetails) {
return {};
}
let ret = _.clone(scrapePerformerDetails);
delete ret.__typename;
// image is not supported
delete ret.image;
return ret as GQL.ScrapedPerformerInput;
}
async function onScrapePerformer() {
setIsDisplayingScraperDialog(undefined);
try {
if (!scrapePerformerDetails || !isDisplayingScraperDialog) { return; }
setIsLoading(true);
const result = await StashService.queryScrapePerformer(isDisplayingScraperDialog.id, getQueryScraperPerformerInput());
if (!result.data || !result.data.scrapePerformer) { return; }
updatePerformerEditStateFromScraper(result.data.scrapePerformer);
} catch (e) {
ErrorUtils.handle(e);
} finally {
setIsLoading(false);
}
}
async function onScrapePerformerURL() {
if (!url) { return; }
setIsLoading(true);
try {
const result = await StashService.queryScrapePerformerURL(url);
if (!result.data || !result.data.scrapePerformerURL) { return; }
// leave URL as is if not set explicitly
if (!result.data.scrapePerformerURL.url) {
result.data.scrapePerformerURL.url = url;
}
updatePerformerEditStateFromScraper(result.data.scrapePerformerURL);
} catch (e) {
ErrorUtils.handle(e);
} finally {
setIsLoading(false);
}
}
function renderEthnicity() {
return TableUtils.renderInputGroup({
title: "Ethnicity",
value: ethnicity,
isEditing: !!props.isEditing,
placeholder: "Ethnicity",
onChange: setEthnicity
});
}
function renderScraperMenu() {
function renderScraperMenuItem(scraper : GQL.ListPerformerScrapersListPerformerScrapers) {
return (
<MenuItem
text={scraper.name}
onClick={() => { onDisplayFreeOnesDialog(scraper); }}
/>
);
}
if (!props.performer) { return; }
if (!props.isEditing) { return; }
const scraperMenu = (
<Menu>
{queryableScrapers ? queryableScrapers.map((s) => renderScraperMenuItem(s)) : undefined}
</Menu>
);
return (
<Popover content={scraperMenu} position="bottom">
<Button text="Scrape with..."/>
</Popover>
);
}
function renderScraperDialog() {
return (
<Dialog
isOpen={!!isDisplayingScraperDialog}
onClose={() => setIsDisplayingScraperDialog(undefined)}
title="Scrape"
>
<div className="dialog-content">
<ScrapePerformerSuggest
placeholder="Performer name"
style={{width: "100%"}}
scraperId={isDisplayingScraperDialog ? isDisplayingScraperDialog.id : ""}
onSelectPerformer={(query) => setScrapePerformerDetails(query)}
/>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={() => onScrapePerformer()}>Scrape</Button>
</div>
</div>
</Dialog>
);
}
function urlScrapable(url: string) : boolean {
return !!url && !!Scrapers.data && Scrapers.data.listPerformerScrapers && Scrapers.data.listPerformerScrapers.some((s) => {
return !!s.performer && !!s.performer.urls && s.performer.urls.some((u) => { return url.includes(u); });
});
}
function maybeRenderScrapeButton() {
if (!url || !props.isEditing || !urlScrapable(url)) {
return undefined;
}
return (
<Button
minimal={true}
icon="import"
id="scrape-url-button"
onClick={() => onScrapePerformerURL()}/>
)
}
function renderURLField() {
return (
<tr>
<td id="url-field">
URL
{maybeRenderScrapeButton()}
</td>
<td>
{EditableTextUtils.renderInputGroup({
value: url, asURL: true, isEditing: !!props.isEditing, onChange: setUrl, placeholder: "URL"
})}
</td>
</tr>
);
}
function renderImageInput() {
if (!props.isEditing) { return; }
return (
<>
<tr>
<td>Image</td>
<td><FileInput text="Choose image..." onInputChange={onImageChange} inputProps={{accept: ".jpg,.jpeg"}} /></td>
</tr>
</>
)
}
function maybeRenderButtons() {
if (props.isEditing) {
return (
<>
<Button className="edit-button" text="Save" intent="primary" onClick={() => onSave()}/>
{!props.isNew ? <Button className="edit-button" text="Delete" intent="danger" onClick={() => setIsDeleteAlertOpen(true)}/> : undefined}
{renderScraperMenu()}
</>
);
}
}
function renderDeleteAlert() {
return (
<Alert
cancelButtonText="Cancel"
confirmButtonText="Delete"
icon="trash"
intent="danger"
isOpen={isDeleteAlertOpen}
onCancel={() => setIsDeleteAlertOpen(false)}
onConfirm={() => onDelete()}
>
<p>
Are you sure you want to delete {name}?
</p>
</Alert>
);
}
function maybeRenderName() {
if (props.isEditing) {
return TableUtils.renderInputGroup(
{title: "Name", value: name, isEditing: !!props.isEditing, placeholder: "Name", onChange: setName});
}
}
function maybeRenderAliases() {
if (props.isEditing) {
return TableUtils.renderInputGroup(
{title: "Aliases", value: aliases, isEditing: !!props.isEditing, placeholder: "Aliases", onChange: setAliases});
}
}
function renderGender() {
return TableUtils.renderHtmlSelect({
title: "Gender",
value: gender,
isEditing: !!props.isEditing,
onChange: (value: string) => setGender(value),
selectOptions: [""].concat(StashService.getGenderStrings()),
});
}
const twitterPrefix = "https://twitter.com/";
const instagramPrefix = "https://www.instagram.com/";
return (
<>
{renderDeleteAlert()}
{renderScraperDialog()}
<HTMLTable id="performer-details" style={{width: "100%"}}>
<tbody>
{maybeRenderName()}
{maybeRenderAliases()}
{renderGender()}
{TableUtils.renderInputGroup(
{title: "Birthdate (YYYY-MM-DD)", value: birthdate, isEditing: !!props.isEditing, onChange: setBirthdate})}
{renderEthnicity()}
{TableUtils.renderInputGroup(
{title: "Eye Color", value: eyeColor, isEditing: !!props.isEditing, onChange: setEyeColor})}
{TableUtils.renderInputGroup(
{title: "Country", value: country, isEditing: !!props.isEditing, onChange: setCountry})}
{TableUtils.renderInputGroup(
{title: "Height (CM)", value: height, isEditing: !!props.isEditing, onChange: setHeight})}
{TableUtils.renderInputGroup(
{title: "Measurements", value: measurements, isEditing: !!props.isEditing, onChange: setMeasurements})}
{TableUtils.renderInputGroup(
{title: "Fake Tits", value: fakeTits, isEditing: !!props.isEditing, onChange: setFakeTits})}
{TableUtils.renderInputGroup(
{title: "Career Length", value: careerLength, isEditing: !!props.isEditing, onChange: setCareerLength})}
{TableUtils.renderInputGroup(
{title: "Tattoos", value: tattoos, isEditing: !!props.isEditing, onChange: setTattoos})}
{TableUtils.renderInputGroup(
{title: "Piercings", value: piercings, isEditing: !!props.isEditing, onChange: setPiercings})}
{renderURLField()}
{TableUtils.renderInputGroup(
{title: "Twitter", value: twitter, asURL: true, urlPrefix: twitterPrefix, isEditing: !!props.isEditing, onChange: setTwitter})}
{TableUtils.renderInputGroup(
{title: "Instagram", value: instagram, asURL: true, urlPrefix: instagramPrefix, isEditing: !!props.isEditing, onChange: setInstagram})}
{renderImageInput()}
</tbody>
</HTMLTable>
{maybeRenderButtons()}
</>
);
};

View file

@ -1,34 +0,0 @@
import {
Button,
} from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
import { ErrorUtils } from "../../../utils/errors";
import { ToastUtils } from "../../../utils/toasts";
interface IPerformerOperationsProps {
performer: Partial<GQL.PerformerDataFragment>
}
export const PerformerOperationsPanel: FunctionComponent<IPerformerOperationsProps> = (props: IPerformerOperationsProps) => {
async function onAutoTag() {
if (!props.performer || !props.performer.id) {
return;
}
try {
await StashService.mutateMetadataAutoTag({ performers: [props.performer.id]});
ToastUtils.success("Started auto tagging");
} catch (e) {
ErrorUtils.handle(e);
}
}
return (
<>
<Button text="Auto Tag" onClick={onAutoTag} />
</>
);
};

View file

@ -1,50 +0,0 @@
import React, { FunctionComponent } from "react";
import * as GQL from "../../../core/generated-graphql";
import { IBaseProps } from "../../../models";
import { SceneList } from "../../scenes/SceneList";
import { PerformersCriterion } from "../../../models/list-filter/criteria/performers";
import { ListFilterModel } from "../../../models/list-filter/filter";
interface IPerformerDetailsProps {
performer: Partial<GQL.PerformerDataFragment>
base: IBaseProps
}
export const PerformerScenesPanel: FunctionComponent<IPerformerDetailsProps> = (props: IPerformerDetailsProps) => {
function filterHook(filter: ListFilterModel) {
let performerValue = {id: props.performer.id!, label: props.performer.name!};
// if performers is already present, then we modify it, otherwise add
let performerCriterion = filter.criteria.find((c) => {
return c.type === "performers";
});
if (performerCriterion &&
(performerCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
performerCriterion.modifier === GQL.CriterionModifier.Includes)) {
// add the performer if not present
if (!performerCriterion.value.find((p : any) => {
return p.id === props.performer.id;
})) {
performerCriterion.value.push(performerValue);
}
performerCriterion.modifier = GQL.CriterionModifier.IncludesAll;
} else {
// overwrite
performerCriterion = new PerformersCriterion();
performerCriterion.value = [performerValue];
filter.criteria.push(performerCriterion);
}
return filter;
}
return (
<SceneList
base={props.base}
subComponent={true}
filterHook={filterHook}
/>
);
}

View file

@ -1,62 +0,0 @@
import _ from "lodash";
import React, { FunctionComponent } from "react";
import { QueryHookResult } from "react-apollo-hooks";
import { FindPerformersQuery, FindPerformersVariables } from "../../core/generated-graphql";
import { ListHook } from "../../hooks/ListHook";
import { IBaseProps } from "../../models/base-props";
import { ListFilterModel } from "../../models/list-filter/filter";
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
import { PerformerCard } from "./PerformerCard";
import { PerformerListTable } from "./PerformerListTable";
import { StashService } from "../../core/StashService";
interface IPerformerListProps extends IBaseProps {}
export const PerformerList: FunctionComponent<IPerformerListProps> = (props: IPerformerListProps) => {
const otherOperations = [
{
text: "Open Random",
onClick: getRandom,
}
];
const listData = ListHook.useList({
filterMode: FilterMode.Performers,
props,
otherOperations: otherOperations,
renderContent,
});
async function getRandom(result: QueryHookResult<FindPerformersQuery, FindPerformersVariables>, filter: ListFilterModel) {
if (result.data && result.data.findPerformers) {
let count = result.data.findPerformers.count;
let index = Math.floor(Math.random() * count);
let filterCopy = _.cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await StashService.queryFindPerformers(filterCopy);
if (singleResult && singleResult.data && singleResult.data.findPerformers && singleResult.data.findPerformers.performers.length === 1) {
let id = singleResult!.data!.findPerformers!.performers[0]!.id;
props.history.push("/performers/" + id);
}
}
}
function renderContent(
result: QueryHookResult<FindPerformersQuery, FindPerformersVariables>, filter: ListFilterModel) {
if (!result.data || !result.data.findPerformers) { return; }
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="grid">
{result.data.findPerformers.performers.map((p) => (<PerformerCard key={p.id} performer={p} />))}
</div>
);
} else if (filter.displayMode === DisplayMode.List) {
return <PerformerListTable performers={result.data.findPerformers.performers}/>;
} else if (filter.displayMode === DisplayMode.Wall) {
return;
}
}
return listData.template;
};

View file

@ -1,107 +0,0 @@
import {
HTMLTable,
H5,
H6,
Button,
} from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql";
import { NavigationUtils } from "../../utils/navigation";
interface IPerformerListTableProps {
performers: GQL.PerformerDataFragment[];
}
export const PerformerListTable: FunctionComponent<IPerformerListTableProps> = (props: IPerformerListTableProps) => {
function maybeRenderFavoriteHeart(performer : GQL.PerformerDataFragment) {
if (!performer.favorite) { return; }
return (
<Button
icon="heart"
disabled={true}
className="favorite"
minimal={true}
/>
);
}
function renderPerformerImage(performer : GQL.PerformerDataFragment) {
const style: React.CSSProperties = {
backgroundImage: `url('${performer.image_path}')`,
lineHeight: 5,
backgroundSize: "contain",
display: "inline-block",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
};
return (
<Link
className="performer-list-thumbnail"
to={`/performers/${performer.id}`}
style={style}/>
)
}
function renderPerformerRow(performer : GQL.PerformerDataFragment) {
return (
<>
<tr>
<td>
{renderPerformerImage(performer)}
</td>
<td style={{textAlign: "left"}}>
<Link to={`/performers/${performer.id}`}>
<H5 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
{performer.name}
</H5>
</Link>
</td>
<td>
{performer.aliases ? performer.aliases : ''}
</td>
<td>
{maybeRenderFavoriteHeart(performer)}
</td>
<td>
<Link to={NavigationUtils.makePerformerScenesUrl(performer)}>
<H6>{performer.scene_count}</H6>
</Link>
</td>
<td>
{performer.birthdate}
</td>
<td>
{performer.height}
</td>
</tr>
</>
)
}
return (
<>
<div className="grid">
<HTMLTable className="bp3-html-table bp3-html-table-bordered bp3-html-table-condensed bp3-html-table-striped bp3-interactive">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Aliases</th>
<th>Favourite</th>
<th>Scene Count</th>
<th>Birthdate</th>
<th>Height</th>
</tr>
</thead>
<tbody>
{props.performers.map(renderPerformerRow)}
</tbody>
</HTMLTable>
</div>
</>
);
};

View file

@ -1,13 +0,0 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import { Performer } from "./PerformerDetails/Performer";
import { PerformerList } from "./PerformerList";
const Performers = () => (
<Switch>
<Route exact={true} path="/performers" component={PerformerList} />
<Route path="/performers/:id" component={Performer} />
</Switch>
);
export default Performers;

View file

@ -1,50 +0,0 @@
import React, { FunctionComponent } from "react";
import { Button, Popover, Menu, MenuItem } from "@blueprintjs/core";
import { Icons } from "../../utils/icons";
export interface IOCounterButtonProps {
loading: boolean
value: number
onIncrement: () => void
onDecrement: () => void
onReset: () => void
onMenuOpened?: () => void
onMenuClosed?: () => void
}
export const OCounterButton: FunctionComponent<IOCounterButtonProps> = (props: IOCounterButtonProps) => {
function renderButton() {
return (
<Button
loading={props.loading}
icon={Icons.sweatDrops()}
text={props.value}
minimal={true}
onClick={props.onIncrement}
disabled={props.loading}
/>
);
}
if (props.value) {
// just render the button by itself
return (
<Popover
interactionKind={"hover"}
hoverOpenDelay={1000}
position="bottom"
disabled={props.loading}
onOpening={props.onMenuOpened}
onClosing={props.onMenuClosed}
>
{renderButton()}
<Menu>
<MenuItem text="Decrement" icon="minus" onClick={props.onDecrement}/>
<MenuItem text="Reset" icon="disable" onClick={props.onReset}/>
</Menu>
</Popover>
);
} else {
return renderButton();
}
}

View file

@ -1,283 +0,0 @@
import {
Button,
ButtonGroup,
Card,
Checkbox,
Divider,
Elevation,
H4,
Popover,
} from "@blueprintjs/core";
import React, { FunctionComponent, useState } from "react";
import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql";
import { VideoHoverHook } from "../../hooks/VideoHover";
import { ColorUtils } from "../../utils/color";
import { TextUtils } from "../../utils/text";
import { TagLink } from "../Shared/TagLink";
import { ZoomUtils } from "../../utils/zoom";
import { StashService } from "../../core/StashService";
import { Icons } from "../../utils/icons";
interface ISceneCardProps {
scene: GQL.SlimSceneDataFragment;
selected: boolean | undefined;
zoomIndex: number;
onSelectedChanged: (selected : boolean, shiftKey : boolean) => void;
}
export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardProps) => {
const [previewPath, setPreviewPath] = useState<string | undefined>(undefined);
const videoHoverHook = VideoHoverHook.useVideoHover({resetOnMouseLeave: false});
const config = StashService.useConfiguration();
const showStudioAsText = !!config.data && !!config.data.configuration ? config.data.configuration.interface.showStudioAsText : false;
function maybeRenderRatingBanner() {
if (!props.scene.rating) { return; }
return (
<div className={`rating-banner ${ColorUtils.classForRating(props.scene.rating)}`}>
RATING: {props.scene.rating}
</div>
);
}
function maybeRenderSceneSpecsOverlay() {
return (
<div className={`scene-specs-overlay`}>
{!!props.scene.file.height ? <span className={`overlay-resolution`}> {TextUtils.resolution(props.scene.file.height)}</span> : undefined}
{props.scene.file.duration !== undefined && props.scene.file.duration >= 1 ? TextUtils.secondsToTimestamp(props.scene.file.duration) : ""}
</div>
);
}
function maybeRenderSceneStudioOverlay() {
if (!props.scene.studio) {
return;
}
let style: React.CSSProperties = {
backgroundImage: `url('${props.scene.studio.image_path}')`,
};
let text = "";
if (showStudioAsText) {
style = {};
text = props.scene.studio.name;
}
return (
<div className={`scene-studio-overlay`}>
<Link
to={`/studios/${props.scene.studio.id}`}
style={style}
>
{text}
</Link>
</div>
);
}
function maybeRenderTagPopoverButton() {
if (props.scene.tags.length <= 0) { return; }
const tags = props.scene.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} />
));
return (
<Popover interactionKind={"hover"} position="bottom">
<Button
icon="tag"
text={props.scene.tags.length}
/>
<>{tags}</>
</Popover>
);
}
function maybeRenderPerformerPopoverButton() {
if (props.scene.performers.length <= 0) { return; }
const performers = props.scene.performers.map((performer) => {
return (
<>
<div className="performer-tag-container">
<Link
to={`/performers/${performer.id}`}
className="performer-tag previewable image"
style={{backgroundImage: `url(${performer.image_path})`}}
></Link>
<TagLink key={performer.id} performer={performer} />
</div>
</>
);
});
return (
<Popover interactionKind={"hover"} position="bottom">
<Button
icon="person"
text={props.scene.performers.length}
/>
<>{performers}</>
</Popover>
);
}
function maybeRenderMoviePopoverButton() {
if (props.scene.movies.length <= 0) { return; }
const movies = props.scene.movies.map((sceneMovie) => {
let movie = sceneMovie.movie;
return (
<>
<div className="movie-tag-container">
<Link
to={`/movies/${movie.id}`}
className="movie-tag previewable image"
style={{backgroundImage: `url(${movie.front_image_path})`}}
></Link>
<TagLink key={movie.id} movie={movie} />
</div>
</>
);
});
return (
<Popover interactionKind={"hover"} position="bottom">
<Button
icon="film"
text={props.scene.movies.length}
/>
<>{movies}</>
</Popover>
);
}
function maybeRenderSceneMarkerPopoverButton() {
if (props.scene.scene_markers.length <= 0) { return; }
const sceneMarkers = props.scene.scene_markers.map((marker) => {
(marker as any).scene = {};
(marker as any).scene.id = props.scene.id;
return <TagLink key={marker.id} marker={marker} />;
});
return (
<Popover interactionKind={"hover"} position="bottom">
<Button
icon="map-marker"
text={props.scene.scene_markers.length}
/>
<>{sceneMarkers}</>
</Popover>
);
}
function maybeRenderOCounter() {
if (props.scene.o_counter) {
return (
<Button
icon={Icons.sweatDrops()}
text={props.scene.o_counter}
/>
)
}
}
function maybeRenderPopoverButtonGroup() {
if (props.scene.tags.length > 0 ||
props.scene.performers.length > 0 ||
props.scene.movies.length > 0 ||
props.scene.scene_markers.length > 0 ||
props.scene.o_counter) {
return (
<>
<Divider />
<ButtonGroup minimal={true} className="card-section centered">
{maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()}
{maybeRenderMoviePopoverButton()}
{maybeRenderSceneMarkerPopoverButton()}
{maybeRenderOCounter()}
</ButtonGroup>
</>
);
}
}
function onMouseEnter() {
if (!previewPath || previewPath === "") {
setPreviewPath(props.scene.paths.preview || "");
}
VideoHoverHook.onMouseEnter(videoHoverHook);
}
function onMouseLeave() {
VideoHoverHook.onMouseLeave(videoHoverHook);
setPreviewPath("");
}
function isPortrait() {
let file = props.scene.file;
let width = file.width ? file.width : 0;
let height = file.height ? file.height : 0;
return height > width;
}
function getLinkClassName() {
let ret = "image previewable";
if (isPortrait()) {
ret += " portrait";
}
return ret;
}
function getVideoClassName() {
let ret = "preview";
if (isPortrait()) {
ret += " portrait";
}
return ret;
}
var shiftKey = false;
return (
<Card
className={"grid-item scene-card " + ZoomUtils.classForZoom(props.zoomIndex)}
elevation={Elevation.ONE}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<Checkbox
className="card-select"
checked={props.selected}
onChange={() => props.onSelectedChanged(!props.selected, shiftKey)}
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => { shiftKey = event.shiftKey; event.stopPropagation(); } }
/>
<Link to={`/scenes/${props.scene.id}`} className={getLinkClassName()}>
<div className="video-container">
{maybeRenderRatingBanner()}
{maybeRenderSceneSpecsOverlay()}
{maybeRenderSceneStudioOverlay()}
<video className={getVideoClassName()} loop={true} poster={props.scene.paths.screenshot || ""} ref={videoHoverHook.videoEl}>
{!!previewPath ? <source src={previewPath} /> : ""}
</video>
</div>
</Link>
<div className="card-section">
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
{!!props.scene.title ? props.scene.title : TextUtils.fileNameFromPath(props.scene.path)}
</H4>
<span className="bp3-text-small bp3-text-muted">{props.scene.date}</span>
<p>{TextUtils.truncate(props.scene.details, 100, "... (continued)")}</p>
</div>
{maybeRenderPopoverButtonGroup()}
</Card>
);
};

View file

@ -1,181 +0,0 @@
import {
Card,
Spinner,
Tab,
Tabs,
} from "@blueprintjs/core";
import queryString from "query-string";
import React, { FunctionComponent, useEffect, useState } from "react";
import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
import { IBaseProps } from "../../../models";
import { GalleryViewer } from "../../Galleries/GalleryViewer";
import { ScenePlayer } from "../ScenePlayer/ScenePlayer";
import { SceneDetailPanel } from "./SceneDetailPanel";
import { SceneEditPanel } from "./SceneEditPanel";
import { SceneFileInfoPanel } from "./SceneFileInfoPanel";
import { SceneMarkersPanel } from "./SceneMarkersPanel";
import { ScenePerformerPanel } from "./ScenePerformerPanel";
import { SceneMoviePanel } from "./SceneMoviePanel";
import { ErrorUtils } from "../../../utils/errors";
import { IOCounterButtonProps, OCounterButton } from "../OCounterButton";
import { SceneOperationsPanel } from "./SceneOperationsPanel";
interface ISceneProps extends IBaseProps {}
export const Scene: FunctionComponent<ISceneProps> = (props: ISceneProps) => {
const [timestamp, setTimestamp] = useState<number>(0);
const [autoplay, setAutoplay] = useState<boolean>(false);
const [scene, setScene] = useState<Partial<GQL.SceneDataFragment>>({});
const [isLoading, setIsLoading] = useState(false);
const { data, error, loading, refetch } = StashService.useFindScene(props.match.params.id);
const [oLoading, setOLoading] = useState(false);
const incrementO = StashService.useSceneIncrementO(scene.id || "0");
const decrementO = StashService.useSceneDecrementO(scene.id || "0");
const resetO = StashService.useSceneResetO(scene.id || "0");
useEffect(() => {
setIsLoading(loading);
if (!data || !data.findScene || !!error) { return; }
setScene(StashService.nullToUndefined(data.findScene));
}, [data, loading, error]);
useEffect(() => {
const queryParams = queryString.parse(props.location.search);
if (!!queryParams.t && typeof queryParams.t === "string" && timestamp === 0) {
const newTimestamp = parseInt(queryParams.t, 10);
setTimestamp(newTimestamp);
}
if (queryParams.autoplay && typeof queryParams.autoplay === "string") {
setAutoplay(queryParams.autoplay === "true");
}
}, [props.location.search, timestamp]);
function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
setTimestamp(marker.seconds);
}
if (!data || !data.findScene || isLoading || Object.keys(scene).length === 0) {
return <Spinner size={Spinner.SIZE_LARGE} />;
}
const modifiedScene =
Object.assign({scene_marker_tags: data.sceneMarkerTags}, scene) as GQL.SceneDataFragment; // TODO Hack from angular
if (!!error) { return <>error...</>; }
function updateOCounter(newValue: number) {
const modifiedScene = Object.assign({}, scene);
modifiedScene.o_counter = newValue;
setScene(modifiedScene);
}
async function onIncrementClick() {
try {
setOLoading(true);
const result = await incrementO();
updateOCounter(result.data.sceneIncrementO);
} catch (e) {
ErrorUtils.handle(e);
} finally {
setOLoading(false);
}
}
async function onDecrementClick() {
try {
setOLoading(true);
const result = await decrementO();
updateOCounter(result.data.sceneDecrementO);
} catch (e) {
ErrorUtils.handle(e);
} finally {
setOLoading(false);
}
}
async function onResetClick() {
try {
setOLoading(true);
const result = await resetO();
updateOCounter(result.data.sceneResetO);
} catch (e) {
ErrorUtils.handle(e);
} finally {
setOLoading(false);
}
}
const oCounterProps : IOCounterButtonProps = {
loading: oLoading,
value: scene.o_counter || 0,
onIncrement: onIncrementClick,
onDecrement: onDecrementClick,
onReset: onResetClick
}
return (
<>
<ScenePlayer scene={modifiedScene} timestamp={timestamp} autoplay={autoplay}/>
<Card id="details-container">
<Tabs
renderActiveTabPanelOnly={true}
large={true}
>
<Tab id="scene-details-panel" title="Details" panel={<SceneDetailPanel scene={modifiedScene} />} />
<Tab
id="scene-markers-panel"
title="Markers"
panel={<SceneMarkersPanel scene={modifiedScene} onClickMarker={onClickMarker} />}
/>
{modifiedScene.performers.length > 0 ?
<Tab
id="scene-performer-panel"
title="Performers"
panel={<ScenePerformerPanel scene={modifiedScene} />}
/> : undefined
}
{modifiedScene.movies.length > 0 ?
<Tab
id="scene-movie-panel"
title="Movies"
panel={<SceneMoviePanel scene={modifiedScene} />}
/> : undefined
}
{!!modifiedScene.gallery ?
<Tab
id="scene-gallery-panel"
title="Gallery"
panel={<GalleryViewer gallery={modifiedScene.gallery} />}
/> : undefined
}
<Tab id="scene-file-info-panel" title="File Info" panel={<SceneFileInfoPanel scene={modifiedScene} />} />
<Tab
id="scene-edit-panel"
title="Edit"
panel={
<SceneEditPanel
scene={modifiedScene}
onUpdate={(newScene) => setScene(newScene)}
onDelete={() => props.history.push("/scenes")}
/>}
/>
<Tab
id="scene-operations-panel"
title="Operations"
panel={
<SceneOperationsPanel
scene={modifiedScene}
/>}
/>
<Tabs.Expander />
<OCounterButton
{...oCounterProps}
/>
</Tabs>
</Card>
</>
);
};

View file

@ -1,54 +0,0 @@
import {
H1,
H4,
H6,
} from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import * as GQL from "../../../core/generated-graphql";
import { TextUtils } from "../../../utils/text";
import { TagLink } from "../../Shared/TagLink";
import { SceneHelpers } from "../helpers";
interface ISceneDetailProps {
scene: GQL.SceneDataFragment;
}
export const SceneDetailPanel: FunctionComponent<ISceneDetailProps> = (props: ISceneDetailProps) => {
function renderDetails() {
if (!props.scene.details || props.scene.details === "") { return; }
return (
<>
<H6>Details</H6>
<p className="pre">{props.scene.details}</p>
</>
);
}
function renderTags() {
if (props.scene.tags.length === 0) { return; }
const tags = props.scene.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} />
));
return (
<>
<H6>Tags</H6>
{tags}
</>
);
}
return (
<>
{SceneHelpers.maybeRenderStudio(props.scene, 70, false)}
<H1 className="bp3-heading">
{!!props.scene.title ? props.scene.title : TextUtils.fileNameFromPath(props.scene.path)}
</H1>
{!!props.scene.date ? <H4>{props.scene.date}</H4> : undefined}
{!!props.scene.rating ? <H6>Rating: {props.scene.rating}</H6> : undefined}
{!!props.scene.file.height ? <H6>Resolution: {TextUtils.resolution(props.scene.file.height)}</H6> : undefined}
{renderDetails()}
{renderTags()}
</>
);
};

View file

@ -1,487 +0,0 @@
import {
Button,
Classes,
Checkbox,
Dialog,
FormGroup,
HTMLSelect,
InputGroup,
Spinner,
TextArea,
Collapse,
Icon,
FileInput,
Menu,
Popover,
MenuItem,
} from "@blueprintjs/core";
import React, { FunctionComponent, useEffect, useState } from "react";
import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
import { ErrorUtils } from "../../../utils/errors";
import { ToastUtils } from "../../../utils/toasts";
import { FilterMultiSelect } from "../../select/FilterMultiSelect";
import { FilterSelect } from "../../select/FilterSelect";
import { ValidGalleriesSelect } from "../../select/ValidGalleriesSelect";
import { ImageUtils } from "../../../utils/image";
import { SceneMovieTable } from "./SceneMovieTable";
interface IProps {
scene: GQL.SceneDataFragment;
onUpdate: (scene: GQL.SceneDataFragment) => void;
onDelete: () => void;
}
export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
// Editing scene state
const [title, setTitle] = useState<string | undefined>(undefined);
const [details, setDetails] = useState<string | undefined>(undefined);
const [url, setUrl] = useState<string | undefined>(undefined);
const [date, setDate] = useState<string | undefined>(undefined);
const [rating, setRating] = useState<number | undefined>(undefined);
const [galleryId, setGalleryId] = useState<string | undefined>(undefined);
const [studioId, setStudioId] = useState<string | undefined>(undefined);
const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined);
const [movieIds, setMovieIds] = useState<string[] | undefined>(undefined);
const [sceneIdx, setSceneIdx] = useState<string[] | undefined>(undefined);
const [tagIds, setTagIds] = useState<string[] | undefined>(undefined);
const [coverImage, setCoverImage] = useState<string | undefined>(undefined);
const Scrapers = StashService.useListSceneScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.ListSceneScrapersListSceneScrapers[]>([]);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [deleteFile, setDeleteFile] = useState<boolean>(false);
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
const [isCoverImageOpen, setIsCoverImageOpen] = useState<boolean>(false);
const [coverImagePreview, setCoverImagePreview] = useState<string | undefined>(undefined);
// Network state
const [isLoading, setIsLoading] = useState(false);
const updateScene = StashService.useSceneUpdate(getSceneInput());
const deleteScene = StashService.useSceneDestroy(getSceneDeleteInput());
useEffect(() => {
var newQueryableScrapers : GQL.ListSceneScrapersListSceneScrapers[] = [];
if (!!Scrapers.data && Scrapers.data.listSceneScrapers) {
newQueryableScrapers = Scrapers.data.listSceneScrapers.filter((s) => {
return s.scene && s.scene.supported_scrapes.includes(GQL.ScrapeType.Fragment);
});
}
setQueryableScrapers(newQueryableScrapers);
}, [Scrapers.data])
function updateSceneEditState(state: Partial<GQL.SceneDataFragment>) {
const perfIds = !!state.performers ? state.performers.map((performer) => performer.id) : undefined;
const moviIds = !!state.movies ? state.movies.map((sceneMovie) => sceneMovie.movie.id) : undefined;
const scenIdx = !!state.movies ? state.movies.map((movie) => movie.scene_index!) : undefined;
const tIds = !!state.tags ? state.tags.map((tag) => tag.id) : undefined;
setTitle(state.title);
setDetails(state.details);
setUrl(state.url);
setDate(state.date);
setRating(state.rating == null ? NaN : state.rating);
setGalleryId(state.gallery ? state.gallery.id : undefined);
setStudioId(state.studio ? state.studio.id : undefined);
setMovieIds(moviIds);
setPerformerIds(perfIds);
setSceneIdx(scenIdx);
setTagIds(tIds);
}
useEffect(() => {
updateSceneEditState(props.scene);
setCoverImagePreview(props.scene.paths.screenshot);
}, [props.scene]);
ImageUtils.addPasteImageHook(onImageLoad);
// if (!isNew && !isEditing) {
// if (!data || !data.findPerformer || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
// if (!!error) { return <>error...</>; }
// }
function getSceneInput(): GQL.SceneUpdateInput {
return {
id: props.scene.id,
title,
details,
url,
date,
rating,
gallery_id: galleryId,
studio_id: studioId,
performer_ids: performerIds,
movies: makeMovieInputs(),
tag_ids: tagIds,
cover_image: coverImage,
};
}
function makeMovieInputs(): GQL.SceneMovieInput[] | undefined {
if (!movieIds) {
return undefined;
}
let ret = movieIds.map((id) => {
let r : GQL.SceneMovieInput = {
movie_id: id
};
return r;
});
if (sceneIdx) {
sceneIdx.forEach((idx, i) => {
if (!!idx && ret.length > i) {
ret[i].scene_index = idx;
}
});
}
return ret;
}
async function onSave() {
setIsLoading(true);
try {
const result = await updateScene();
props.onUpdate(result.data.sceneUpdate);
ToastUtils.success("Updated scene");
} catch (e) {
ErrorUtils.handle(e);
}
setIsLoading(false);
}
function getSceneDeleteInput(): GQL.SceneDestroyInput {
return {
id: props.scene.id,
delete_file: deleteFile,
delete_generated: deleteGenerated
};
}
async function onDelete() {
setIsDeleteAlertOpen(false);
setIsLoading(true);
try {
await deleteScene();
ToastUtils.success("Deleted scene");
} catch (e) {
ErrorUtils.handle(e);
}
setIsLoading(false);
props.onDelete();
}
function renderMultiSelect(type: "performers" | "movies" | "tags", initialIds: string[] | undefined) {
return (
<FilterMultiSelect
type={type}
onUpdate={(items) => {
const ids = items.map((i) => i.id);
switch (type) {
case "performers": setPerformerIds(ids); break;
case "movies": setMovieIds(ids); break;
case "tags": setTagIds(ids); break;
}
}}
initialIds={initialIds}
/>
);
}
function renderTableMovies( initialIds: string[] | undefined, initialIdx: string[] | undefined ) {
return (
<SceneMovieTable
initialIds={initialIds}
initialIdx={initialIdx}
onUpdate={(items) => {
const idx = items.map((i) => i);
setSceneIdx(idx);
}}
/>
);
}
function renderDeleteAlert() {
return (
<>
<Dialog
canOutsideClickClose={false}
canEscapeKeyClose={false}
icon="trash"
isCloseButtonShown={false}
isOpen={isDeleteAlertOpen}
title="Delete Scene?"
>
<div className={Classes.DIALOG_BODY}>
<p>
Are you sure you want to delete this scene? Unless the file is also deleted, this scene will be re-added when scan is performed.
</p>
<Checkbox checked={deleteFile} label="Delete file" onChange={() => setDeleteFile(!deleteFile)} />
<Checkbox checked={deleteGenerated} label="Delete generated supporting files" onChange={() => setDeleteGenerated(!deleteGenerated)} />
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button intent="danger" onClick={() => onDelete()}>Delete</Button>
<Button onClick={() => setIsDeleteAlertOpen(false)}>Cancel</Button>
</div>
</div>
</Dialog>
</>
);
}
function onImageLoad(this: FileReader) {
setCoverImagePreview(this.result as string);
setCoverImage(this.result as string);
}
function onCoverImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
async function onScrapeClicked(scraper : GQL.ListSceneScrapersListSceneScrapers) {
setIsLoading(true);
try {
const result = await StashService.queryScrapeScene(scraper.id, getSceneInput());
if (!result.data || !result.data.scrapeScene) { return; }
updateSceneFromScrapedScene(result.data.scrapeScene);
} catch (e) {
ErrorUtils.handle(e);
} finally {
setIsLoading(false);
}
}
function renderScraperMenuItem(scraper : GQL.ListSceneScrapersListSceneScrapers) {
return (
<MenuItem
text={scraper.name}
onClick={() => { onScrapeClicked(scraper); }}
/>
);
}
function renderScraperMenu() {
if (!queryableScrapers || queryableScrapers.length === 0) {
return;
}
const scraperMenu = (
<Menu>
{queryableScrapers ? queryableScrapers.map((s) => renderScraperMenuItem(s)) : undefined}
</Menu>
);
return (
<Popover content={scraperMenu} position="bottom">
<Button text="Scrape with..."/>
</Popover>
);
}
function urlScrapable(url: string) : boolean {
return !!url && !!Scrapers.data && Scrapers.data.listSceneScrapers && Scrapers.data.listSceneScrapers.some((s) => {
return !!s.scene && !!s.scene.urls && s.scene.urls.some((u) => { return url.includes(u); });
});
}
function updateSceneFromScrapedScene(scene : GQL.ScrapedSceneDataFragment) {
if (!title && scene.title) {
setTitle(scene.title);
}
if (!details && scene.details) {
setDetails(scene.details);
}
if (!date && scene.date) {
setDate(scene.date);
}
if (!url && scene.url) {
setUrl(scene.url);
}
if (!studioId && scene.studio && scene.studio.id) {
setStudioId(scene.studio.id);
}
if ((!performerIds || performerIds.length === 0) && scene.performers && scene.performers.length > 0) {
let idPerfs = scene.performers.filter((p) => {
return p.id !== undefined && p.id !== null;
});
if (idPerfs.length > 0) {
let newIds = idPerfs.map((p) => p.id);
setPerformerIds(newIds as string[]);
}
}
if ((!movieIds || movieIds.length === 0) && scene.movies && scene.movies.length > 0) {
let idMovis = scene.movies.filter((p) => {
return p.id !== undefined && p.id !== null;
});
if (idMovis.length > 0) {
let newIds = idMovis.map((p) => p.id);
setMovieIds(newIds as string[]);
}
}
if ((!sceneIdx || sceneIdx.length === 0) && scene.movies && scene.movies.length > 0) {
let idxScen= scene.movies.filter((p) => {
return p.id !== undefined && p.id !== null;
});
if (idxScen.length > 0) {
let newIds = idxScen.map((p) => p.id);
setSceneIdx(newIds as string[]);
}
}
if ((!tagIds || tagIds.length === 0) && scene.tags && scene.tags.length > 0) {
let idTags = scene.tags.filter((p) => {
return p.id !== undefined && p.id !== null;
});
if (idTags.length > 0) {
let newIds = idTags.map((p) => p.id);
setTagIds(newIds as string[]);
}
}
if (scene.image) {
// image is a base64 string
setCoverImage(scene.image);
setCoverImagePreview(scene.image);
}
}
async function onScrapeSceneURL() {
if (!url) { return; }
setIsLoading(true);
try {
const result = await StashService.queryScrapeSceneURL(url);
if (!result.data || !result.data.scrapeSceneURL) { return; }
updateSceneFromScrapedScene(result.data.scrapeSceneURL);
} catch (e) {
ErrorUtils.handle(e);
} finally {
setIsLoading(false);
}
}
function maybeRenderScrapeButton() {
if (!url || !urlScrapable(url)) {
return undefined;
}
return (
<Button
minimal={true}
icon="import"
id="scrape-url-button"
onClick={() => onScrapeSceneURL()}/>
)
}
return (
<>
{renderDeleteAlert()}
{isLoading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
<div className="form-container " style={{width: "50%"}}>
<FormGroup label="Title">
<InputGroup
onChange={(newValue: any) => setTitle(newValue.target.value)}
value={title}
/>
</FormGroup>
<FormGroup label="Details">
<TextArea
fill={true}
onChange={(newValue) => setDetails(newValue.target.value)}
value={details}
/>
</FormGroup>
<FormGroup label="URL">
<InputGroup
onChange={(newValue: any) => setUrl(newValue.target.value)}
value={url}
/>
{maybeRenderScrapeButton()}
</FormGroup>
<FormGroup label="Date" helperText="YYYY-MM-DD">
<InputGroup
onChange={(newValue: any) => setDate(newValue.target.value)}
value={date}
/>
</FormGroup>
<FormGroup label="Rating">
<HTMLSelect
options={["", 1, 2, 3, 4, 5]}
onChange={(event) => setRating(parseInt(event.target.value, 10))}
value={rating}
/>
</FormGroup>
<FormGroup label="Gallery">
<ValidGalleriesSelect
sceneId={props.scene.id}
initialId={galleryId}
onSelectItem={(item) => setGalleryId(item ? item.id : undefined)}
/>
</FormGroup>
<FormGroup label="Studio">
<FilterSelect
type="studios"
onSelectItem={(item) => setStudioId(item ? item.id : undefined)}
initialId={studioId}
/>
</FormGroup>
<FormGroup label="Performers">
{renderMultiSelect("performers", performerIds)}
</FormGroup>
<FormGroup label="Movies/Scenes">
{renderMultiSelect("movies", movieIds)}
{renderTableMovies(movieIds, sceneIdx)}
</FormGroup>
<FormGroup label="Tags">
{renderMultiSelect("tags", tagIds)}
</FormGroup>
<div className="bp3-form-group">
<label className="bp3-label collapsible-label" onClick={() => setIsCoverImageOpen(!isCoverImageOpen)}>
<Icon className="label-icon" icon={isCoverImageOpen ? "chevron-down" : "chevron-right"}/>
<span>Cover Image</span>
</label>
<Collapse isOpen={isCoverImageOpen}>
<img alt="Scene cover" className="scene-cover" src={coverImagePreview} />
<FileInput text="Choose image..." onInputChange={onCoverImageChange} inputProps={{accept: ".jpg,.jpeg,.png"}} />
</Collapse>
</div>
</div>
<Button className="edit-button" text="Save" intent="primary" onClick={() => onSave()}/>
<Button className="edit-button" text="Delete" intent="danger" onClick={() => setIsDeleteAlertOpen(true)}/>
{renderScraperMenu()}
</>
);
};

View file

@ -1,139 +0,0 @@
import {
HTMLTable,
} from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import * as GQL from "../../../core/generated-graphql";
import { TextUtils } from "../../../utils/text";
interface ISceneFileInfoPanelProps {
scene: GQL.SceneDataFragment;
}
export const SceneFileInfoPanel: FunctionComponent<ISceneFileInfoPanelProps> = (props: ISceneFileInfoPanelProps) => {
function renderChecksum() {
return (
<tr>
<td>Checksum</td>
<td>{props.scene.checksum}</td>
</tr>
);
}
function renderPath() {
return (
<tr>
<td>Path</td>
<td><a href={"file://"+props.scene.path}>{"file://"+props.scene.path}</a> </td>
</tr>
);
}
function renderStream() {
return (
<tr>
<td>Stream</td>
<td><a href={props.scene.paths.stream}>{props.scene.paths.stream}</a> </td>
</tr>
);
}
function renderFileSize() {
if (props.scene.file.size === undefined) { return; }
return (
<tr>
<td>File Size</td>
<td>{TextUtils.fileSize(parseInt(props.scene.file.size, 10))}</td>
</tr>
);
}
function renderDuration() {
if (props.scene.file.duration === undefined) { return; }
return (
<tr>
<td>Duration</td>
<td>{TextUtils.secondsToTimestamp(props.scene.file.duration)}</td>
</tr>
);
}
function renderDimensions() {
if (props.scene.file.duration === undefined) { return; }
return (
<tr>
<td>Dimensions</td>
<td>{props.scene.file.width} x {props.scene.file.height}</td>
</tr>
);
}
function renderFrameRate() {
if (props.scene.file.framerate === undefined) { return; }
return (
<tr>
<td>Frame Rate</td>
<td>{props.scene.file.framerate} frames per second</td>
</tr>
);
}
function renderBitRate() {
if (props.scene.file.bitrate === undefined) { return; }
return (
<tr>
<td>Bit Rate</td>
<td>{TextUtils.bitRate(props.scene.file.bitrate)}</td>
</tr>
);
}
function renderVideoCodec() {
if (props.scene.file.video_codec === undefined) { return; }
return (
<tr>
<td>Video Codec</td>
<td>{props.scene.file.video_codec}</td>
</tr>
);
}
function renderAudioCodec() {
if (props.scene.file.audio_codec === undefined) { return; }
return (
<tr>
<td>Audio Codec</td>
<td>{props.scene.file.audio_codec}</td>
</tr>
);
}
function renderUrl() {
if (!props.scene.url || props.scene.url === "") { return; }
return (
<tr>
<td>Downloaded From</td>
<td>{props.scene.url}</td>
</tr>
);
}
return (
<>
<HTMLTable>
<tbody>
{renderChecksum()}
{renderPath()}
{renderStream()}
{renderFileSize()}
{renderDuration()}
{renderDimensions()}
{renderFrameRate()}
{renderBitRate()}
{renderVideoCodec()}
{renderAudioCodec()}
{renderUrl()}
</tbody>
</HTMLTable>
</>
);
};

View file

@ -1,270 +0,0 @@
import {
Button,
Card,
Collapse,
Divider,
FormGroup,
H3,
Tag,
} from "@blueprintjs/core";
import { Field, FieldProps, Form, Formik, FormikActions, FormikProps } from "formik";
import React, { CSSProperties, FunctionComponent, useState } from "react";
import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
import { TextUtils } from "../../../utils/text";
import { FilterMultiSelect } from "../../select/FilterMultiSelect";
import { FilterSelect } from "../../select/FilterSelect";
import { MarkerTitleSuggest } from "../../select/MarkerTitleSuggest";
import { WallPanel } from "../../Wall/WallPanel";
import { SceneHelpers } from "../helpers";
import { ErrorUtils } from "../../../utils/errors";
import { DurationInput } from "../../Shared/DurationInput";
interface ISceneMarkersPanelProps {
scene: GQL.SceneDataFragment;
onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;
}
interface IFormFields {
title: string;
seconds: string;
primaryTagId: string;
tagIds: string[];
}
export const SceneMarkersPanel: FunctionComponent<ISceneMarkersPanelProps> = (props: ISceneMarkersPanelProps) => {
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
const [editingMarker, setEditingMarker] = useState<GQL.SceneMarkerDataFragment | null>(null);
const sceneMarkerCreate = StashService.useSceneMarkerCreate();
const sceneMarkerUpdate = StashService.useSceneMarkerUpdate();
const sceneMarkerDestroy = StashService.useSceneMarkerDestroy();
const jwplayer = SceneHelpers.getPlayer();
function onOpenEditor(marker: GQL.SceneMarkerDataFragment | null = null) {
setIsEditorOpen(true);
setEditingMarker(marker);
}
function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
props.onClickMarker(marker);
}
function renderTags() {
function renderMarkers(primaryTag: GQL.FindSceneSceneMarkerTags) {
const markers = primaryTag.scene_markers.map((marker) => {
const markerTags = marker.tags.map((tag) => (
<Tag key={tag.id} className="tag-item">{tag.name}</Tag>
));
return (
<div key={marker.id}>
<Divider />
<div>
<button className="button-link" onClick={() => onClickMarker(marker)}>{marker.title}</button>
{!isEditorOpen ? <button className="button-link" style={{float: "right"}} onClick={() => onOpenEditor(marker)}>Edit</button> : undefined}
</div>
<div>
{TextUtils.secondsToTimestamp(marker.seconds)}
</div>
<div className="card-section centered">
{markerTags}
</div>
</div>
);
});
return markers;
}
const style: CSSProperties = {
height: "300px",
overflowY: "auto",
overflowX: "hidden",
display: "inline-block",
margin: "5px",
width: "300px",
flex: "0 0 auto",
};
const tags = (props.scene as any).scene_marker_tags.map((primaryTag: GQL.FindSceneSceneMarkerTags) => {
return (
<div key={primaryTag.tag.id} style={{padding: "1px"}}>
<Card style={style}>
<div className="content" style={{whiteSpace: "normal"}}>
<H3>{primaryTag.tag.name}</H3>
{renderMarkers(primaryTag)}
</div>
</Card>
</div>
);
});
return tags;
}
function renderForm() {
function onSubmit(values: IFormFields, _: FormikActions<IFormFields>) {
const isEditing = !!editingMarker;
const variables: GQL.SceneMarkerCreateVariables | GQL.SceneMarkerUpdateVariables = {
title: values.title,
seconds: parseFloat(values.seconds),
scene_id: props.scene.id,
primary_tag_id: values.primaryTagId,
tag_ids: values.tagIds,
};
if (!isEditing) {
sceneMarkerCreate({ variables }).then((response) => {
setIsEditorOpen(false);
setEditingMarker(null);
}).catch((err) => ErrorUtils.handleApolloError(err));
} else {
const updateVariables = variables as GQL.SceneMarkerUpdateVariables;
updateVariables.id = editingMarker!.id;
sceneMarkerUpdate({ variables: updateVariables }).then((response) => {
setIsEditorOpen(false);
setEditingMarker(null);
}).catch((err) => ErrorUtils.handleApolloError(err));
}
}
function onDelete() {
if (!editingMarker) { return; }
sceneMarkerDestroy({variables: {id: editingMarker.id}}).then((response) => {
console.log(response);
}).catch((err) => console.error(err));
setIsEditorOpen(false);
setEditingMarker(null);
}
function renderTitleField(fieldProps: FieldProps<IFormFields>) {
return (
<MarkerTitleSuggest
initialMarkerString={!!editingMarker ? editingMarker.title : undefined}
placeholder="Title"
name={fieldProps.field.name}
onBlur={fieldProps.field.onBlur}
value={fieldProps.field.value}
onQueryChange={(query) => fieldProps.form.setFieldValue("title", query)}
/>
);
}
function renderSecondsField(fieldProps: FieldProps<IFormFields>) {
return (
<DurationInput
onValueChange={(s) => fieldProps.form.setFieldValue("seconds", s)}
onReset={() => fieldProps.form.setFieldValue("seconds", Math.round(jwplayer.getPosition()))}
numericValue={fieldProps.field.value}
/>
);
}
function renderPrimaryTagField(fieldProps: FieldProps<IFormFields>) {
return (
<FilterSelect
type="tags"
onSelectItem={(tag) => fieldProps.form.setFieldValue("primaryTagId", tag ? tag.id : undefined)}
initialId={!!editingMarker ? editingMarker.primary_tag.id : undefined}
/>
);
}
function renderTagsField(fieldProps: FieldProps<IFormFields>) {
return (
<FilterMultiSelect
type="tags"
onUpdate={(tags) => fieldProps.form.setFieldValue("tagIds", tags.map((tag) => tag.id))}
initialIds={!!editingMarker ? fieldProps.form.values.tagIds : undefined}
/>
);
}
function renderFormFields(formikProps: FormikProps<IFormFields>) {
let deleteButton: JSX.Element | undefined;
if (!!editingMarker) {
deleteButton = (
<Button
type="button"
intent="danger"
style={{float: "right", marginRight: "10px"}}
onClick={() => onDelete()}
>
Delete
</Button>
);
}
return (
<Form style={{marginTop: "10px"}}>
<div className="columns is-multiline is-gapless">
<FormGroup label="Scene Marker Title" labelFor="title" className="column is-full">
<Field name="title" render={renderTitleField} />
</FormGroup>
<FormGroup label="Time" labelFor="seconds" className="column is-half">
<Field name="seconds" render={renderSecondsField} />
</FormGroup>
<FormGroup label="Primary Tag" labelFor="primaryTagId" className="column is-half">
<Field name="primaryTagId" render={renderPrimaryTagField} />
</FormGroup>
<FormGroup label="Tags" labelFor="tagIds" className="column is-full">
<Field name="tagIds" render={renderTagsField} />
</FormGroup>
</div>
<div className="buttons-container">
<Button intent="primary" type="submit">Submit</Button>
<Button type="button" onClick={() => setIsEditorOpen(false)}>Cancel</Button>
{deleteButton}
</div>
</Form>
);
}
let initialValues: any;
if (!!editingMarker) {
initialValues = {
title: editingMarker.title,
seconds: editingMarker.seconds,
primaryTagId: editingMarker.primary_tag.id,
tagIds: editingMarker.tags.map((tag) => tag.id),
};
} else {
initialValues = {title: "", seconds: Math.round(jwplayer.getPosition()), primaryTagId: "", tagIds: []};
}
return (
<Collapse isOpen={isEditorOpen}>
{isEditorOpen ? <Formik
initialValues={initialValues}
onSubmit={onSubmit}
render={renderFormFields}
/> : undefined}
</Collapse>
);
}
function render() {
const newMarkerForm = (
<div style={{margin: "5px"}}>
<Button onClick={() => onOpenEditor()}>Create</Button>
{renderForm()}
</div>
);
if (props.scene.scene_markers.length === 0) {
return newMarkerForm;
}
const containerStyle: CSSProperties = {
overflowY: "hidden",
overflowX: "scroll",
whiteSpace: "nowrap",
display: "flex",
flexWrap: "nowrap",
marginBottom: "20px",
};
return (
<>
{newMarkerForm}
<div style={containerStyle}>
{renderTags()}
</div>
<WallPanel
sceneMarkers={props.scene.scene_markers}
clickHandler={(marker) => { window.scrollTo(0, 0); onClickMarker(marker as any); }}
/>
</>
);
}
return render();
};

View file

@ -1,21 +0,0 @@
import React, { FunctionComponent } from "react";
import * as GQL from "../../../core/generated-graphql";
import { MovieCard } from "../../Movies/MovieCard";
interface ISceneMoviePanelProps {
scene: GQL.SceneDataFragment;
}
export const SceneMoviePanel: FunctionComponent<ISceneMoviePanelProps> = (props: ISceneMoviePanelProps) => {
const cards = props.scene.movies.map((sceneMovie) => (
<MovieCard key={sceneMovie.movie.id} movie={sceneMovie.movie} sceneIndex={sceneMovie.scene_index} />
));
return (
<>
<div className="grid">
{cards}
</div>
</>
);
};

View file

@ -1,117 +0,0 @@
import * as React from "react";
import { HTMLSelect, Divider} from "@blueprintjs/core";
import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
type ValidTypes = GQL.SlimMovieDataFragment;
export interface IProps {
initialIds: string[] | undefined;
initialIdx: string[] | undefined;
onUpdate: (itemsNumber: string[]) => void;
}
let items: ValidTypes[];
let itemsFilter: ValidTypes[];
let storeIdx: string[];
export const SceneMovieTable: React.FunctionComponent<IProps> = (props: IProps) => {
const [itemsNumber, setItemsNumber] = React.useState<string[]>([]);
const [initialIdsprev, setinitialIdsprev] = React.useState(props.initialIds);
const { data } = StashService.useAllMoviesForFilter();
items = !!data && !!data.allMovies ? data.allMovies : [];
itemsFilter=[];
storeIdx=[];
if (!!props.initialIds && !!items && !!props.initialIdx)
{
for(var i=0; i< props.initialIds!.length; i++)
{
itemsFilter=itemsFilter.concat(items.filter((x) => x.id ===props.initialIds![i]));
}
}
/* eslint-disable react-hooks/rules-of-hooks */
React.useEffect(() => {
if (!!props.initialIdx)
{
setItemsNumber(props.initialIdx);
}
}, [props.initialIdx]);
/* eslint-enable */
React.useEffect(() => {
if (!!props.initialIds) {
setinitialIdsprev(props.initialIds);
UpdateIndex();
}
}, [props.initialIds]);
const updateFieldChanged = (index : any) => (e : any) => {
let newArr = [...itemsNumber];
newArr[index] = e.target.value;
setItemsNumber(newArr);
props.onUpdate(newArr);
}
const updateIdsChanged = (index : any, value: string) => {
storeIdx.push(value);
setItemsNumber(storeIdx);
props.onUpdate(storeIdx);
}
function UpdateIndex(){
if (!!props.initialIds && !!initialIdsprev ){
loop1:
for(var i=0; i< props.initialIds!.length; i++) {
for(var j=0; j< initialIdsprev!.length; j++) {
if (props.initialIds[i]===initialIdsprev[j])
{
updateIdsChanged(i, props.initialIdx![j]);
continue loop1;
}
}
updateIdsChanged(i, "0");
}
}
}
function renderTableData() {
return(
<tbody>
{ itemsFilter!.map((item, index : any) => (
<tr>
<td>{item.name} </td>
<td><Divider /> </td>
<td key={item.toString()}> Scene number: <HTMLSelect
options={["","1", "2", "3", "4", "5","6","7","8","9","10"]}
onChange={updateFieldChanged(index)}
value={itemsNumber[index]}
/>
</td>
</tr>
))}
</tbody>
)
}
return (
<div>
<table id='movies'>
{renderTableData()}
</table>
</div>
);
};

View file

@ -1,47 +0,0 @@
import {
Button,
} from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import * as GQL from "../../../core/generated-graphql";
import { StashService } from "../../../core/StashService";
import { SceneHelpers } from "../helpers";
import { ToastUtils } from "../../../utils/toasts";
interface IOperationsPanelProps {
scene: GQL.SceneDataFragment;
}
export const SceneOperationsPanel: FunctionComponent<IOperationsPanelProps> = (props: IOperationsPanelProps) => {
const jwplayer = SceneHelpers.getPlayer();
const generateScreenshot = StashService.useSceneGenerateScreenshot();
async function onGenerateScreenshot() {
let position = jwplayer.getPosition();
await generateScreenshot({
variables: {
id: props.scene.id,
at: position
}
});
ToastUtils.success("Generating screenshot");
}
async function onGenerateDefaultScreenshot() {
await generateScreenshot({
variables: {
id: props.scene.id,
}
});
ToastUtils.success("Generating screenshot");
}
return (
<>
<Button className="edit-button" text="Generate thumbnail from current" onClick={() => onGenerateScreenshot()}/>
<Button className="edit-button" text="Generate default thumbnail" onClick={() => onGenerateDefaultScreenshot()}/>
</>
);
};

View file

@ -1,21 +0,0 @@
import React, { FunctionComponent } from "react";
import * as GQL from "../../../core/generated-graphql";
import { PerformerCard } from "../../performers/PerformerCard";
interface IScenePerformerPanelProps {
scene: GQL.SceneDataFragment;
}
export const ScenePerformerPanel: FunctionComponent<IScenePerformerPanelProps> = (props: IScenePerformerPanelProps) => {
const cards = props.scene.performers.map((performer) => (
<PerformerCard key={performer.id} performer={performer} ageFromDate={props.scene.date} />
));
return (
<>
<div className="grid">
{cards}
</div>
</>
);
};

File diff suppressed because it is too large Load diff

View file

@ -1,110 +0,0 @@
import _ from "lodash";
import React, { FunctionComponent } from "react";
import { QueryHookResult } from "react-apollo-hooks";
import { FindScenesQuery, FindScenesVariables, SlimSceneDataFragment } from "../../core/generated-graphql";
import { ListHook } from "../../hooks/ListHook";
import { IBaseProps } from "../../models/base-props";
import { ListFilterModel } from "../../models/list-filter/filter";
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
import { WallPanel } from "../Wall/WallPanel";
import { SceneCard } from "./SceneCard";
import { SceneListTable } from "./SceneListTable";
import { SceneSelectedOptions } from "./SceneSelectedOptions";
import { StashService } from "../../core/StashService";
interface ISceneListProps {
base : IBaseProps
subComponent?: boolean
filterHook?: (filter: ListFilterModel) => ListFilterModel;
}
export const SceneList: FunctionComponent<ISceneListProps> = (props: ISceneListProps) => {
const otherOperations = [
{
text: "Play Random",
onClick: playRandom,
}
];
const listData = ListHook.useList({
filterMode: FilterMode.Scenes,
props: props.base,
subComponent: props.subComponent,
filterHook: props.filterHook,
zoomable: true,
otherOperations: otherOperations,
renderContent,
renderSelectedOptions
});
async function playRandom(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel, selectedIds: Set<string>) {
// query for a random scene
if (result.data && result.data.findScenes) {
let count = result.data.findScenes.count;
let index = Math.floor(Math.random() * count);
let filterCopy = _.cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await StashService.queryFindScenes(filterCopy);
if (singleResult && singleResult.data && singleResult.data.findScenes && singleResult.data.findScenes.scenes.length === 1) {
let id = singleResult!.data!.findScenes!.scenes[0].id;
// navigate to the scene player page
props.base.history.push("/scenes/" + id + "?autoplay=true");
}
}
}
function renderSelectedOptions(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, selectedIds: Set<string>) {
// find the selected items from the ids
if (!result.data || !result.data.findScenes) { return undefined; }
var scenes = result.data.findScenes.scenes;
var selectedScenes : SlimSceneDataFragment[] = [];
selectedIds.forEach((id) => {
var scene = scenes.find((scene) => {
return scene.id === id;
});
if (scene) {
selectedScenes.push(scene);
}
});
return (
<>
<SceneSelectedOptions selected={selectedScenes} onScenesUpdated={() => { return; }}/>
</>
);
}
function renderSceneCard(scene : SlimSceneDataFragment, selectedIds: Set<string>, zoomIndex: number) {
return (
<SceneCard
key={scene.id}
scene={scene}
zoomIndex={zoomIndex}
selected={selectedIds.has(scene.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) => listData.onSelectChange(scene.id, selected, shiftKey)}
/>
)
}
function renderContent(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel, selectedIds: Set<string>, zoomIndex: number) {
if (!result.data || !result.data.findScenes) { return; }
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="grid">
{result.data.findScenes.scenes.map((scene) => renderSceneCard(scene, selectedIds, zoomIndex))}
</div>
);
} else if (filter.displayMode === DisplayMode.List) {
return <SceneListTable scenes={result.data.findScenes.scenes}/>;
} else if (filter.displayMode === DisplayMode.Wall) {
return <WallPanel scenes={result.data.findScenes.scenes} />;
}
}
return listData.template;
};

View file

@ -1,9 +0,0 @@
import React, { FunctionComponent } from "react";
import { IBaseProps } from "../../models/base-props";
import { SceneList } from "./SceneList";
interface ISceneListPageProps extends IBaseProps {}
export const SceneListPage: FunctionComponent<ISceneListPageProps> = (props: ISceneListPageProps) => {
return <SceneList base={props}/>;
};

View file

@ -1,136 +0,0 @@
import {
HTMLTable,
H5,
H6,
} from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql";
import { TextUtils } from "../../utils/text";
import { NavigationUtils } from "../../utils/navigation";
interface ISceneListTableProps {
scenes: GQL.SlimSceneDataFragment[];
}
export const SceneListTable: FunctionComponent<ISceneListTableProps> = (props: ISceneListTableProps) => {
function renderSceneImage(scene : GQL.SlimSceneDataFragment) {
const style: React.CSSProperties = {
backgroundImage: `url('${scene.paths.screenshot}')`,
lineHeight: 5,
backgroundSize: "contain",
display: "inline-block",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
};
return (
<Link
className="scene-list-thumbnail"
to={`/scenes/${scene.id}`}
style={style}/>
)
}
function renderDuration(scene : GQL.SlimSceneDataFragment) {
if (scene.file.duration === undefined) { return; }
return TextUtils.secondsToTimestamp(scene.file.duration);
}
function renderTags(tags : GQL.SlimSceneDataTags[]) {
return tags.map((tag) => (
<Link to={NavigationUtils.makeTagScenesUrl(tag)}>
<H6>{tag.name}</H6>
</Link>
));
}
function renderPerformers(performers : GQL.SlimSceneDataPerformers[]) {
return performers.map((performer) => (
<Link to={NavigationUtils.makePerformerScenesUrl(performer)}>
<H6>{performer.name}</H6>
</Link>
));
}
function renderStudio(studio : GQL.SlimSceneDataStudio | undefined) {
if (!!studio) {
return (
<Link to={NavigationUtils.makeStudioScenesUrl(studio)}>
<H6>{studio.name}</H6>
</Link>
);
}
}
function renderMovies(movies : GQL.SlimSceneDataMovies[]) {
return movies.map((sceneMovie) => (
<Link to={NavigationUtils.makeMovieScenesUrl(sceneMovie.movie)}>
<H6>{sceneMovie.movie.name}</H6>
</Link>
));
}
function renderSceneRow(scene : GQL.SlimSceneDataFragment) {
return (
<>
<tr>
<td>
{renderSceneImage(scene)}
</td>
<td style={{textAlign: "left"}}>
<Link to={`/scenes/${scene.id}`}>
<H5 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
{!!scene.title ? scene.title : TextUtils.fileNameFromPath(scene.path)}
</H5>
</Link>
</td>
<td>
{scene.rating ? scene.rating : ''}
</td>
<td>
{renderDuration(scene)}
</td>
<td>
{renderTags(scene.tags)}
</td>
<td>
{renderPerformers(scene.performers)}
</td>
<td>
{renderStudio(scene.studio)}
</td>
<td>
{renderMovies(scene.movies)}
</td>
</tr>
</>
)
}
return (
<>
<div className="grid">
<HTMLTable className="bp3-html-table bp3-html-table-bordered bp3-html-table-condensed bp3-html-table-striped bp3-interactive">
<thead>
<tr>
<th></th>
<th>Title</th>
<th>Rating</th>
<th>Duration</th>
<th>Tags</th>
<th>Performers</th>
<th>Studio</th>
<th>Movies</th>
</tr>
</thead>
<tbody>
{props.scenes.map(renderSceneRow)}
</tbody>
</HTMLTable>
</div>
</>
);
};

View file

@ -1,59 +0,0 @@
import _ from "lodash";
import React, { FunctionComponent } from "react";
import { QueryHookResult } from "react-apollo-hooks";
import { FindSceneMarkersQuery, FindSceneMarkersVariables } from "../../core/generated-graphql";
import { ListHook } from "../../hooks/ListHook";
import { IBaseProps } from "../../models/base-props";
import { ListFilterModel } from "../../models/list-filter/filter";
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
import { WallPanel } from "../Wall/WallPanel";
import { StashService } from "../../core/StashService";
import { NavigationUtils } from "../../utils/navigation";
interface IProps extends IBaseProps {}
export const SceneMarkerList: FunctionComponent<IProps> = (props: IProps) => {
const otherOperations = [
{
text: "Play Random",
onClick: playRandom,
}
];
const listData = ListHook.useList({
filterMode: FilterMode.SceneMarkers,
otherOperations: otherOperations,
props,
renderContent,
});
async function playRandom(result: QueryHookResult<FindSceneMarkersQuery, FindSceneMarkersVariables>, filter: ListFilterModel, selectedIds: Set<string>) {
// query for a random scene
if (result.data && result.data.findSceneMarkers) {
let count = result.data.findSceneMarkers.count;
let index = Math.floor(Math.random() * count);
let filterCopy = _.cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await StashService.queryFindSceneMarkers(filterCopy);
if (singleResult && singleResult.data && singleResult.data.findSceneMarkers && singleResult.data.findSceneMarkers.scene_markers.length === 1) {
// navigate to the scene player page
let url = NavigationUtils.makeSceneMarkerUrl(singleResult!.data!.findSceneMarkers!.scene_markers[0])
props.history.push(url);
}
}
}
function renderContent(
result: QueryHookResult<FindSceneMarkersQuery, FindSceneMarkersVariables>,
filter: ListFilterModel,
) {
if (!result.data || !result.data.findSceneMarkers) { return; }
if (filter.displayMode === DisplayMode.Wall) {
return <WallPanel sceneMarkers={result.data.findSceneMarkers.scene_markers} />;
}
}
return listData.template;
};

View file

@ -1,210 +0,0 @@
import { Hotkey, Hotkeys, HotkeysTarget } from "@blueprintjs/core";
import React, { FunctionComponent } from "react";
import ReactJWPlayer from "react-jw-player";
import * as GQL from "../../../core/generated-graphql";
import { SceneHelpers } from "../helpers";
import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
import { StashService } from "../../../core/StashService";
interface IScenePlayerProps {
scene: GQL.SceneDataFragment;
timestamp: number;
autoplay?: boolean;
onReady?: any;
onSeeked?: any;
onTime?: any;
config?: GQL.ConfigInterfaceDataFragment;
}
interface IScenePlayerState {
scrubberPosition: number;
}
@HotkeysTarget
export class ScenePlayerImpl extends React.Component<IScenePlayerProps, IScenePlayerState> {
private player: any;
private lastTime = 0;
constructor(props: IScenePlayerProps) {
super(props);
this.onReady = this.onReady.bind(this);
this.onSeeked = this.onSeeked.bind(this);
this.onTime = this.onTime.bind(this);
this.onScrubberSeek = this.onScrubberSeek.bind(this);
this.onScrubberScrolled = this.onScrubberScrolled.bind(this);
this.state = {scrubberPosition: 0};
}
public componentDidUpdate(prevProps: IScenePlayerProps) {
if (prevProps.timestamp !== this.props.timestamp) {
this.player.seek(this.props.timestamp);
}
}
renderPlayer() {
const config = this.makeJWPlayerConfig(this.props.scene);
return (
<ReactJWPlayer
playerId={SceneHelpers.getJWPlayerId()}
playerScript="/jwplayer/jwplayer.js"
customProps={config}
onReady={this.onReady}
onSeeked={this.onSeeked}
onTime={this.onTime}
/>
);
}
public render() {
return (
<>
<div id="jwplayer-container">
{this.renderPlayer()}
<ScenePlayerScrubber
scene={this.props.scene}
position={this.state.scrubberPosition}
onSeek={this.onScrubberSeek}
onScrolled={this.onScrubberScrolled}
/>
</div>
</>
);
}
public renderHotkeys() {
const onIncrease = () => {
const currentPlaybackRate = !!this.player ? this.player.getPlaybackRate() : 1;
this.player.setPlaybackRate(currentPlaybackRate + 0.5);
};
const onDecrease = () => {
const currentPlaybackRate = !!this.player ? this.player.getPlaybackRate() : 1;
this.player.setPlaybackRate(currentPlaybackRate - 0.5);
};
const onReset = () => { this.player.setPlaybackRate(1); };
return (
<Hotkeys>
<Hotkey
global={true}
combo="num2"
label="Increase playback speed"
preventDefault={true}
onKeyDown={onIncrease}
/>
<Hotkey
global={true}
combo="num1"
label="Decrease playback speed"
preventDefault={true}
onKeyDown={onDecrease}
/>
<Hotkey
global={true}
combo="num0"
label="Reset playback speed"
preventDefault={true}
onKeyDown={onReset}
/>
</Hotkeys>
);
}
private shouldRepeat(scene: GQL.SceneDataFragment) {
let maxLoopDuration = this.props.config ? this.props.config.maximumLoopDuration : 0;
return !!scene.file.duration && !!maxLoopDuration && scene.file.duration < maxLoopDuration;
}
private makeJWPlayerConfig(scene: GQL.SceneDataFragment) {
if (!scene.paths.stream) { return {}; }
let repeat = this.shouldRepeat(scene);
let getDurationHook: (() => GQL.Maybe<number>) | undefined = undefined;
let seekHook: ((seekToPosition: number, _videoTag: any) => void) | undefined = undefined;
let getCurrentTimeHook: ((_videoTag: any) => number) | undefined = undefined;
if (!this.props.scene.is_streamable) {
getDurationHook = () => {
return this.props.scene.file.duration;
};
seekHook = (seekToPosition: number, _videoTag: any) => {
_videoTag.start = seekToPosition;
_videoTag.src = (this.props.scene.paths.stream + "?start=" + seekToPosition);
_videoTag.play();
};
getCurrentTimeHook = (_videoTag: any) => {
let start = _videoTag.start || 0;
return _videoTag.currentTime + start;
}
}
let ret = {
file: scene.paths.stream,
image: scene.paths.screenshot,
tracks: [
{
file: scene.paths.vtt,
kind: "thumbnails",
},
{
file: scene.paths.chapters_vtt,
kind: "chapters",
},
],
aspectratio: "16:9",
width: "100%",
floating: {
dismissible: true,
},
cast: {},
primary: "html5",
autostart: this.props.autoplay || (this.props.config ? this.props.config.autostartVideo : false),
repeat: repeat,
playbackRateControls: true,
playbackRates: [0.75, 1, 1.5, 2, 3, 4],
getDurationHook: getDurationHook,
seekHook: seekHook,
getCurrentTimeHook: getCurrentTimeHook
};
return ret;
}
private onReady() {
this.player = SceneHelpers.getPlayer();
if (this.props.timestamp > 0) {
this.player.seek(this.props.timestamp);
}
}
private onSeeked() {
const position = this.player.getPosition();
this.setState({scrubberPosition: position});
this.player.play();
}
private onTime(data: any) {
const position = this.player.getPosition();
const difference = Math.abs(position - this.lastTime);
if (difference > 1) {
this.lastTime = position;
this.setState({scrubberPosition: position});
}
}
private onScrubberSeek(seconds: number) {
this.player.seek(seconds);
}
private onScrubberScrolled() {
this.player.pause();
}
}
export const ScenePlayer: FunctionComponent<IScenePlayerProps> = (props: IScenePlayerProps) => {
const config = StashService.useConfiguration();
return <ScenePlayerImpl {...props} config={config.data && config.data.configuration ? config.data.configuration.interface : undefined}/>
}

View file

@ -1,128 +0,0 @@
.scrubber-wrapper {
position: relative;
overflow: hidden;
margin: 5px 0;
}
#scrubber-back {
float: left;
}
#scrubber-forward {
float: right;
}
.scrubber-button {
width: 1.5%;
height: 100%;
line-height: 120px;
padding: 0;
text-align: center;
border: 1px solid #555;
font-weight: 800;
font-size: 20px;
color: #FFF;
cursor: pointer;
}
.scrubber-content {
-webkit-user-select: none;
-webkit-overflow-scrolling: touch;
cursor: -webkit-grab;
height: 120px;
width: 96%;
margin: 0 0.5%;
display: inline-block;
position: relative;
overflow: hidden;
}
.scrubber-content.dragging {
cursor: -webkit-grabbing;
}
.scrubber-tags-background {
background-color: #555;
position: absolute;
left: 0;
right: 0;
height: 20px;
}
#scrubber-position-indicator {
background-color: #CCC;
width: 100%;
left: -100%;
height: 20px;
z-index: 0;
position: absolute;
}
#scrubber-current-position {
background-color: #FFF;
width: 2px;
height: 30px;
left: 50%;
z-index: 1;
position: absolute;
}
.scrubber-viewport {
position: static;
height: 100%;
overflow: hidden;
}
.scrubber-slider {
position: absolute;
width: 100%;
height: 100%;
left: 0;
transition: 333ms ease-out;
}
.scrubber-tags {
height: 20px;
position: relative;
margin-bottom: 10px;
}
.scrubber-tag {
position: absolute;
background-color: #000;
font-size: 10px;
white-space: nowrap;
padding: 0 10px;
cursor: pointer;
}
.scrubber-tag:hover {
z-index: 1;
background-color: #444;
}
.scrubber-tag:after {
content: "";
position: absolute;
bottom: -5px;
left: 50%;
margin-left: -5px;
border-top: solid 5px #000;
border-left: solid 5px transparent;
border-right: solid 5px transparent;
}
.scrubber-item {
position: absolute;
display: flex;
margin-right: 10px;
cursor: pointer;
color: white;
text-shadow: 1px 1px black;
text-align: center;
font-size: 10px;
}
.scrubber-item span {
display: inline-block;
align-self: flex-end;
width: 100%;
}

View file

@ -1,301 +0,0 @@
import axios from "axios";
import React, { CSSProperties, FunctionComponent, useEffect, useRef, useState } from "react";
import * as GQL from "../../../core/generated-graphql";
import { TextUtils } from "../../../utils/text";
import "./ScenePlayerScrubber.scss";
interface IScenePlayerScrubberProps {
scene: GQL.SceneDataFragment;
position: number;
onSeek: (seconds: number) => void;
onScrolled: () => void;
}
interface ISceneSpriteItem {
start: number;
end: number;
x: number;
y: number;
w: number;
h: number;
}
export const ScenePlayerScrubber: FunctionComponent<IScenePlayerScrubberProps> = (props: IScenePlayerScrubberProps) => {
const contentEl = useRef<HTMLDivElement>(null);
const positionIndicatorEl = useRef<HTMLDivElement>(null);
const scrubberSliderEl = useRef<HTMLDivElement>(null);
const mouseDown = useRef(false);
const lastMouseEvent = useRef<any>(null);
const startMouseEvent = useRef<any>(null);
const velocity = useRef(0);
const _position = useRef(0);
function getPostion() { return _position.current; }
function setPosition(newPostion: number, shouldEmit: boolean = true) {
if (!scrubberSliderEl.current || !positionIndicatorEl.current) { return; }
if (shouldEmit) { props.onScrolled(); }
const midpointOffset = scrubberSliderEl.current.clientWidth / 2;
const bounds = getBounds() * -1;
if (newPostion > midpointOffset) {
_position.current = midpointOffset;
} else if (newPostion < bounds - midpointOffset) {
_position.current = bounds - midpointOffset;
} else {
_position.current = newPostion;
}
scrubberSliderEl.current.style.transform = `translateX(${_position.current}px)`;
const indicatorPosition = (
(newPostion - midpointOffset) / (bounds - (midpointOffset * 2)) * scrubberSliderEl.current.clientWidth
);
positionIndicatorEl.current.style.transform = `translateX(${indicatorPosition}px)`;
}
const [spriteItems, setSpriteItems] = useState<ISceneSpriteItem[]>([]);
useEffect(() => {
if (!scrubberSliderEl.current) { return; }
scrubberSliderEl.current.style.transform = `translateX(${scrubberSliderEl.current.clientWidth / 2}px)`;
}, [scrubberSliderEl]);
useEffect(() => {
async function fetchSpriteInfo() {
if (!props.scene || !props.scene.paths.vtt) { return; }
const response = await axios.get<string>(props.scene.paths.vtt, {responseType: "text"});
if (response.status !== 200) {
console.log(response.statusText);
}
// TODO: This is gnarly
const lines = response.data.split("\n");
if (lines.shift() !== "WEBVTT") { return; }
if (lines.shift() !== "") { return; }
let item: ISceneSpriteItem = {start: 0, end: 0, x: 0, y: 0, w: 0, h: 0};
const newSpriteItems: ISceneSpriteItem[] = [];
while (lines.length) {
const line = lines.shift();
if (line === undefined) { continue; }
if (line.includes("#") && line.includes("=") && line.includes(",")) {
const size = line.split("#")[1].split("=")[1].split(",");
item.x = Number(size[0]);
item.y = Number(size[1]);
item.w = Number(size[2]);
item.h = Number(size[3]);
newSpriteItems.push(item);
item = {start: 0, end: 0, x: 0, y: 0, w: 0, h: 0};
} else if (line.includes(" --> ")) {
const times = line.split(" --> ");
const start = times[0].split(":");
item.start = (+start[0]) * 60 * 60 + (+start[1]) * 60 + (+start[2]);
const end = times[1].split(":");
item.end = (+end[0]) * 60 * 60 + (+end[1]) * 60 + (+end[2]);
}
}
setSpriteItems(newSpriteItems);
}
fetchSpriteInfo();
}, [props.scene]);
useEffect(() => {
if (!scrubberSliderEl.current) { return; }
const duration = Number(props.scene.file.duration);
const percentage = props.position / duration;
const position = (
(scrubberSliderEl.current.scrollWidth * percentage) - (scrubberSliderEl.current.clientWidth / 2)
) * -1;
setPosition(position, false);
}, [props.position]);
useEffect(() => {
let element = contentEl.current;
if (!element) { return; }
element.addEventListener("mousedown", onMouseDown, false);
element.addEventListener("mousemove", onMouseMove, false);
window.addEventListener("mouseup", onMouseUp, false);
return () => {
if (!element) { return; }
element.removeEventListener("mousedown", onMouseDown);
element.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
});
function onMouseUp(this: Window, event: MouseEvent) {
if (!startMouseEvent.current || !scrubberSliderEl.current) { return; }
mouseDown.current = false;
const delta = Math.abs(event.clientX - startMouseEvent.current.clientX);
if (delta < 1 && event.target instanceof HTMLDivElement) {
const target: HTMLDivElement = event.target;
let seekSeconds: number | undefined;
const spriteIdString = target.getAttribute("data-sprite-item-id");
if (spriteIdString != null) {
const spritePercentage = event.offsetX / target.clientWidth;
const offset = target.offsetLeft + (target.clientWidth * spritePercentage);
const percentage = offset / scrubberSliderEl.current.scrollWidth;
seekSeconds = percentage * (props.scene.file.duration || 0);
}
const markerIdString = target.getAttribute("data-marker-id");
if (markerIdString != null) {
const marker = props.scene.scene_markers[Number(markerIdString)];
seekSeconds = marker.seconds;
}
if (!!seekSeconds) { props.onSeek(seekSeconds); }
} else if (Math.abs(velocity.current) > 25) {
const newPosition = getPostion() + (velocity.current * 10);
setPosition(newPosition);
velocity.current = 0;
}
}
function onMouseDown(this: HTMLDivElement, event: MouseEvent) {
event.preventDefault();
mouseDown.current = true;
lastMouseEvent.current = event;
startMouseEvent.current = event;
velocity.current = 0;
}
function onMouseMove(this: HTMLDivElement, event: MouseEvent) {
if (!mouseDown.current) { return; }
// negative dragging right (past), positive left (future)
const delta = event.clientX - lastMouseEvent.current.clientX;
const movement = event.movementX;
velocity.current = movement;
const newPostion = getPostion() + delta;
setPosition(newPostion);
lastMouseEvent.current = event;
}
function getBounds(): number {
if (!scrubberSliderEl.current || !positionIndicatorEl.current) { return 0; }
return scrubberSliderEl.current.scrollWidth - scrubberSliderEl.current.clientWidth;
}
function goBack() {
if (!scrubberSliderEl.current) { return; }
const newPosition = getPostion() + scrubberSliderEl.current.clientWidth;
setPosition(newPosition);
}
function goForward() {
if (!scrubberSliderEl.current) { return; }
const newPosition = getPostion() - scrubberSliderEl.current.clientWidth;
setPosition(newPosition);
}
function renderTags() {
function getTagStyle(i: number): CSSProperties {
if (!scrubberSliderEl.current ||
spriteItems.length === 0 ||
getBounds() === 0) { return {}; }
const tags = window.document.getElementsByClassName("scrubber-tag");
if (tags.length === 0) { return {}; }
let tag: any;
for (let index = 0; index < tags.length; index++) {
tag = tags.item(index) as any;
const id = tag.getAttribute("data-marker-id");
if (id === i.toString()) {
break;
}
}
const marker = props.scene.scene_markers[i];
const duration = Number(props.scene.file.duration);
const percentage = marker.seconds / duration;
const left = (scrubberSliderEl.current.scrollWidth * percentage) - (tag.clientWidth / 2);
return {
left: `${left}px`,
height: 20,
};
}
return props.scene.scene_markers.map((marker, index) => {
const dataAttrs = {
"data-marker-id": index,
};
return (
<div
key={index}
className="scrubber-tag"
style={getTagStyle(index)}
{...dataAttrs}
>
{marker.title}
</div>
);
});
}
function renderSprites() {
function getStyleForSprite(index: number): CSSProperties {
if (!props.scene.paths.vtt) { return {}; }
const sprite = spriteItems[index];
const left = sprite.w * index;
const path = props.scene.paths.vtt.replace("_thumbs.vtt", "_sprite.jpg"); // TODO: Gnarly
return {
width: `${sprite.w}px`,
height: `${sprite.h}px`,
margin: "0px auto",
backgroundPosition: -sprite.x + "px " + -sprite.y + "px",
backgroundImage: `url(${path})`,
left: `${left}px`,
};
}
return spriteItems.map((spriteItem, index) => {
const dataAttrs = {
"data-sprite-item-id": index,
};
return (
<div
key={index}
className="scrubber-item"
style={getStyleForSprite(index)}
{...dataAttrs}
>
<span>{TextUtils.secondsToTimestamp(spriteItem.start)} - {TextUtils.secondsToTimestamp(spriteItem.end)}</span>
</div>
);
});
}
return (
<div className="scrubber-wrapper">
<button className="scrubber-button" id="scrubber-back" onClick={() => goBack()}>&lt;</button>
<div ref={contentEl} className="scrubber-content">
<div className="scrubber-tags-background" />
<div ref={positionIndicatorEl} id="scrubber-position-indicator" />
<div id="scrubber-current-position" />
<div className="scrubber-viewport">
<div ref={scrubberSliderEl} className="scrubber-slider">
<div className="scrubber-tags">
{renderTags()}
</div>
{renderSprites()}
</div>
</div>
</div>
<button className="scrubber-button" id="scrubber-forward" onClick={() => goForward()}>&gt;</button>
</div>
);
};

View file

@ -1,328 +0,0 @@
import _ from "lodash";
import {
Button,
ButtonGroup,
FormGroup,
HTMLSelect,
Spinner,
} from "@blueprintjs/core";
import React, { FunctionComponent, useEffect, useState } from "react";
import { FilterSelect } from "../select/FilterSelect";
import { StashService } from "../../core/StashService";
import * as GQL from "../../core/generated-graphql";
import { ErrorUtils } from "../../utils/errors";
import { ToastUtils } from "../../utils/toasts";
import { FilterMultiSet } from "../select/FilterMultiSet";
interface IListOperationProps {
selected: GQL.SlimSceneDataFragment[],
onScenesUpdated: () => void;
}
export const SceneSelectedOptions: FunctionComponent<IListOperationProps> = (props: IListOperationProps) => {
const [rating, setRating] = useState<string>("");
const [studioId, setStudioId] = useState<string | undefined>(undefined);
const [performerMode, setPerformerMode] = React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined);
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(GQL.BulkUpdateIdMode.Add);
const [tagIds, setTagIds] = useState<string[] | undefined>(undefined);
const updateScenes = StashService.useBulkSceneUpdate(getSceneInput());
// Network state
const [isLoading, setIsLoading] = useState(false);
function makeBulkUpdateIds(ids: string[], mode: GQL.BulkUpdateIdMode) : GQL.BulkUpdateIds {
return {
mode,
ids
};
}
function getSceneInput() : GQL.BulkSceneUpdateInput {
// need to determine what we are actually setting on each scene
var aggregateRating = getRating(props.selected);
var aggregateStudioId = getStudioId(props.selected);
var aggregatePerformerIds = getPerformerIds(props.selected);
var aggregateTagIds = getTagIds(props.selected);
var sceneInput : GQL.BulkSceneUpdateInput = {
ids: props.selected.map((scene) => {
return scene.id;
})
};
// if rating is undefined
if (rating === "") {
// and all scenes have the same rating, then we are unsetting the rating.
if(aggregateRating) {
// an undefined rating is ignored in the server, so set it to 0 instead
sceneInput.rating = 0;
}
// otherwise not setting the rating
} else {
// if rating is set, then we are setting the rating for all
sceneInput.rating = Number.parseInt(rating);
}
// if studioId is undefined
if (studioId === undefined) {
// and all scenes have the same studioId,
// then unset the studioId, otherwise ignoring studioId
if (aggregateStudioId) {
// an undefined studio_id is ignored in the server, so set it to empty string instead
sceneInput.studio_id = "";
}
} else {
// if studioId is set, then we are setting it
sceneInput.studio_id = studioId;
}
// if performerIds are empty
if (performerMode == GQL.BulkUpdateIdMode.Set && (!performerIds || performerIds.length === 0)) {
// and all scenes have the same ids,
if (aggregatePerformerIds.length > 0) {
// then unset the performerIds, otherwise ignore
sceneInput.performer_ids = makeBulkUpdateIds(performerIds || [], performerMode);
}
} else {
// if performerIds non-empty, then we are setting them
sceneInput.performer_ids = makeBulkUpdateIds(performerIds || [], performerMode);
}
// if tagIds non-empty, then we are setting them
if (tagMode == GQL.BulkUpdateIdMode.Set && (!tagIds || tagIds.length === 0)) {
// and all scenes have the same ids,
if (aggregateTagIds.length > 0) {
// then unset the tagIds, otherwise ignore
sceneInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
} else {
// if tagIds non-empty, then we are setting them
sceneInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
return sceneInput;
}
async function onSave() {
setIsLoading(true);
try {
await updateScenes();
ToastUtils.success("Updated scenes");
} catch (e) {
ErrorUtils.handle(e);
}
setIsLoading(false);
props.onScenesUpdated();
}
function getRating(state: GQL.SlimSceneDataFragment[]) {
var ret : number | undefined;
var first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => {
if (first) {
ret = scene.rating;
first = false;
} else {
if (ret !== scene.rating) {
ret = undefined;
}
}
});
return ret;
}
function getStudioId(state: GQL.SlimSceneDataFragment[]) {
var ret : string | undefined;
var first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => {
if (first) {
ret = scene.studio ? scene.studio.id : undefined;
first = false;
} else {
var studioId = scene.studio ? scene.studio.id : undefined;
if (ret !== studioId) {
ret = undefined;
}
}
});
return ret;
}
function toId(object : any) {
return object.id;
}
function getPerformerIds(state: GQL.SlimSceneDataFragment[]) {
var ret : string[] = [];
var first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => {
if (first) {
ret = !!scene.performers ? scene.performers.map(toId).sort() : [];
first = false;
} else {
const perfIds = !!scene.performers ? scene.performers.map(toId).sort() : [];
if (!_.isEqual(ret, perfIds)) {
ret = [];
}
}
});
return ret;
}
function getTagIds(state: GQL.SlimSceneDataFragment[]) {
var ret : string[] = [];
var first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => {
if (first) {
ret = !!scene.tags ? scene.tags.map(toId).sort() : [];
first = false;
} else {
const tIds = !!scene.tags ? scene.tags.map(toId).sort() : [];
if (!_.isEqual(ret, tIds)) {
ret = [];
}
}
});
return ret;
}
function updateScenesEditState(state: GQL.SlimSceneDataFragment[]) {
function toId(object : any) {
return object.id;
}
var rating : string = "";
var studioId : string | undefined;
var performerIds : string[] = [];
var tagIds : string[] = [];
var first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => {
var thisRating = scene.rating ? scene.rating.toString() : "";
var thisStudio = scene.studio ? scene.studio.id : undefined;
if (first) {
rating = thisRating;
studioId = thisStudio;
performerIds = !!scene.performers ? scene.performers.map(toId).sort() : [];
tagIds = !!scene.tags ? scene.tags.map(toId).sort() : [];
first = false;
} else {
if (rating !== thisRating) {
rating = "";
}
if (studioId !== thisStudio) {
studioId = undefined;
}
const perfIds = !!scene.performers ? scene.performers.map(toId).sort() : [];
const tIds = !!scene.tags ? scene.tags.map(toId).sort() : [];
if (!_.isEqual(performerIds, perfIds)) {
performerIds = [];
}
if (!_.isEqual(tagIds, tIds)) {
tagIds = [];
}
}
});
setRating(rating);
setStudioId(studioId);
if (performerMode == GQL.BulkUpdateIdMode.Set) {
setPerformerIds(performerIds);
}
if (tagMode == GQL.BulkUpdateIdMode.Set) {
setTagIds(tagIds);
}
}
useEffect(() => {
updateScenesEditState(props.selected);
}, [props.selected, performerMode, tagMode]);
function renderMultiSelect(type: "performers" | "tags", initialIds: string[] | undefined) {
let mode = GQL.BulkUpdateIdMode.Add;
switch (type) {
case "performers": mode = performerMode; break;
case "tags": mode = tagMode; break;
}
return (
<FilterMultiSet
type={type}
onUpdate={(items) => {
const ids = items.map((i) => i.id);
switch (type) {
case "performers": setPerformerIds(ids); break;
case "tags": setTagIds(ids); break;
}
}}
onSetMode={(mode) => {
switch (type) {
case "performers": setPerformerMode(mode); break;
case "tags": setTagMode(mode); break;
}
}}
initialIds={initialIds}
mode={mode}
/>
);
}
function render() {
return (
<>
{isLoading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
<div className="operation-container">
<FormGroup className="operation-item" label="Rating">
<HTMLSelect
options={["", 1, 2, 3, 4, 5]}
onChange={(event) => setRating(event.target.value)}
value={rating}
/>
</FormGroup>
<FormGroup className="operation-item" label="Studio">
<FilterSelect
type="studios"
onSelectItem={(item : any) => setStudioId(item ? item.id : undefined)}
initialId={studioId}
/>
</FormGroup>
<FormGroup className="operation-item" label="Performers">
{renderMultiSelect("performers", performerIds)}
</FormGroup>
<FormGroup className="operation-item" label="Tags">
{renderMultiSelect("tags", tagIds)}
</FormGroup>
<ButtonGroup className="operation-item">
<Button
intent="primary"
onClick={() => onSave()}>
Apply
</Button>
</ButtonGroup>
</div>
</>
);
}
return render();
};

View file

@ -1,43 +0,0 @@
import {
Divider,
} from "@blueprintjs/core";
import React, { } from "react";
import { Link } from "react-router-dom";
import videojs from "video.js";
import * as GQL from "../../core/generated-graphql";
export class SceneHelpers {
private static videoJSPlayer: videojs.Player | null;
public static maybeRenderStudio(
scene: GQL.SceneDataFragment | GQL.SlimSceneDataFragment,
height: number,
showDivider: boolean,
) {
if (!scene.studio) { return; }
const style: React.CSSProperties = {
backgroundImage: `url('${scene.studio.image_path}')`,
width: "100%",
height: `${height}px`,
lineHeight: 5,
backgroundSize: "contain",
display: "inline-block",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
};
return (
<>
{showDivider ? <Divider /> : undefined}
<Link
to={`/studios/${scene.studio.id}`}
style={style}
/>
</>
);
}
public static getJWPlayerId(): string { return "main-jwplayer"; }
public static getPlayer(): any {
return (window as any).jwplayer("main-jwplayer");
}
}

View file

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

View file

@ -1,199 +0,0 @@
import * as React from "react";
import { MenuItem } from "@blueprintjs/core";
import { IMultiSelectProps, ItemPredicate, ItemRenderer, MultiSelect } from "@blueprintjs/select";
import * as GQL from "../../core/generated-graphql";
import { StashService } from "../../core/StashService";
import { HTMLInputProps } from "../../models";
import { ErrorUtils } from "../../utils/errors";
import { ToastUtils } from "../../utils/toasts";
const InternalPerformerMultiSelect = MultiSelect.ofType<GQL.AllPerformersForFilterAllPerformers>();
const InternalTagMultiSelect = MultiSelect.ofType<GQL.AllTagsForFilterAllTags>();
const InternalStudioMultiSelect = MultiSelect.ofType<GQL.AllStudiosForFilterAllStudios>();
const InternalMovieMultiSelect = MultiSelect.ofType<GQL.AllMoviesForFilterAllMovies>();
type ValidTypes =
GQL.AllPerformersForFilterAllPerformers |
GQL.AllTagsForFilterAllTags |
GQL.AllMoviesForFilterAllMovies |
GQL.AllStudiosForFilterAllStudios;
interface IProps extends HTMLInputProps, Partial<IMultiSelectProps<ValidTypes>> {
type: "performers" | "studios" | "movies" | "tags";
initialIds?: string[];
onUpdate: (items: ValidTypes[]) => void;
}
export const FilterMultiSelect: React.FunctionComponent<IProps> = (props: IProps) => {
let MultiSelectImpl = getMultiSelectImpl();
let InternalMultiSelect = MultiSelectImpl.getInternalMultiSelect();
const data = MultiSelectImpl.getData();
const [selectedItems, setSelectedItems] = React.useState<ValidTypes[]>([]);
const [items, setItems] = React.useState<ValidTypes[]>([]);
const [newTagName, setNewTagName] = React.useState<string>("");
const createTag = StashService.useTagCreate(getTagInput() as GQL.TagCreateInput);
React.useEffect(() => {
if (!!data) {
MultiSelectImpl.translateData();
}
}, [data]);
function getTagInput() {
const tagInput: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { name: newTagName };
return tagInput;
}
async function onCreateNewObject(item: ValidTypes) {
var created : any;
if (props.type === "tags") {
try {
created = await createTag();
items.push(created.data.tagCreate);
setItems(items.slice());
addSelectedItem(created.data.tagCreate);
ToastUtils.success("Created tag");
} catch (e) {
ErrorUtils.handle(e);
}
}
}
function createNewTag(query : string) {
setNewTagName(query);
return {
name : query
};
}
function createNewRenderer(query: string, active: boolean, handleClick: React.MouseEventHandler<HTMLElement>) {
// if tag already exists with that name, then don't return anything
if (items.find((item) => {
return item.name === query;
})) {
return undefined;
}
return (
<MenuItem
icon="add"
text={`Create "${query}"`}
active={active}
onClick={handleClick}
/>
);
}
React.useEffect(() => {
if (!!props.initialIds && !!items) {
const initialItems = items.filter((item) => props.initialIds!.includes(item.id));
setSelectedItems(initialItems);
}
}, [props.initialIds, items]);
function getMultiSelectImpl() {
let getInternalMultiSelect: () => new (props: IMultiSelectProps<any>) => MultiSelect<any>;
let getData: () => GQL.AllPerformersForFilterQuery | GQL.AllStudiosForFilterQuery | GQL.AllMoviesForFilterQuery | GQL.AllTagsForFilterQuery | undefined;
let translateData: () => void;
let createNewObject: ((query : string) => void) | undefined = undefined;
switch (props.type) {
case "performers": {
getInternalMultiSelect = () => { return InternalPerformerMultiSelect; };
getData = () => { const { data } = StashService.useAllPerformersForFilter(); return data; }
translateData = () => { let perfData = data as GQL.AllPerformersForFilterQuery; setItems(!!perfData && !!perfData.allPerformers ? perfData.allPerformers : []); };
break;
}
case "studios": {
getInternalMultiSelect = () => { return InternalStudioMultiSelect; };
getData = () => { const { data } = StashService.useAllStudiosForFilter(); return data; }
translateData = () => { let studioData = data as GQL.AllStudiosForFilterQuery; setItems(!!studioData && !!studioData.allStudios ? studioData.allStudios : []); };
break;
}
case "movies": {
getInternalMultiSelect = () => { return InternalMovieMultiSelect; };
getData = () => { const { data } = StashService.useAllMoviesForFilter(); return data; }
translateData = () => { let moviData = data as GQL.AllMoviesForFilterQuery; setItems(!!moviData && !!moviData.allMovies ? moviData.allMovies : []); };
break;
}
case "tags": {
getInternalMultiSelect = () => { return InternalTagMultiSelect; };
getData = () => { const { data } = StashService.useAllTagsForFilter(); return data; }
translateData = () => { let tagData = data as GQL.AllTagsForFilterQuery; setItems(!!tagData && !!tagData.allTags ? tagData.allTags : []); };
createNewObject = createNewTag;
break;
}
default: {
throw new Error("Unhandled case in FilterMultiSelect");
}
}
return {
getInternalMultiSelect: getInternalMultiSelect,
getData: getData,
translateData: translateData,
createNewObject: createNewObject
};
}
const renderItem: ItemRenderer<ValidTypes> = (item, itemProps) => {
if (!itemProps.modifiers.matchesPredicate) { return null; }
return (
<MenuItem
active={itemProps.modifiers.active}
disabled={itemProps.modifiers.disabled}
key={item.id}
onClick={itemProps.handleClick}
text={item.name}
/>
);
};
const filter: ItemPredicate<ValidTypes> = (query, item) => {
if (selectedItems.includes(item)) { return false; }
return item.name!.toLowerCase().indexOf(query.toLowerCase()) >= 0;
};
function addSelectedItem(item: ValidTypes) {
selectedItems.push(item);
setSelectedItems(selectedItems);
props.onUpdate(selectedItems);
}
function onItemSelect(item: ValidTypes) {
if (item.id === undefined) {
// create the new item, if applicable
onCreateNewObject(item);
} else {
addSelectedItem(item);
}
}
function onItemRemove(value: string, index: number) {
const newSelectedItems = selectedItems.filter((_, i) => i !== index);
setSelectedItems(newSelectedItems);
props.onUpdate(newSelectedItems);
}
return (
<InternalMultiSelect
items={items}
selectedItems={selectedItems}
itemRenderer={renderItem}
itemPredicate={filter}
tagRenderer={(tag) => tag.name}
tagInputProps={{ onRemove: onItemRemove }}
onItemSelect={onItemSelect}
resetOnSelect={true}
popoverProps={{position: "bottom"}}
createNewItemFromQuery={MultiSelectImpl.createNewObject}
createNewItemRenderer={createNewRenderer}
{...props}
/>
);
};

View file

@ -1,74 +0,0 @@
import * as React from "react";
import { ControlGroup, Button } from "@blueprintjs/core";
import * as GQL from "../../core/generated-graphql";
import { FilterMultiSelect } from "./FilterMultiSelect";
type ValidTypes =
GQL.AllPerformersForFilterAllPerformers |
GQL.AllTagsForFilterAllTags |
GQL.AllMoviesForFilterAllMovies |
GQL.AllStudiosForFilterAllStudios;
interface IFilterMultiSetProps {
type: "performers" | "studios" | "movies" | "tags";
initialIds?: string[];
mode: GQL.BulkUpdateIdMode;
onUpdate: (items: ValidTypes[]) => void;
onSetMode: (mode: GQL.BulkUpdateIdMode) => void;
}
export const FilterMultiSet: React.FunctionComponent<IFilterMultiSetProps> = (props: IFilterMultiSetProps) => {
function onUpdate(items: ValidTypes[]) {
props.onUpdate(items);
}
function getModeIcon() {
switch(props.mode) {
case GQL.BulkUpdateIdMode.Set:
return "edit";
case GQL.BulkUpdateIdMode.Add:
return "plus";
case GQL.BulkUpdateIdMode.Remove:
return "cross";
}
}
function getModeText() {
switch(props.mode) {
case GQL.BulkUpdateIdMode.Set:
return "Set";
case GQL.BulkUpdateIdMode.Add:
return "Add";
case GQL.BulkUpdateIdMode.Remove:
return "Remove";
}
}
function nextMode() {
switch(props.mode) {
case GQL.BulkUpdateIdMode.Set:
return GQL.BulkUpdateIdMode.Add;
case GQL.BulkUpdateIdMode.Add:
return GQL.BulkUpdateIdMode.Remove;
case GQL.BulkUpdateIdMode.Remove:
return GQL.BulkUpdateIdMode.Set;
}
}
return (
<ControlGroup>
<Button
icon={getModeIcon()}
minimal={true}
onClick={() => props.onSetMode(nextMode())}
title={getModeText()}
/>
<FilterMultiSelect
type={props.type}
initialIds={props.initialIds}
onUpdate={onUpdate}
/>
</ControlGroup>
);
};

View file

@ -1,127 +0,0 @@
import * as React from "react";
import { Button, MenuItem } from "@blueprintjs/core";
import { ISelectProps, ItemPredicate, ItemRenderer, Select } from "@blueprintjs/select";
import * as GQL from "../../core/generated-graphql";
import { StashService } from "../../core/StashService";
import { HTMLInputProps } from "../../models";
const InternalPerformerSelect = Select.ofType<GQL.AllPerformersForFilterAllPerformers>();
const InternalTagSelect = Select.ofType<GQL.AllTagsForFilterAllTags>();
const InternalStudioSelect = Select.ofType<GQL.AllStudiosForFilterAllStudios>();
const InternalMovieSelect = Select.ofType<GQL.AllMoviesForFilterAllMovies>();
type ValidTypes =
GQL.AllPerformersForFilterAllPerformers |
GQL.AllTagsForFilterAllTags |
GQL.AllStudiosForFilterAllStudios |
GQL.AllMoviesForFilterAllMovies;
interface IProps extends HTMLInputProps {
type: "performers" | "studios" | "movies" | "tags";
initialId?: string;
noSelectionString?: string;
onSelectItem: (item: ValidTypes | undefined) => void;
}
function addNoneOption(items: ValidTypes[]) {
// Add a none option to clear the gallery
if (!items.find((item) => item.id === "0")) { items.unshift({id: "0", name: "None"}); }
}
export const FilterSelect: React.FunctionComponent<IProps> = (props: IProps) => {
let items: ValidTypes[];
let InternalSelect: new (props: ISelectProps<any>) => Select<any>;
switch (props.type) {
case "performers": {
const { data } = StashService.useAllPerformersForFilter();
items = !!data && !!data.allPerformers ? data.allPerformers : [];
addNoneOption(items);
InternalSelect = InternalPerformerSelect;
break;
}
case "studios": {
const { data } = StashService.useAllStudiosForFilter();
items = !!data && !!data.allStudios ? data.allStudios : [];
addNoneOption(items);
InternalSelect = InternalStudioSelect;
break;
}
case "movies": {
const { data } = StashService.useAllMoviesForFilter();
items = !!data && !!data.allMovies ? data.allMovies : [];
addNoneOption(items);
InternalSelect = InternalMovieSelect;
break;
}
case "tags": {
const { data } = StashService.useAllTagsForFilter();
items = !!data && !!data.allTags ? data.allTags : [];
InternalSelect = InternalTagSelect;
break;
}
default: {
console.error("Unhandled case in FilterSelect");
return <>Unhandled case in FilterSelect</>;
}
}
/* eslint-disable react-hooks/rules-of-hooks */
const [selectedItem, setSelectedItem] = React.useState<ValidTypes | undefined>(undefined);
React.useEffect(() => {
if (!!items) {
const initialItem = items.find((item) => props.initialId === item.id);
if (!!initialItem) {
setSelectedItem(initialItem);
} else {
setSelectedItem(undefined);
}
}
}, [props.initialId, items]);
/* eslint-enable */
const renderItem: ItemRenderer<ValidTypes> = (item, itemProps) => {
if (!itemProps.modifiers.matchesPredicate) { return null; }
return (
<MenuItem
active={itemProps.modifiers.active}
disabled={itemProps.modifiers.disabled}
key={item.id}
onClick={itemProps.handleClick}
text={item.name}
shouldDismissPopover={false}
/>
);
};
const filter: ItemPredicate<ValidTypes> = (query, item) => {
return item.name!.toLowerCase().indexOf(query.toLowerCase()) >= 0;
};
function onItemSelect(item: ValidTypes | undefined) {
if (item && item.id === "0") {
item = undefined;
}
props.onSelectItem(item);
setSelectedItem(item);
}
const noSelection = props.noSelectionString !== undefined ? props.noSelectionString : "(No selection)"
const buttonText = selectedItem ? selectedItem.name : noSelection;
return (
<InternalSelect
items={items}
itemRenderer={renderItem}
itemPredicate={filter}
noResults={<MenuItem disabled={true} text="No results." />}
onItemSelect={onItemSelect}
popoverProps={{position: "bottom"}}
{...props}
>
<Button fill={true} text={buttonText} />
</InternalSelect>
);
};

View file

@ -1,61 +0,0 @@
import * as React from "react";
import { MenuItem } from "@blueprintjs/core";
import { ItemPredicate, ItemRenderer, Suggest } from "@blueprintjs/select";
import * as GQL from "../../core/generated-graphql";
import { StashService } from "../../core/StashService";
import { HTMLInputProps } from "../../models";
const InternalSuggest = Suggest.ofType<GQL.MarkerStringsMarkerStrings>();
interface IProps extends HTMLInputProps {
initialMarkerString?: string;
onQueryChange: (query: string) => void;
}
export const MarkerTitleSuggest: React.FunctionComponent<IProps> = (props: IProps) => {
const { data } = StashService.useMarkerStrings();
const markerStrings = !!data && !!data.markerStrings ? data.markerStrings : [];
const [selectedItem, setSelectedItem] = React.useState<GQL.MarkerStringsMarkerStrings | null>(null);
if (!!props.initialMarkerString && !selectedItem) {
const initialItem = markerStrings.find((item) => {
return props.initialMarkerString!.toLowerCase() === item!.title.toLowerCase();
});
if (!!initialItem) { setSelectedItem(initialItem); }
}
const renderInputValue = (markerString: GQL.MarkerStringsMarkerStrings) => markerString.title;
const renderItem: ItemRenderer<GQL.MarkerStringsMarkerStrings> = (markerString, itemProps) => {
if (!itemProps.modifiers.matchesPredicate) { return null; }
return (
<MenuItem
active={itemProps.modifiers.active}
disabled={itemProps.modifiers.disabled}
label={markerString.count.toString()}
key={markerString.id}
onClick={itemProps.handleClick}
text={markerString.title}
/>
);
};
const filter: ItemPredicate<GQL.MarkerStringsMarkerStrings> = (query, item) => {
return item.title.toLowerCase().indexOf(query.toLowerCase()) >= 0;
};
return (
<InternalSuggest
inputValueRenderer={renderInputValue}
items={markerStrings as any}
itemRenderer={renderItem}
itemPredicate={filter}
onItemSelect={(item) => { props.onQueryChange(item.title); setSelectedItem(item); }}
onQueryChange={(query) => { props.onQueryChange(query); setSelectedItem(null); }}
activeItem={null}
selectedItem={selectedItem}
popoverProps={{position: "bottom"}}
/>
);
};

View file

@ -1,74 +0,0 @@
import * as React from "react";
import { MenuItem } from "@blueprintjs/core";
import { ItemRenderer, Suggest } from "@blueprintjs/select";
import * as GQL from "../../core/generated-graphql";
import { StashService } from "../../core/StashService";
import { HTMLInputProps } from "../../models";
const InternalSuggest = Suggest.ofType<GQL.ScrapePerformerListScrapePerformerList>();
interface IProps extends HTMLInputProps {
scraperId: string;
onSelectPerformer: (query: GQL.ScrapePerformerListScrapePerformerList) => void;
}
export const ScrapePerformerSuggest: React.FunctionComponent<IProps> = (props: IProps) => {
const [query, setQuery] = React.useState<string>("");
const [selectedItem, setSelectedItem] = React.useState<GQL.ScrapePerformerListScrapePerformerList | undefined>();
const [debouncedQuery, setDebouncedQuery] = React.useState<string>("");
const { data, error, loading } = StashService.useScrapePerformerList(props.scraperId, debouncedQuery);
React.useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
}, 500);
return () => {
clearTimeout(handler);
};
}, [query])
const performerNames = !!data && !!data.scrapePerformerList ? data.scrapePerformerList : [];
const renderInputValue = (performer: GQL.ScrapePerformerListScrapePerformerList) => performer.name || "";
const renderItem: ItemRenderer<GQL.ScrapePerformerListScrapePerformerList> = (performer, itemProps) => {
if (!itemProps.modifiers.matchesPredicate) { return null; }
return (
<MenuItem
active={itemProps.modifiers.active}
disabled={itemProps.modifiers.disabled}
key={performer.name}
onClick={itemProps.handleClick}
text={performer.name}
/>
);
};
function renderLoadingError() {
if (error) {
return (<MenuItem disabled={true} text={error.toString()} />);
}
if (loading) {
return (<MenuItem disabled={true} text="Loading..." />);
}
if (debouncedQuery && data && !!data.scrapePerformerList && data.scrapePerformerList.length === 0) {
return (<MenuItem disabled={true} text="No results" />);
}
}
return (
<InternalSuggest
inputValueRenderer={renderInputValue}
items={performerNames}
itemRenderer={renderItem}
onItemSelect={(item) => { props.onSelectPerformer(item); setSelectedItem(item); }}
onQueryChange={(newQuery) => { setQuery(newQuery); }}
activeItem={null}
selectedItem={selectedItem}
noResults={renderLoadingError()}
popoverProps={{position: "bottom"}}
/>
);
};

View file

@ -1,75 +0,0 @@
import * as React from "react";
import { Button, MenuItem } from "@blueprintjs/core";
import { ItemPredicate, ItemRenderer, Select } from "@blueprintjs/select";
import * as GQL from "../../core/generated-graphql";
import { StashService } from "../../core/StashService";
import { HTMLInputProps } from "../../models";
const InternalSelect = Select.ofType<GQL.ValidGalleriesForSceneValidGalleriesForScene>();
interface IProps extends HTMLInputProps {
initialId?: string;
sceneId: string;
onSelectItem: (item: GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined) => void;
}
export const ValidGalleriesSelect: React.FunctionComponent<IProps> = (props: IProps) => {
const { data } = StashService.useValidGalleriesForScene(props.sceneId);
const items = !!data && !!data.validGalleriesForScene ? data.validGalleriesForScene : [];
// Add a none option to clear the gallery
if (!items.find((item) => item.id === "0")) { items.unshift({id: "0", path: "None"}); }
const [selectedItem, setSelectedItem] = React.useState<GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined>(undefined);
const [isInitialized, setIsInitialized] = React.useState<boolean>(false);
if (!!props.initialId && !selectedItem && !isInitialized) {
const initialItem = items.find((item) => props.initialId === item.id);
if (!!initialItem) {
setSelectedItem(initialItem);
setIsInitialized(true);
}
}
const renderItem: ItemRenderer<GQL.ValidGalleriesForSceneValidGalleriesForScene> = (item, itemProps) => {
if (!itemProps.modifiers.matchesPredicate) { return null; }
return (
<MenuItem
active={itemProps.modifiers.active}
disabled={itemProps.modifiers.disabled}
key={item.id}
onClick={itemProps.handleClick}
text={item.path}
shouldDismissPopover={false}
/>
);
};
const filter: ItemPredicate<GQL.ValidGalleriesForSceneValidGalleriesForScene> = (query, item) => {
return item.path!.toLowerCase().indexOf(query.toLowerCase()) >= 0;
};
function onItemSelect(item: GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined) {
if (item && item.id === "0") {
item = undefined;
}
props.onSelectItem(item);
setSelectedItem(item);
}
const buttonText = selectedItem ? selectedItem.path : "(No selection)";
return (
<InternalSelect
items={items}
itemRenderer={renderItem}
itemPredicate={filter}
noResults={<MenuItem disabled={true} text="No results." />}
onItemSelect={onItemSelect}
popoverProps={{position: "bottom"}}
{...props}
>
<Button fill={true} text={buttonText} />
</InternalSelect>
);
};

View file

@ -1,631 +0,0 @@
import ApolloClient from "apollo-client";
import { WebSocketLink } from 'apollo-link-ws';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { HttpLink, split } from "apollo-boost";
import _ from "lodash";
import { ListFilterModel } from "../models/list-filter/filter";
import * as GQL from "./generated-graphql";
import { getMainDefinition } from "apollo-utilities";
export class StashService {
public static client: ApolloClient<any>;
private static cache: InMemoryCache;
public static initialize() {
const platformUrl = new URL(window.location.origin);
const wsPlatformUrl = new URL(window.location.origin);
wsPlatformUrl.protocol = "ws:";
if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") {
platformUrl.port = "9999"; // TODO: Hack. Development expects port 9999
wsPlatformUrl.port = "9999";
if (process.env.REACT_APP_HTTPS === "true") {
platformUrl.protocol = "https:";
}
}
if (platformUrl.protocol === "https:") {
wsPlatformUrl.protocol = "wss:";
}
const url = platformUrl.toString().slice(0, -1) + "/graphql";
const wsUrl = wsPlatformUrl.toString().slice(0, -1) + "/graphql";
const httpLink = new HttpLink({
uri: url,
});
const wsLink = new WebSocketLink({
uri: wsUrl,
options: {
reconnect: true
},
});
const link = split(
({ query }) => {
const { kind, operation } = getMainDefinition(query);
return kind === 'OperationDefinition' && operation === 'subscription';
},
wsLink,
httpLink,
);
StashService.cache = new InMemoryCache();
StashService.client = new ApolloClient({
link: link,
cache: StashService.cache
});
(window as any).StashService = StashService;
return StashService.client;
}
private static invalidateCache() {
StashService.client.resetStore();
}
private static invalidateQueries(queries : string[]) {
if (!!StashService.cache) {
const cache = StashService.cache as any;
const keyMatchers = queries.map(query => {
return new RegExp("^" + query);
});
const rootQuery = cache.data.data.ROOT_QUERY;
Object.keys(rootQuery).forEach(key => {
if (keyMatchers.some(matcher => {
return !!key.match(matcher);
})) {
delete rootQuery[key];
}
});
}
}
public static useFindGalleries(filter: ListFilterModel) {
return GQL.useFindGalleries({
variables: {
filter: filter.makeFindFilter(),
},
});
}
public static useFindScenes(filter: ListFilterModel) {
let sceneFilter = {};
// if (!!filter && filter.criteriaFilterOpen) {
sceneFilter = filter.makeSceneFilter();
// }
// if (filter.customCriteria) {
// filter.customCriteria.forEach(criteria => {
// scene_filter[criteria.key] = criteria.value;
// });
// }
return GQL.useFindScenes({
variables: {
filter: filter.makeFindFilter(),
scene_filter: sceneFilter,
},
});
}
public static queryFindScenes(filter: ListFilterModel) {
let sceneFilter = {};
sceneFilter = filter.makeSceneFilter();
return StashService.client.query<GQL.FindScenesQuery>({
query: GQL.FindScenesDocument,
variables: {
filter: filter.makeFindFilter(),
scene_filter: sceneFilter,
}
});
}
public static useFindSceneMarkers(filter: ListFilterModel) {
let sceneMarkerFilter = {};
// if (!!filter && filter.criteriaFilterOpen) {
sceneMarkerFilter = filter.makeSceneMarkerFilter();
// }
// if (filter.customCriteria) {
// filter.customCriteria.forEach(criteria => {
// scene_filter[criteria.key] = criteria.value;
// });
// }
return GQL.useFindSceneMarkers({
variables: {
filter: filter.makeFindFilter(),
scene_marker_filter: sceneMarkerFilter,
},
});
}
public static queryFindSceneMarkers(filter: ListFilterModel) {
let sceneMarkerFilter = {};
sceneMarkerFilter = filter.makeSceneMarkerFilter();
return StashService.client.query<GQL.FindSceneMarkersQuery>({
query: GQL.FindSceneMarkersDocument,
variables: {
filter: filter.makeFindFilter(),
scene_marker_filter: sceneMarkerFilter,
}
});
}
public static useFindStudios(filter: ListFilterModel) {
return GQL.useFindStudios({
variables: {
filter: filter.makeFindFilter(),
},
});
}
public static useFindMovies(filter: ListFilterModel) {
return GQL.useFindMovies({
variables: {
filter: filter.makeFindFilter(),
},
});
}
public static useFindPerformers(filter: ListFilterModel) {
let performerFilter = {};
// if (!!filter && filter.criteriaFilterOpen) {
performerFilter = filter.makePerformerFilter();
// }
// if (filter.customCriteria) {
// filter.customCriteria.forEach(criteria => {
// scene_filter[criteria.key] = criteria.value;
// });
// }
return GQL.useFindPerformers({
variables: {
filter: filter.makeFindFilter(),
performer_filter: performerFilter,
},
});
}
public static queryFindPerformers(filter: ListFilterModel) {
let performerFilter = {};
performerFilter = filter.makePerformerFilter();
return StashService.client.query<GQL.FindPerformersQuery>({
query: GQL.FindPerformersDocument,
variables: {
filter: filter.makeFindFilter(),
performer_filter: performerFilter,
}
});
}
public static useFindGallery(id: string) { return GQL.useFindGallery({ variables: { id } }); }
public static useFindScene(id: string) { return GQL.useFindScene({ variables: { id } }); }
public static useFindPerformer(id: string) {
const skip = id === "new" ? true : false;
return GQL.useFindPerformer({ variables: { id }, skip });
}
public static useFindStudio(id: string) {
const skip = id === "new" ? true : false;
return GQL.useFindStudio({ variables: { id }, skip });
}
public static useFindMovie(id: string) {
const skip = id === "new" ? true : false;
return GQL.useFindMovie({ variables: { id }, skip });
}
// TODO - scene marker manipulation functions are handled differently
private static sceneMarkerMutationImpactedQueries = [
"findSceneMarkers",
"findScenes",
"markerStrings",
"sceneMarkerTags"
];
public static useSceneMarkerCreate() {
return GQL.useSceneMarkerCreate({ refetchQueries: ["FindScene"] });
}
public static useSceneMarkerUpdate() {
return GQL.useSceneMarkerUpdate({ refetchQueries: ["FindScene"] });
}
public static useSceneMarkerDestroy() {
return GQL.useSceneMarkerDestroy({ refetchQueries: ["FindScene"] });
}
public static useListPerformerScrapers() {
return GQL.useListPerformerScrapers();
}
public static useScrapePerformerList(scraperId: string, q: string) {
return GQL.useScrapePerformerList({ variables: { scraper_id: scraperId, query: q } });
}
public static useScrapePerformer(scraperId: string, scrapedPerformer: GQL.ScrapedPerformerInput) {
return GQL.useScrapePerformer({ variables: { scraper_id: scraperId, scraped_performer: scrapedPerformer } });
}
public static useListSceneScrapers() {
return GQL.useListSceneScrapers();
}
public static useScrapeFreeonesPerformers(q: string) { return GQL.useScrapeFreeonesPerformers({ variables: { q } }); }
public static useMarkerStrings() { return GQL.useMarkerStrings(); }
public static useAllTags() { return GQL.useAllTags(); }
public static useAllTagsForFilter() { return GQL.useAllTagsForFilter(); }
public static useAllPerformersForFilter() { return GQL.useAllPerformersForFilter(); }
public static useAllStudiosForFilter() { return GQL.useAllStudiosForFilter(); }
public static useAllMoviesForFilter() { return GQL.useAllMoviesForFilter(); }
public static useValidGalleriesForScene(sceneId: string) {
return GQL.useValidGalleriesForScene({ variables: { scene_id: sceneId } });
}
public static useStats() { return GQL.useStats(); }
public static useVersion() { return GQL.useVersion(); }
public static useLatestVersion() { return GQL.useLatestVersion({ notifyOnNetworkStatusChange: true, errorPolicy: 'ignore' }); }
public static useConfiguration() { return GQL.useConfiguration(); }
public static useDirectories(path?: string) { return GQL.useDirectories({ variables: { path } }); }
private static performerMutationImpactedQueries = [
"findPerformers",
"findScenes",
"findSceneMarkers",
"allPerformers"
];
public static usePerformerCreate() {
return GQL.usePerformerCreate({
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
});
}
public static usePerformerUpdate() {
return GQL.usePerformerUpdate({
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
});
}
public static usePerformerDestroy() {
return GQL.usePerformerDestroy({
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries)
});
}
private static sceneMutationImpactedQueries = [
"findPerformers",
"findScenes",
"findSceneMarkers",
"findStudios",
"findMovies",
"allTags"
// TODO - add "findTags" when it is implemented
];
public static useSceneUpdate(input: GQL.SceneUpdateInput) {
return GQL.useSceneUpdate({
variables: input,
update: () => StashService.invalidateQueries(StashService.sceneMutationImpactedQueries),
refetchQueries: ["AllTagsForFilter"]
});
}
// remove findScenes for bulk scene update so that we don't lose
// existing results
private static sceneBulkMutationImpactedQueries = [
"findPerformers",
"findSceneMarkers",
"findStudios",
"findMovies",
"allTags"
];
public static useBulkSceneUpdate(input: GQL.BulkSceneUpdateInput) {
return GQL.useBulkSceneUpdate({
variables: input,
update: () => StashService.invalidateQueries(StashService.sceneBulkMutationImpactedQueries)
});
}
public static useScenesUpdate(input: GQL.SceneUpdateInput[]) {
return GQL.useScenesUpdate({ variables: { input: input } });
}
public static useSceneIncrementO(id: string) {
return GQL.useSceneIncrementO({
variables: {id: id}
});
}
public static useSceneDecrementO(id: string) {
return GQL.useSceneDecrementO({
variables: {id: id}
});
}
public static useSceneResetO(id: string) {
return GQL.useSceneResetO({
variables: {id: id}
});
}
public static useSceneDestroy(input: GQL.SceneDestroyInput) {
return GQL.useSceneDestroy({
variables: input,
update: () => StashService.invalidateQueries(StashService.sceneMutationImpactedQueries)
});
}
public static useSceneGenerateScreenshot() {
return GQL.useSceneGenerateScreenshot({
update: () => StashService.invalidateQueries(["findScenes"]),
});
}
private static studioMutationImpactedQueries = [
"findStudios",
"findScenes",
"allStudios"
];
public static useStudioCreate(input: GQL.StudioCreateInput) {
return GQL.useStudioCreate({
variables: input,
update: () => StashService.invalidateQueries(StashService.studioMutationImpactedQueries)
});
}
public static useStudioUpdate(input: GQL.StudioUpdateInput) {
return GQL.useStudioUpdate({
variables: input,
update: () => StashService.invalidateQueries(StashService.studioMutationImpactedQueries)
});
}
public static useStudioDestroy(input: GQL.StudioDestroyInput) {
return GQL.useStudioDestroy({
variables: input,
update: () => StashService.invalidateQueries(StashService.studioMutationImpactedQueries)
});
}
private static movieMutationImpactedQueries = [
"findMovies",
"findScenes",
"allMovies"
];
public static useMovieCreate(input: GQL.MovieCreateInput) {
return GQL.useMovieCreate({
variables: input,
update: () => StashService.invalidateQueries(StashService.movieMutationImpactedQueries)
});
}
public static useMovieUpdate(input: GQL.MovieUpdateInput) {
return GQL.useMovieUpdate({
variables: input,
update: () => StashService.invalidateQueries(StashService.movieMutationImpactedQueries)
});
}
public static useMovieDestroy(input: GQL.MovieDestroyInput) {
return GQL.useMovieDestroy({
variables: input,
update: () => StashService.invalidateQueries(StashService.movieMutationImpactedQueries)
});
}
private static tagMutationImpactedQueries = [
"findScenes",
"findSceneMarkers",
"sceneMarkerTags",
"allTags"
];
public static useTagCreate(input: GQL.TagCreateInput) {
return GQL.useTagCreate({
variables: input,
refetchQueries: ["AllTags"],
update: () => StashService.invalidateQueries(StashService.tagMutationImpactedQueries)
});
}
public static useTagUpdate(input: GQL.TagUpdateInput) {
return GQL.useTagUpdate({
variables: input,
refetchQueries: ["AllTags"],
update: () => StashService.invalidateQueries(StashService.tagMutationImpactedQueries)
});
}
public static useTagDestroy(input: GQL.TagDestroyInput) {
return GQL.useTagDestroy({
variables: input,
refetchQueries: ["AllTags"],
update: () => StashService.invalidateQueries(StashService.tagMutationImpactedQueries)
});
}
public static useConfigureGeneral(input: GQL.ConfigGeneralInput) {
return GQL.useConfigureGeneral({ variables: { input }, refetchQueries: ["Configuration"] });
}
public static useConfigureInterface(input: GQL.ConfigInterfaceInput) {
return GQL.useConfigureInterface({ variables: { input }, refetchQueries: ["Configuration"] });
}
public static useMetadataUpdate() {
return GQL.useMetadataUpdate();
}
public static useLoggingSubscribe() {
return GQL.useLoggingSubscribe();
}
public static useLogs() {
return GQL.useLogs({
fetchPolicy: 'no-cache'
});
}
public static useJobStatus() {
return GQL.useJobStatus({
fetchPolicy: 'no-cache'
});
}
public static mutateStopJob() {
return StashService.client.mutate<GQL.StopJobMutation>({
mutation: GQL.StopJobDocument,
});
}
public static queryScrapeFreeones(performerName: string) {
return StashService.client.query<GQL.ScrapeFreeonesQuery>({
query: GQL.ScrapeFreeonesDocument,
variables: {
performer_name: performerName,
},
});
}
public static queryScrapePerformer(scraperId: string, scrapedPerformer: GQL.ScrapedPerformerInput) {
return StashService.client.query<GQL.ScrapePerformerQuery>({
query: GQL.ScrapePerformerDocument,
variables: {
scraper_id: scraperId,
scraped_performer: scrapedPerformer,
},
});
}
public static queryScrapePerformerURL(url: string) {
return StashService.client.query<GQL.ScrapePerformerUrlQuery>({
query: GQL.ScrapePerformerUrlDocument,
variables: {
url: url,
},
});
}
public static queryScrapeSceneURL(url: string) {
return StashService.client.query<GQL.ScrapeSceneUrlQuery>({
query: GQL.ScrapeSceneUrlDocument,
variables: {
url: url,
},
});
}
public static queryScrapeScene(scraperId: string, scene: GQL.SceneUpdateInput) {
return StashService.client.query<GQL.ScrapeSceneQuery>({
query: GQL.ScrapeSceneDocument,
variables: {
scraper_id: scraperId,
scene: scene,
},
});
}
public static mutateMetadataScan(input: GQL.ScanMetadataInput) {
return StashService.client.mutate<GQL.MetadataScanMutation>({
mutation: GQL.MetadataScanDocument,
variables: { input },
});
}
public static mutateMetadataAutoTag(input: GQL.AutoTagMetadataInput) {
return StashService.client.mutate<GQL.MetadataAutoTagMutation>({
mutation: GQL.MetadataAutoTagDocument,
variables: { input },
});
}
public static mutateMetadataGenerate(input: GQL.GenerateMetadataInput) {
return StashService.client.mutate<GQL.MetadataGenerateMutation>({
mutation: GQL.MetadataGenerateDocument,
variables: { input },
});
}
public static mutateMetadataClean() {
return StashService.client.mutate<GQL.MetadataCleanMutation>({
mutation: GQL.MetadataCleanDocument,
});
}
public static mutateMetadataExport() {
return StashService.client.mutate<GQL.MetadataExportMutation>({
mutation: GQL.MetadataExportDocument,
});
}
public static mutateMetadataImport() {
return StashService.client.mutate<GQL.MetadataImportMutation>({
mutation: GQL.MetadataImportDocument,
});
}
public static querySceneByPathRegex(filter: GQL.FindFilterType) {
return StashService.client.query<GQL.FindScenesByPathRegexQuery>({
query: GQL.FindScenesByPathRegexDocument,
variables: { filter: filter },
});
}
public static queryParseSceneFilenames(filter: GQL.FindFilterType, config: GQL.SceneParserInput) {
return StashService.client.query<GQL.ParseSceneFilenamesQuery>({
query: GQL.ParseSceneFilenamesDocument,
variables: { filter: filter, config: config },
fetchPolicy: "network-only",
});
}
private static stringGenderMap = new Map<string, GQL.GenderEnum>(
[["Male", GQL.GenderEnum.Male],
["Female", GQL.GenderEnum.Female],
["Transgender Male", GQL.GenderEnum.TransgenderMale],
["Transgender Female", GQL.GenderEnum.TransgenderFemale],
["Intersex", GQL.GenderEnum.Intersex]]
);
public static genderToString(value?: GQL.GenderEnum) {
if (!value) {
return undefined;
}
const foundEntry = Array.from(StashService.stringGenderMap.entries()).find((e) => {
return e[1] === value;
});
if (foundEntry) {
return foundEntry[0];
}
}
public static stringToGender(value?: string) {
if (!value) {
return undefined;
}
return StashService.stringGenderMap.get(value);
}
public static getGenderStrings() {
return Array.from(StashService.stringGenderMap.keys());
}
public static nullToUndefined(value: any): any {
if (_.isPlainObject(value)) {
return _.mapValues(value, StashService.nullToUndefined);
}
if (_.isArray(value)) {
return value.map(StashService.nullToUndefined);
}
if (value === null) {
return undefined;
}
return value;
}
private constructor() { }
}

View file

@ -1,449 +0,0 @@
import { Spinner } from "@blueprintjs/core";
import _ from "lodash";
import queryString from "query-string";
import React, { useEffect, useState, useRef } from "react";
import { QueryHookResult } from "react-apollo-hooks";
import { ListFilter } from "../components/list/ListFilter";
import { Pagination } from "../components/list/Pagination";
import { StashService } from "../core/StashService";
import { IBaseProps } from "../models";
import { Criterion } from "../models/list-filter/criteria/criterion";
import { ListFilterModel } from "../models/list-filter/filter";
import { DisplayMode, FilterMode } from "../models/list-filter/types";
import { useInterfaceLocalForage } from "./LocalForage";
export interface IListHookData {
filter: ListFilterModel;
template: JSX.Element;
options: IListHookOptions;
onSelectChange: (id: string, selected : boolean, shiftKey: boolean) => void;
}
interface IListHookOperation {
text: string;
onClick: (result: QueryHookResult<any, any>, filter: ListFilterModel, selectedIds: Set<string>) => void;
}
export interface IFilterListImpl {
getData: (filter : ListFilterModel) => QueryHookResult<any, any>;
getItems: (data: any) => any[];
getCount: (data: any) => number;
}
const SceneFilterListImpl: IFilterListImpl = {
getData: (filter : ListFilterModel) => { return StashService.useFindScenes(filter); },
getItems: (data: any) => { return !!data && !!data.findScenes ? data.findScenes.scenes : []; },
getCount: (data: any) => { return !!data && !!data.findScenes ? data.findScenes.count : 0; }
}
const SceneMarkerFilterListImpl: IFilterListImpl = {
getData: (filter : ListFilterModel) => { return StashService.useFindSceneMarkers(filter); },
getItems: (data: any) => { return !!data && !!data.findSceneMarkers ? data.findSceneMarkers.scene_markers : []; },
getCount: (data: any) => { return !!data && !!data.findSceneMarkers ? data.findSceneMarkers.count : 0; }
}
const GalleryFilterListImpl: IFilterListImpl = {
getData: (filter : ListFilterModel) => { return StashService.useFindGalleries(filter); },
getItems: (data: any) => { return !!data && !!data.findGalleries ? data.findGalleries.galleries : []; },
getCount: (data: any) => { return !!data && !!data.findGalleries ? data.findGalleries.count : 0; }
}
const StudioFilterListImpl: IFilterListImpl = {
getData: (filter : ListFilterModel) => { return StashService.useFindStudios(filter); },
getItems: (data: any) => { return !!data && !!data.findStudios ? data.findStudios.studios : []; },
getCount: (data: any) => { return !!data && !!data.findStudios ? data.findStudios.count : 0; }
}
const PerformerFilterListImpl: IFilterListImpl = {
getData: (filter : ListFilterModel) => { return StashService.useFindPerformers(filter); },
getItems: (data: any) => { return !!data && !!data.findPerformers ? data.findPerformers.performers : []; },
getCount: (data: any) => { return !!data && !!data.findPerformers ? data.findPerformers.count : 0; }
}
const MoviesFilterListImpl: IFilterListImpl = {
getData: (filter : ListFilterModel) => { return StashService.useFindMovies(filter); },
getItems: (data: any) => { return !!data && !!data.findMovies ? data.findMovies.movies : []; },
getCount: (data: any) => { return !!data && !!data.findMovies ? data.findMovies.count : 0; }
}
function getFilterListImpl(filterMode: FilterMode) {
switch (filterMode) {
case FilterMode.Scenes: {
return SceneFilterListImpl;
}
case FilterMode.SceneMarkers: {
return SceneMarkerFilterListImpl;
}
case FilterMode.Galleries: {
return GalleryFilterListImpl;
}
case FilterMode.Studios: {
return StudioFilterListImpl;
}
case FilterMode.Performers: {
return PerformerFilterListImpl;
}
case FilterMode.Movies: {
return MoviesFilterListImpl;
}
default: {
console.error("REMOVE DEFAULT IN LIST HOOK");
return SceneFilterListImpl;
}
}
}
export interface IListHookOptions {
filterMode: FilterMode;
subComponent?: boolean;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
props: IBaseProps;
zoomable?: boolean;
otherOperations?: IListHookOperation[];
renderContent: (result: QueryHookResult<any, any>, filter: ListFilterModel, selectedIds: Set<string>, zoomIndex: number) => JSX.Element | undefined;
renderSelectedOptions?: (result: QueryHookResult<any, any>, selectedIds: Set<string>) => JSX.Element | undefined;
}
function updateFromQueryString(queryStr: string, setFilter: (value: React.SetStateAction<ListFilterModel>) => void, forageData?: any) {
const queryParams = queryString.parse(queryStr);
setFilter((f) => {
const newFilter = _.cloneDeep(f);
newFilter.configureFromQueryParameters(queryParams);
if (forageData) {
const forageParams = queryString.parse(forageData.filter);
newFilter.overridePrefs(queryParams, forageParams);
}
return newFilter;
});
}
export class ListHook {
public static useList(options: IListHookOptions): IListHookData {
const [filter, setFilter] = useState<ListFilterModel>(new ListFilterModel(options.filterMode));
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string | undefined>(undefined);
const [totalCount, setTotalCount] = useState<number>(0);
const [zoomIndex, setZoomIndex] = useState<number>(1);
const [interfaceForage, setInterfaceForage] = useInterfaceLocalForage();
const forageInitialised = useRef<boolean>(false);
const filterListImpl = getFilterListImpl(options.filterMode);
// Initialise from interface forage when loaded
useEffect(() => {
function updateFromLocalForage(queryData: any) {
const queryParams = queryString.parse(queryData.filter);
setFilter((f) => {
const newFilter = _.cloneDeep(f);
newFilter.configureFromQueryParameters(queryParams);
newFilter.currentPage = queryData.currentPage;
newFilter.itemsPerPage = queryData.itemsPerPage;
return newFilter;
});
}
function initialise() {
forageInitialised.current = true;
let forageData: any;
if (interfaceForage.data && interfaceForage.data.queries[options.filterMode]) {
forageData = interfaceForage.data.queries[options.filterMode];
}
if (!options.props!.location.search && forageData) {
// we have some data, try to load it
updateFromLocalForage(forageData);
} else {
// use query string instead - include the forageData to include the following
// preferences if not specified: displayMode, itemsPerPage, sortBy and sortDir
updateFromQueryString(options.props!.location.search, setFilter, forageData);
}
}
// don't use query parameters for sub-components
if (!options.subComponent) {
// initialise once when the forage is loaded
if (!forageInitialised.current && !interfaceForage.loading) {
initialise();
return;
}
}
}, [interfaceForage.data, interfaceForage.loading, options.props, options.filterMode, options.subComponent]);
// Update the filter when the query parameters change
useEffect(() => {
// don't use query parameters for sub-components
if (!options.subComponent) {
// only update from the URL if the forage is initialised
if (forageInitialised.current) {
updateFromQueryString(options.props!.location.search, setFilter);
}
}
}, [options.props, options.filterMode, options.subComponent]);
function getFilter() {
if (!options.filterHook) {
return filter;
}
// make a copy of the filter and call the hook
let newFilter = _.cloneDeep(filter);
return options.filterHook(newFilter);
}
const result = filterListImpl.getData(getFilter());
useEffect(() => {
setTotalCount(filterListImpl.getCount(result.data));
// select none when data changes
onSelectNone();
setLastClickedId(undefined);
}, [result.data, filterListImpl])
// Update the query parameters when the data changes
useEffect(() => {
// don't use query parameters for sub-components
if (!options.subComponent) {
// don't update this until local forage is loaded
if (forageInitialised.current) {
const location = Object.assign({}, options.props.history.location);
const includePrefs = true;
location.search = "?" + filter.makeQueryParameters(includePrefs);
if (location.search !== options.props.history.location.search) {
options.props.history.replace(location);
}
setInterfaceForage((d) => {
const dataClone = _.cloneDeep(d);
dataClone!.queries[options.filterMode] = {
filter: location.search,
itemsPerPage: filter.itemsPerPage,
currentPage: filter.currentPage
};
return dataClone;
});
}
}
}, [result.data, filter, options.subComponent, options.filterMode, options.props.history, setInterfaceForage]);
function onChangePageSize(pageSize: number) {
const newFilter = _.cloneDeep(filter);
newFilter.itemsPerPage = pageSize;
newFilter.currentPage = 1;
setFilter(newFilter);
}
function onChangeQuery(query: string) {
const newFilter = _.cloneDeep(filter);
newFilter.searchTerm = query;
newFilter.currentPage = 1;
setFilter(newFilter);
}
function onChangeSortDirection(sortDirection: "asc" | "desc") {
const newFilter = _.cloneDeep(filter);
newFilter.sortDirection = sortDirection;
setFilter(newFilter);
}
function onChangeSortBy(sortBy: string) {
const newFilter = _.cloneDeep(filter);
newFilter.sortBy = sortBy;
newFilter.currentPage = 1;
setFilter(newFilter);
}
function onChangeDisplayMode(displayMode: DisplayMode) {
const newFilter = _.cloneDeep(filter);
newFilter.displayMode = displayMode;
setFilter(newFilter);
}
function onAddCriterion(criterion: Criterion, oldId?: string) {
const newFilter = _.cloneDeep(filter);
// Find if we are editing an existing criteria, then modify that. Or create a new one.
const existingIndex = newFilter.criteria.findIndex((c) => {
// If we modified an existing criterion, then look for the old id.
const id = !!oldId ? oldId : criterion.getId();
return c.getId() === id;
});
if (existingIndex === -1) {
newFilter.criteria.push(criterion);
} else {
newFilter.criteria[existingIndex] = criterion;
}
// Remove duplicate modifiers
newFilter.criteria = newFilter.criteria.filter((obj, pos, arr) => {
return arr.map((mapObj: any) => mapObj.getId()).indexOf(obj.getId()) === pos;
});
newFilter.currentPage = 1;
setFilter(newFilter);
}
function onRemoveCriterion(removedCriterion: Criterion) {
const newFilter = _.cloneDeep(filter);
newFilter.criteria = newFilter.criteria.filter((criterion) => criterion.getId() !== removedCriterion.getId());
newFilter.currentPage = 1;
setFilter(newFilter);
}
function onChangePage(page: number) {
const newFilter = _.cloneDeep(filter);
newFilter.currentPage = page;
setFilter(newFilter);
}
function onSelectChange(id: string, selected : boolean, shiftKey: boolean) {
if (shiftKey) {
multiSelect(id, selected);
} else {
singleSelect(id, selected);
}
}
function singleSelect(id: string, selected: boolean) {
setLastClickedId(id);
const newSelectedIds = _.clone(selectedIds);
if (selected) {
newSelectedIds.add(id);
} else {
newSelectedIds.delete(id);
}
setSelectedIds(newSelectedIds);
}
function multiSelect(id: string, selected : boolean) {
let startIndex = 0;
let thisIndex = -1;
if (!!lastClickedId) {
startIndex = filterListImpl.getItems(result.data).findIndex((item) => {
return item.id === lastClickedId;
});
}
thisIndex = filterListImpl.getItems(result.data).findIndex((item) => {
return item.id === id;
});
selectRange(startIndex, thisIndex);
}
function selectRange(startIndex : number, endIndex : number) {
if (startIndex > endIndex) {
let tmp = startIndex;
startIndex = endIndex;
endIndex = tmp;
}
const subset = filterListImpl.getItems(result.data).slice(startIndex, endIndex + 1);
const newSelectedIds : Set<string> = new Set();
subset.forEach((item) => {
newSelectedIds.add(item.id);
});
setSelectedIds(newSelectedIds);
}
function onSelectAll() {
const newSelectedIds : Set<string> = new Set();
filterListImpl.getItems(result.data).forEach((item) => {
newSelectedIds.add(item.id);
});
setSelectedIds(newSelectedIds);
setLastClickedId(undefined);
}
function onSelectNone() {
const newSelectedIds : Set<string> = new Set();
setSelectedIds(newSelectedIds);
setLastClickedId(undefined);
}
function onChangeZoom(newZoomIndex : number) {
setZoomIndex(newZoomIndex);
}
const otherOperations = options.otherOperations ? options.otherOperations.map((o) => {
return {
text: o.text,
onClick: () => {
o.onClick(result, filter, selectedIds);
}
}
}) : undefined;
function maybeRenderContent() {
if (!result.loading && !result.error) {
return options.renderContent(result, filter, selectedIds, zoomIndex);
}
}
function maybeRenderPagination() {
if (!result.loading && !result.error) {
return (
<Pagination
itemsPerPage={filter.itemsPerPage}
currentPage={filter.currentPage}
totalItems={totalCount}
onChangePage={onChangePage}
/>
);
}
}
function getTemplate() {
if (!options.subComponent && !forageInitialised.current) {
return (
<div>
{!result.error ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
{result.error ? <h1>{result.error.message}</h1> : undefined}
</div>
)
} else {
return (
<div>
<ListFilter
onChangePageSize={onChangePageSize}
onChangeQuery={onChangeQuery}
onChangeSortDirection={onChangeSortDirection}
onChangeSortBy={onChangeSortBy}
onChangeDisplayMode={onChangeDisplayMode}
onAddCriterion={onAddCriterion}
onRemoveCriterion={onRemoveCriterion}
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
zoomIndex={options.zoomable ? zoomIndex : undefined}
onChangeZoom={options.zoomable ? onChangeZoom : undefined}
otherOperations={otherOperations}
filter={filter}
/>
{options.renderSelectedOptions && selectedIds.size > 0 ? options.renderSelectedOptions(result, selectedIds) : undefined}
{result.loading || (!options.subComponent && !forageInitialised.current) ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
{result.error ? <h1>{result.error.message}</h1> : undefined}
{maybeRenderContent()}
{maybeRenderPagination()}
</div>
)
}
}
return { filter, template: getTemplate(), options, onSelectChange };
}
}

View file

@ -1,75 +0,0 @@
import localForage from "localforage";
import _ from "lodash";
import React, { Dispatch, SetStateAction } from "react";
interface IInterfaceWallConfig {
}
export interface IInterfaceConfig {
wall: IInterfaceWallConfig;
queries: any;
}
type ValidTypes = IInterfaceConfig | undefined;
interface ILocalForage<T> {
data: T;
setData: React.Dispatch<React.SetStateAction<T>>;
error: Error | null;
loading: boolean;
}
export function useInterfaceLocalForage(): [ILocalForage<IInterfaceConfig | undefined>, React.Dispatch<React.SetStateAction<IInterfaceConfig | undefined>>] {
const result = useLocalForage("interface");
// Set defaults
React.useEffect(() => {
if (!result.data) {
result.setData({
wall: {
// nothing here currently
},
queries: {}
});
} else if (!result.data.queries) {
let newData = Object.assign({}, result.data);
newData.queries = {};
result.setData(newData);
}
});
return [result, result.setData];
}
function useLocalForage(item: string): ILocalForage<ValidTypes> {
const [json, setJson] = React.useState<ValidTypes>(undefined);
const [err, setErr] = React.useState(null);
const [loaded, setLoaded] = React.useState<boolean>(false);
const prevJson = React.useRef<ValidTypes>(undefined);
React.useEffect(() => {
async function runAsync() {
if (typeof json !== "undefined" && !_.isEqual(json, prevJson.current)) {
await localForage.setItem(item, JSON.stringify(json));
}
prevJson.current = json;
}
runAsync();
});
React.useEffect(() => {
async function runAsync() {
try {
const serialized = await localForage.getItem<any>(item);
const parsed = JSON.parse(serialized);
if (typeof json === "undefined" && !Object.is(parsed, null)) {
setErr(null);
setJson(parsed);
}
} catch (error) {
setErr(error);
}
setLoaded(true);
}
runAsync();
});
return {data: json, setData: setJson, error: err, loading: !loaded};
}

View file

@ -1,72 +0,0 @@
import { useEffect, useRef } from "react";
import { StashService } from "../core/StashService";
export interface IVideoHoverHookData {
videoEl: React.RefObject<HTMLVideoElement>;
isPlaying: React.MutableRefObject<boolean>;
isHovering: React.MutableRefObject<boolean>;
options: IVideoHoverHookOptions;
}
export interface IVideoHoverHookOptions {
resetOnMouseLeave: boolean;
}
export class VideoHoverHook {
public static useVideoHover(options: IVideoHoverHookOptions): IVideoHoverHookData {
const videoEl = useRef<HTMLVideoElement>(null);
const isPlaying = useRef<boolean>(false);
const isHovering = useRef<boolean>(false);
const config = StashService.useConfiguration();
const soundEnabled = !!config.data && !!config.data.configuration ? config.data.configuration.interface.soundOnPreview : true;
useEffect(() => {
const videoTag = videoEl.current;
if (!videoTag) { return; }
videoTag.onplaying = () => {
if (isHovering.current === true) {
isPlaying.current = true;
} else {
videoTag.pause();
}
};
videoTag.onpause = () => isPlaying.current = false;
}, [videoEl]);
useEffect(() => {
const videoTag = videoEl.current;
if (!videoTag) { return; }
videoTag.volume = soundEnabled ? 0.05 : 0;
}, [soundEnabled]);
return {videoEl, isPlaying, isHovering, options};
}
public static onMouseEnter(data: IVideoHoverHookData) {
data.isHovering.current = true;
const videoTag = data.videoEl.current;
if (!videoTag) { return; }
if (videoTag.paused && !data.isPlaying.current) {
videoTag.play().catch((error) => {
console.log(error.message);
});
}
}
public static onMouseLeave(data: IVideoHoverHookData) {
data.isHovering.current = false;
const videoTag = data.videoEl.current;
if (!videoTag) { return; }
if (!videoTag.paused && data.isPlaying) {
videoTag.pause();
if (data.options.resetOnMouseLeave) {
videoTag.removeAttribute("src");
videoTag.load();
data.isPlaying.current = false;
}
}
}
}

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