Add new v2.5 UI (#357)

This commit is contained in:
WithoutPants
2020-03-05 10:15:02 +11:00
committed by GitHub
153 changed files with 29845 additions and 0 deletions

View File

@@ -21,6 +21,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
showStudioAsText
css
cssEnabled
language
}
fragment ConfigData on ConfigResult {

View File

@@ -75,6 +75,7 @@ input ConfigInterfaceInput {
"""Custom CSS"""
css: String
cssEnabled: Boolean
language: String
}
type ConfigInterfaceResult {
@@ -91,6 +92,8 @@ type ConfigInterfaceResult {
"""Custom CSS"""
css: String
cssEnabled: Boolean
"""Interface language"""
language: String
}
"""All configuration settings"""

View File

@@ -106,6 +106,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
config.Set(config.ShowStudioAsText, *input.ShowStudioAsText)
}
if input.Language != nil {
config.Set(config.Language, *input.Language)
}
css := ""
if input.CSS != nil {

View File

@@ -57,6 +57,8 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
showStudioAsText := config.GetShowStudioAsText()
css := config.GetCSS()
cssEnabled := config.GetCSSEnabled()
language := config.GetLanguage()
return &models.ConfigInterfaceResult{
SoundOnPreview: &soundOnPreview,
@@ -66,5 +68,6 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
ShowStudioAsText: &showStudioAsText,
CSS: &css,
CSSEnabled: &cssEnabled,
Language: &language,
}
}

View File

@@ -32,6 +32,9 @@ const Host = "host"
const Port = "port"
const ExternalHost = "external_host"
// i18n
const Language = "language"
// Interface options
const SoundOnPreview = "sound_on_preview"
const WallShowTitle = "wall_show_title"
@@ -97,6 +100,17 @@ func GetExcludes() []string {
return viper.GetStringSlice(Exclude)
}
func GetLanguage() string {
ret := viper.GetString(Language)
// default to English
if ret == "" {
return "en-US"
}
return ret
}
func GetScrapersPath() string {
return viper.GetString(ScrapersPath)
}

3
ui/v2.5/.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"presets": ["react-app"]
}

9
ui/v2.5/.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

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

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

53
ui/v2.5/.eslintrc.json Normal file
View File

@@ -0,0 +1,53 @@
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
},
"plugins": [
"@typescript-eslint"
],
"extends": [
"airbnb-typescript",
"airbnb/hooks",
"prettier",
"prettier/react",
"prettier/@typescript-eslint"
],
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": 2,
"lines-between-class-members": "off",
"@typescript-eslint/interface-name-prefix": [
"warn",
{ "prefixWithI": "always" }
],
"import/named": "off",
"import/namespace": "off",
"import/default": "off",
"import/no-named-as-default-member": "off",
"import/no-named-as-default": "off",
"import/no-cycle": "off",
"import/no-unused-modules": "off",
"import/no-deprecated": "off",
"import/no-unresolved": "off",
"import/prefer-default-export": "off",
"import/no-extraneous-dependencies": "off",
"indent": "off",
"@typescript-eslint/indent": "off",
"react/prop-types": "off",
"react/destructuring-assignment": "off",
"react/jsx-props-no-spreading": "off",
"spaced-comment": ["error", "always", {
"markers": ["/"]
}],
"max-classes-per-file": "off",
"no-plusplus": "off",
"prefer-destructuring": ["error", {"object": true, "array": false}],
"default-case": "off",
"consistent-return": "off",
"@typescript-eslint/no-use-before-define": ["error", { "functions": false, "classes": true }],
"no-underscore-dangle": "off",
"no-nested-ternary": "off",
"jsx-a11y/media-has-caption": "off"
}
}

25
ui/v2.5/.gitignore vendored Executable file
View File

@@ -0,0 +1,25 @@
# 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*
.eslintcache

96
ui/v2.5/.stylelintrc Normal file
View File

@@ -0,0 +1,96 @@
{
"plugins": [
"stylelint-order"
],
"rules": {
"indentation": 2,
"at-rule-empty-line-before": [ "always", {
except: ["after-same-name", "first-nested" ],
ignore: ["after-comment"],
} ],
"at-rule-no-vendor-prefix": true,
"selector-no-vendor-prefix": true,
"block-closing-brace-newline-after": "always",
"block-closing-brace-newline-before": "always-multi-line",
"block-closing-brace-space-before": "always-single-line",
"block-no-empty": true,
"block-opening-brace-newline-after": "always-multi-line",
"block-opening-brace-space-after": "always-single-line",
"block-opening-brace-space-before": "always",
"color-hex-case": "lower",
"color-hex-length": "short",
"color-no-invalid-hex": true,
"comment-empty-line-before": [ "always", {
except: ["first-nested"],
ignore: ["stylelint-commands"],
} ],
"comment-whitespace-inside": "always",
"declaration-bang-space-after": "never",
"declaration-bang-space-before": "always",
"declaration-block-no-shorthand-property-overrides": true,
"declaration-block-semicolon-newline-after": "always-multi-line",
"declaration-block-semicolon-space-after": "always-single-line",
"declaration-block-semicolon-space-before": "never",
"declaration-block-single-line-max-declarations": 1,
"declaration-block-trailing-semicolon": "always",
"declaration-colon-newline-after": "always-multi-line",
"declaration-colon-space-after": "always-single-line",
"declaration-colon-space-before": "never",
"declaration-no-important": true,
"font-family-name-quotes": "always-where-recommended",
"function-calc-no-unspaced-operator": true,
"function-comma-newline-after": "always-multi-line",
"function-comma-space-after": "always-single-line",
"function-comma-space-before": "never",
"function-linear-gradient-no-nonstandard-direction": true,
"function-parentheses-newline-inside": "always-multi-line",
"function-parentheses-space-inside": "never-single-line",
"function-url-quotes": "always",
"function-whitespace-after": "always",
"length-zero-no-unit": true,
"max-empty-lines": 1,
"max-nesting-depth": 4,
"max-line-length": 100,
"media-feature-colon-space-after": "always",
"media-feature-colon-space-before": "never",
"media-feature-range-operator-space-after": "always",
"media-feature-range-operator-space-before": "always",
"media-query-list-comma-newline-after": "always-multi-line",
"media-query-list-comma-space-after": "always-single-line",
"media-query-list-comma-space-before": "never",
"media-feature-parentheses-space-inside": "never",
"no-descending-specificity": null,
"no-invalid-double-slash-comments": true,
"no-missing-end-of-source-newline": true,
"number-leading-zero": "never",
"number-max-precision": 2,
"number-no-trailing-zeros": true,
"order/order": [
"custom-properties",
"declarations"
],
"order/properties-alphabetical-order": true,
"rule-empty-line-before": ["always-multi-line", {
except: ["after-single-line-comment", "first-nested" ],
ignore: ["after-comment"],
}],
"selector-max-id": 1,
"selector-max-type": 2,
"selector-class-pattern": "^(\\.*[A-Z]*[a-z]+)+(-[a-z0-9]+)*$",
"selector-combinator-space-after": "always",
"selector-combinator-space-before": "always",
"selector-list-comma-newline-after": "always",
"selector-list-comma-space-before": "never",
"selector-max-universal": 0,
"selector-type-case": "lower",
"selector-pseudo-element-colon-notation": "double",
"string-no-newline": true,
"string-quotes": "double",
"time-min-milliseconds": 100,
"unit-blacklist": ["em"],
"value-list-comma-newline-after": "always-multi-line",
"value-list-comma-space-after": "always-single-line",
"value-list-comma-space-before": "never",
"value-no-vendor-prefix": true
},
}

18
ui/v2.5/.vscode/launch.json vendored Normal file
View File

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

10
ui/v2.5/.vscode/settings.json vendored Normal file
View File

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

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

@@ -0,0 +1,47 @@
* Install gulp `yarn global add gulp`
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.<br>
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br>
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.<br>
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.<br>
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

15
ui/v2.5/codegen.yml Normal file
View File

@@ -0,0 +1,15 @@
overwrite: true
schema: "../../graphql/schema/**/*.graphql"
documents: "../../graphql/documents/**/*.graphql"
generates:
src/core/generated-graphql.tsx:
config:
withHooks: true
withHOC: false
withComponents: false
plugins:
- add: "/* eslint-disable */"
- time
- typescript
- typescript-operations
- typescript-react-apollo

97
ui/v2.5/package.json Normal file
View File

@@ -0,0 +1,97 @@
{
"name": "stash",
"version": "0.1.0",
"private": true,
"sideEffects": false,
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "yarn lint:css && yarn lint:js",
"lint:js": "eslint --cache src/**/*.{ts,tsx}",
"lint:css": "stylelint 'src/**/*.scss'",
"format": "prettier --write \"src/**/!(generated-graphql).{js,jsx,ts,tsx}\"",
"gqlgen": "gql-gen --config codegen.yml",
"extract": "NODE_ENV=development extract-messages -l=en,de -o src/locale -d en --flat false 'src/**/!(*.test).tsx'"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
],
"dependencies": {
"@apollo/react-hooks": "^3.1.3",
"@fortawesome/fontawesome-svg-core": "^1.2.26",
"@fortawesome/free-solid-svg-icons": "^5.12.0",
"@fortawesome/react-fontawesome": "^0.1.8",
"apollo-cache": "^1.3.4",
"apollo-cache-inmemory": "^1.6.5",
"apollo-client": "^2.6.8",
"apollo-link": "^1.2.13",
"apollo-link-http": "^1.5.16",
"apollo-link-ws": "^1.0.19",
"apollo-utilities": "^1.3.3",
"axios": "0.18.1",
"bootstrap": "^4.4.1",
"classnames": "^2.2.6",
"formik": "^2.1.2",
"graphql": "^14.5.8",
"graphql-tag": "^2.10.1",
"localforage": "1.7.3",
"lodash": "^4.17.15",
"query-string": "6.10.1",
"react": "~16.12.0",
"react-apollo": "^3.1.3",
"react-bootstrap": "^1.0.0-beta.16",
"react-dom": "16.12.0",
"react-hotkeys": "^2.0.0",
"react-images": "0.5.19",
"react-intl": "^3.12.0",
"react-jw-player": "1.19.0",
"react-photo-gallery": "^8.0.0",
"react-router-bootstrap": "^0.25.0",
"react-router-dom": "^5.1.2",
"react-select": "^3.0.8",
"subscriptions-transport-ws": "^0.9.16",
"video.js": "^7.6.0"
},
"devDependencies": {
"@graphql-codegen/add": "^1.11.2",
"@graphql-codegen/cli": "^1.11.2",
"@graphql-codegen/time": "^1.11.2",
"@graphql-codegen/typescript": "^1.11.2",
"@graphql-codegen/typescript-compatibility": "^1.11.2",
"@graphql-codegen/typescript-operations": "^1.11.2",
"@graphql-codegen/typescript-react-apollo": "^1.11.2",
"@types/classnames": "^2.2.9",
"@types/jest": "24.0.13",
"@types/lodash": "^4.14.149",
"@types/node": "13.1.8",
"@types/react": "16.9.19",
"@types/react-dom": "^16.9.5",
"@types/react-images": "^0.5.1",
"@types/react-router-bootstrap": "^0.24.5",
"@types/react-router-dom": "5.1.3",
"@types/react-select": "^3.0.8",
"@types/video.js": "^7.2.11",
"@typescript-eslint/eslint-plugin": "^2.16.0",
"@typescript-eslint/parser": "^2.16.0",
"eslint": "^6.7.2",
"eslint-config-airbnb-typescript": "^6.3.1",
"eslint-config-prettier": "^6.9.0",
"eslint-plugin-import": "^2.20.0",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.18.0",
"eslint-plugin-react-hooks": "^1.7.0",
"extract-react-intl-messages": "^2.3.5",
"node-sass": "4.13.1",
"postcss-safe-parser": "^4.0.1",
"prettier": "1.19.1",
"react-scripts": "^3.3.1",
"stylelint": "^13.0.0",
"stylelint-order": "^4.0.0",
"typescript": "^3.7.5"
}
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

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

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

58
ui/v2.5/src/App.tsx Executable file
View File

@@ -0,0 +1,58 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import { IntlProvider } from "react-intl";
import { ToastProvider } from "src/hooks/Toast";
import { library } from "@fortawesome/fontawesome-svg-core";
import { fas } from "@fortawesome/free-solid-svg-icons";
import locales from "src/locale";
import { StashService } from "src/core/StashService";
import { flattenMessages } from "src/utils";
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 { TagList } from "./components/Tags/TagList";
import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser";
// Set fontawesome/free-solid-svg as default fontawesome icons
library.add(fas);
export const App: React.FC = () => {
const config = StashService.useConfiguration();
const language = config.data?.configuration?.interface?.language ?? "en-US";
const messageLanguage = language.slice(0, 2);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages = flattenMessages((locales as any)[messageLanguage]);
return (
<ErrorBoundary>
<IntlProvider locale={language} messages={messages}>
<ToastProvider>
<MainNavbar />
<div className="main container-fluid">
<Switch>
<Route exact path="/" component={Stats} />
<Route path="/scenes" component={Scenes} />
<Route path="/galleries" component={Galleries} />
<Route path="/performers" component={Performers} />
<Route path="/tags" component={TagList} />
<Route path="/studios" component={Studios} />
<Route path="/settings" component={Settings} />
<Route
path="/sceneFilenameParser"
component={SceneFilenameParser}
/>
<Route component={PageNotFound} />
</Switch>
</div>
</ToastProvider>
</IntlProvider>
</ErrorBoundary>
);
};

View File

@@ -0,0 +1,50 @@
import React from "react";
interface IErrorBoundaryProps {
children?: React.ReactNode;
}
type ErrorInfo = {
componentStack: string;
};
interface IErrorBoundaryState {
error?: Error;
errorInfo?: ErrorInfo;
}
export class ErrorBoundary extends React.Component<
IErrorBoundaryProps,
IErrorBoundaryState
> {
constructor(props: IErrorBoundaryProps) {
super(props);
this.state = {};
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState({
error,
errorInfo
});
}
public render() {
if (this.state.errorInfo) {
// Error path
return (
<div>
<h2>Something went wrong.</h2>
<details className="error-message">
{this.state.error && this.state.error.toString()}
<br />
{this.state.errorInfo.componentStack}
</details>
</div>
);
}
// Normally, just render children
return this.props.children;
}
}

View File

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

View File

@@ -0,0 +1,21 @@
import React from "react";
import { useParams } from "react-router-dom";
import { StashService } from "src/core/StashService";
import { LoadingIndicator } from "src/components/Shared";
import { GalleryViewer } from "./GalleryViewer";
export const Gallery: React.FC = () => {
const { id = "" } = useParams();
const { data, error, loading } = StashService.useFindGallery(id);
const gallery = data?.findGallery;
if (loading || !gallery) return <LoadingIndicator />;
if (error) return <div>{error.message}</div>;
return (
<div className="col-9 m-auto">
<GalleryViewer gallery={gallery} />
</div>
);
};

View File

@@ -0,0 +1,64 @@
import React from "react";
import { Table } from "react-bootstrap";
import { Link } from "react-router-dom";
import { FindGalleriesQueryResult } from "src/core/generated-graphql";
import { useGalleriesList } from "src/hooks";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
export const GalleryList: React.FC = () => {
const listData = useGalleriesList({
renderContent
});
function renderContent(
result: FindGalleriesQueryResult,
filter: ListFilterModel
) {
if (!result.data || !result.data.findGalleries) {
return;
}
if (filter.displayMode === DisplayMode.Grid) {
return <h1>TODO</h1>;
}
if (filter.displayMode === DisplayMode.List) {
return (
<Table className="col col-sm-6 mx-auto">
<thead>
<tr>
<th>Preview</th>
<th className="d-none d-sm-none">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 ?? ""}
className="w-100 w-sm-auto"
src={`${gallery.files[0].path}?thumb=true`}
/>
) : (
undefined
)}
</Link>
</td>
<td className="d-none d-sm-block">
<Link to={`/galleries/${gallery.id}`}>{gallery.path}</Link>
</td>
</tr>
))}
</tbody>
</Table>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return <h1>TODO</h1>;
}
}
return listData.template;
};

View File

@@ -0,0 +1,59 @@
import React, { FunctionComponent, useState } from "react";
import Lightbox from "react-images";
import Gallery from "react-photo-gallery";
import * as GQL from "src/core/generated-graphql";
interface IProps {
gallery: GQL.GalleryDataFragment;
}
export const GalleryViewer: FunctionComponent<IProps> = ({ gallery }) => {
const [currentImage, setCurrentImage] = useState<number>(0);
const [lightboxIsOpen, setLightboxIsOpen] = useState<boolean>(false);
function openLightbox(
_event: React.MouseEvent<Element>,
obj: { index: number }
) {
setCurrentImage(obj.index);
setLightboxIsOpen(true);
}
function closeLightbox() {
setCurrentImage(0);
setLightboxIsOpen(false);
}
function gotoPrevious() {
setCurrentImage(currentImage - 1);
}
function gotoNext() {
setCurrentImage(currentImage + 1);
}
const photos = gallery.files.map(file => ({
src: file.path ?? "",
caption: file.name ?? ""
}));
const thumbs = 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}
onClickImage={() =>
window.open(photos[currentImage].src ?? "", "_blank")
}
isOpen={lightboxIsOpen}
width={9999}
/>
</div>
);
};

View File

@@ -0,0 +1,7 @@
/* stylelint-disable selector-class-pattern */
.react-photo-gallery--gallery {
img {
object-fit: contain;
}
}
/* stylelint-enable selector-class-pattern */

View File

@@ -0,0 +1,255 @@
import _ from "lodash";
import React, { useEffect, useRef, useState } from "react";
import { Button, Form, Modal, OverlayTrigger, Tooltip } from "react-bootstrap";
import { Icon, FilterSelect, DurationInput } from "src/components/Shared";
import { CriterionModifier } from "src/core/generated-graphql";
import {
Criterion,
CriterionType,
DurationCriterion,
CriterionValue
} from "src/models/list-filter/criteria/criterion";
import { NoneCriterion } from "src/models/list-filter/criteria/none";
import { makeCriteria } from "src/models/list-filter/criteria/utils";
import { ListFilterModel } from "src/models/list-filter/filter";
interface IAddFilterProps {
onAddCriterion: (criterion: Criterion, oldId?: string) => void;
onCancel: () => void;
filter: ListFilterModel;
editingCriterion?: Criterion;
}
export const AddFilter: React.FC<IAddFilterProps> = (
props: IAddFilterProps
) => {
const defaultValue = useRef<string | number | undefined>();
const [isOpen, setIsOpen] = useState(false);
const [criterion, setCriterion] = useState<Criterion>(new NoneCriterion());
const valueStage = useRef<CriterionValue>(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 CriterionModifier;
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 (!Array.isArray(criterion.value) && defaultValue.current !== undefined) {
const value = defaultValue.current;
if (
criterion.options &&
(value === undefined || value === "" || typeof value === "number")
) {
criterion.value = criterion.options[0].toString();
} 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 (
<Form.Control
as="select"
onChange={onChangedModifierSelect}
value={criterion.modifier}
>
{criterion.modifierOptions.map(c => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</Form.Control>
);
}
function renderSelect() {
// Hide the value select if the modifier is "IsNull" or "NotNull"
if (
criterion.modifier === CriterionModifier.IsNull ||
criterion.modifier === CriterionModifier.NotNull
) {
return;
}
if (Array.isArray(criterion.value)) {
if (
criterion.type !== "performers" &&
criterion.type !== "studios" &&
criterion.type !== "tags"
)
return;
return (
<FilterSelect
type={criterion.type}
isMulti
onSelect={items => {
const newCriterion = _.cloneDeep(criterion);
newCriterion.value = items.map(i => ({
id: i.id,
label: i.name!
}));
setCriterion(newCriterion);
}}
ids={criterion.value.map(labeled => labeled.id)}
/>
);
}
if (criterion.options) {
defaultValue.current = criterion.value;
return (
<Form.Control
as="select"
onChange={onChangedSingleSelect}
value={criterion.value.toString()}
>
{criterion.options.map(c => (
<option key={c.toString()} value={c.toString()}>
{c}
</option>
))}
</Form.Control>
);
}
if (criterion instanceof DurationCriterion) {
// render duration control
return (
<DurationInput
numericValue={criterion.value ? criterion.value : 0}
onValueChange={onChangedDuration}
/>
);
}
return (
<Form.Control
type={criterion.inputType}
onChange={onChangedInput}
onBlur={onBlurInput}
defaultValue={criterion.value ? criterion.value.toString() : ""}
/>
);
}
return (
<>
<Form.Group>{renderModifier()}</Form.Group>
<Form.Group>{renderSelect()}</Form.Group>
</>
);
};
function maybeRenderFilterSelect() {
if (props.editingCriterion) {
return;
}
return (
<Form.Group controlId="filter">
<Form.Label>Filter</Form.Label>
<Form.Control
as="select"
onChange={onChangedCriteriaType}
value={criterion.type}
>
{props.filter.criterionOptions.map(c => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</Form.Control>
</Form.Group>
);
}
const title = !props.editingCriterion ? "Add Filter" : "Update Filter";
return (
<>
<OverlayTrigger
placement="top"
overlay={<Tooltip id="filter-tooltip">Filter</Tooltip>}
>
<Button variant="secondary" onClick={() => onToggle()} active={isOpen}>
<Icon icon="filter" />
</Button>
</OverlayTrigger>
<Modal show={isOpen} onHide={() => onToggle()}>
<Modal.Header>{title}</Modal.Header>
<Modal.Body>
<div className="dialog-content">
{maybeRenderFilterSelect()}
{maybeRenderFilterPopoverContents()}
</div>
</Modal.Body>
<Modal.Footer>
<Button onClick={onAddFilter} disabled={criterion.type === "none"}>
{title}
</Button>
</Modal.Footer>
</Modal>
</>
);
};

View File

@@ -0,0 +1,329 @@
import { debounce } from "lodash";
import React, { useCallback, useState } from "react";
import { SortDirectionEnum } from "src/core/generated-graphql";
import {
Badge,
Button,
ButtonGroup,
Dropdown,
Form,
OverlayTrigger,
Tooltip,
SafeAnchor
} from "react-bootstrap";
import { Icon } from "src/components/Shared";
import { Criterion } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/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: SortDirectionEnum) => 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: React.FC<IListFilterProps> = (
props: IListFilterProps
) => {
const searchCallback = useCallback(
debounce((value: string) => {
props.onChangeQuery(value);
}, 500),
[props.onChangeQuery]
);
const [editingCriterion, setEditingCriterion] = useState<
Criterion | undefined
>(undefined);
function onChangePageSize(event: React.FormEvent<HTMLSelectElement>) {
const val = event.currentTarget.value;
props.onChangePageSize(parseInt(val, 10));
}
function onChangeQuery(event: React.FormEvent<HTMLInputElement>) {
searchCallback(event.currentTarget.value);
}
function onChangeSortDirection() {
if (props.filter.sortDirection === SortDirectionEnum.Asc) {
props.onChangeSortDirection(SortDirectionEnum.Desc);
} else {
props.onChangeSortDirection(SortDirectionEnum.Asc);
}
}
function onChangeSortBy(event: React.MouseEvent<SafeAnchor>) {
const target = (event.currentTarget as unknown) as HTMLAnchorElement;
props.onChangeSortBy(target.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 => (
<Dropdown.Item onClick={onChangeSortBy} key={option}>
{option}
</Dropdown.Item>
));
}
function renderDisplayModeOptions() {
function getIcon(option: DisplayMode) {
switch (option) {
case DisplayMode.Grid:
return "th-large";
case DisplayMode.List:
return "list";
case DisplayMode.Wall:
return "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 => (
<OverlayTrigger
key={option}
overlay={
<Tooltip id="display-mode-tooltip">{getLabel(option)}</Tooltip>
}
>
<Button
variant="secondary"
active={props.filter.displayMode === option}
onClick={() => onChangeDisplayMode(option)}
>
<Icon icon={getIcon(option)} />
</Button>
</OverlayTrigger>
));
}
function renderFilterTags() {
return props.filter.criteria.map(criterion => (
<Badge
className="tag-item"
variant="secondary"
key={criterion.getId()}
onClick={() => onClickCriterionTag(criterion)}
>
{criterion.getLabel()}
<Button
variant="secondary"
onClick={() => onRemoveCriterionTag(criterion)}
>
<Icon icon="times" />
</Button>
</Badge>
));
}
function onSelectAll() {
if (props.onSelectAll) {
props.onSelectAll();
}
}
function onSelectNone() {
if (props.onSelectNone) {
props.onSelectNone();
}
}
function renderSelectAll() {
if (props.onSelectAll) {
return (
<Dropdown.Item key="select-all" onClick={() => onSelectAll()}>
Select All
</Dropdown.Item>
);
}
}
function renderSelectNone() {
if (props.onSelectNone) {
return (
<Dropdown.Item key="select-none" onClick={() => onSelectNone()}>
Select None
</Dropdown.Item>
);
}
}
function renderMore() {
const options = [renderSelectAll(), renderSelectNone()];
if (props.otherOperations) {
props.otherOperations.forEach(o => {
options.push(
<Dropdown.Item key={o.text} onClick={o.onClick}>
{o.text}
</Dropdown.Item>
);
});
}
if (options.length > 0) {
return (
<Dropdown>
<Dropdown.Toggle variant="secondary" id="more-menu">
<Icon icon="ellipsis-h" />
</Dropdown.Toggle>
<Dropdown.Menu>{options}</Dropdown.Menu>
</Dropdown>
);
}
}
function onChangeZoom(v: number) {
if (props.onChangeZoom) {
props.onChangeZoom(v);
}
}
function maybeRenderZoom() {
if (props.onChangeZoom) {
return (
<Form.Control
className="zoom-slider col-1 d-none d-sm-block"
type="range"
min={0}
max={3}
defaultValue={1}
onChange={(e: React.FormEvent<HTMLInputElement>) =>
onChangeZoom(Number.parseInt(e.currentTarget.value, 10))
}
/>
);
}
}
function render() {
return (
<>
<div className="filter-container">
<Form.Control
placeholder="Search..."
defaultValue={props.filter.searchTerm}
onChange={onChangeQuery}
className="filter-item col-5 col-sm-2"
/>
<Form.Control
as="select"
onChange={onChangePageSize}
value={props.filter.itemsPerPage.toString()}
className="filter-item col-1 d-none d-sm-inline"
>
{PAGE_SIZE_OPTIONS.map(s => (
<option value={s} key={s}>
{s}
</option>
))}
</Form.Control>
<ButtonGroup className="filter-item">
<Dropdown as={ButtonGroup}>
<Dropdown.Toggle split variant="secondary" id="more-menu">
{props.filter.sortBy}
</Dropdown.Toggle>
<Dropdown.Menu>{renderSortByOptions()}</Dropdown.Menu>
<OverlayTrigger
overlay={
<Tooltip id="sort-direction-tooltip">
{props.filter.sortDirection === SortDirectionEnum.Asc
? "Ascending"
: "Descending"}
</Tooltip>
}
>
<Button variant="secondary" onClick={onChangeSortDirection}>
<Icon
icon={
props.filter.sortDirection === SortDirectionEnum.Asc
? "caret-up"
: "caret-down"
}
/>
</Button>
</OverlayTrigger>
</Dropdown>
</ButtonGroup>
<AddFilter
filter={props.filter}
onAddCriterion={onAddCriterion}
onCancel={onCancelAddCriterion}
editingCriterion={editingCriterion}
/>
<ButtonGroup className="filter-item d-none d-sm-inline-flex">
{renderDisplayModeOptions()}
</ButtonGroup>
{maybeRenderZoom()}
<ButtonGroup className="filter-item d-none d-sm-inline-flex">
{renderMore()}
</ButtonGroup>
</div>
<div className="d-flex justify-content-center">
{renderFilterTags()}
</div>
</>
);
}
return render();
};

View File

@@ -0,0 +1,101 @@
import React from "react";
import { Button, ButtonGroup } from "react-bootstrap";
interface IPaginationProps {
itemsPerPage: number;
currentPage: number;
totalItems: number;
onChangePage: (page: number) => void;
}
export const Pagination: React.FC<IPaginationProps> = ({
itemsPerPage,
currentPage,
totalItems,
onChangePage
}) => {
const totalPages = Math.ceil(totalItems / itemsPerPage);
let startPage: number;
let endPage: number;
if (totalPages <= 10) {
// less than 10 total pages so show all
startPage = 1;
endPage = totalPages;
} else if (currentPage <= 6) {
startPage = 1;
endPage = 10;
} else if (currentPage + 4 >= totalPages) {
startPage = totalPages - 9;
endPage = totalPages;
} else {
startPage = currentPage - 5;
endPage = currentPage + 4;
}
const pages = [...Array(endPage + 1 - startPage).keys()].map(
i => startPage + i
);
const calculatePageClass = (buttonPage: number) => {
if (pages.length <= 4) return "";
if (currentPage === 1 && buttonPage <= 4) return "";
const maxPage = pages[pages.length - 1];
if (currentPage === maxPage && buttonPage > maxPage - 3) return "";
if (Math.abs(buttonPage - currentPage) <= 1) return "";
return "d-none d-sm-block";
};
const pageButtons = pages.map((page: number) => (
<Button
variant="secondary"
className={calculatePageClass(page)}
key={page}
active={currentPage === page}
onClick={() => onChangePage(page)}
>
{page}
</Button>
));
if (pages.length <= 1) return <div />;
return (
<ButtonGroup className="filter-container pagination">
<Button
variant="secondary"
disabled={currentPage === 1}
onClick={() => onChangePage(1)}
>
<span className="d-none d-sm-inline">First</span>
<span className="d-inline d-sm-none">&#x300a;</span>
</Button>
<Button
className="d-none d-sm-block"
variant="secondary"
disabled={currentPage === 1}
onClick={() => onChangePage(currentPage - 1)}
>
Previous
</Button>
{pageButtons}
<Button
className="d-none d-sm-block"
variant="secondary"
disabled={currentPage === totalPages}
onClick={() => onChangePage(currentPage + 1)}
>
Next
</Button>
<Button
variant="secondary"
disabled={currentPage === totalPages}
onClick={() => onChangePage(totalPages)}
>
<span className="d-none d-sm-inline">Last</span>
<span className="d-inline d-sm-none">&#x300b;</span>
</Button>
</ButtonGroup>
);
};

View File

@@ -0,0 +1,15 @@
.pagination {
.btn {
border-left: 1px solid $body-bg;
border-right: 1px solid $body-bg;
flex-grow: 0;
padding-left: 15px;
padding-right: 15px;
transition: none;
}
}
.zoom-slider {
padding-left: 0;
padding-right: 0;
}

View File

@@ -0,0 +1,120 @@
import React from "react";
import { FormattedMessage } from "react-intl";
import { Nav, Navbar, Button } from "react-bootstrap";
import { IconName } from "@fortawesome/fontawesome-svg-core";
import { LinkContainer } from "react-router-bootstrap";
import { Link, useLocation } from "react-router-dom";
import { Icon } from "src/components/Shared";
interface IMenuItem {
messageID: string;
href: string;
icon: IconName;
}
const menuItems: IMenuItem[] = [
{
icon: "play-circle",
messageID: "scenes",
href: "/scenes"
},
{
href: "/scenes/markers",
icon: "map-marker-alt",
messageID: "markers"
},
{
href: "/galleries",
icon: "image",
messageID: "galleries"
},
{
href: "/performers",
icon: "user",
messageID: "performers"
},
{
href: "/studios",
icon: "video",
messageID: "studios"
},
{
href: "/tags",
icon: "tag",
messageID: "tags"
}
];
export const MainNavbar: React.FC = () => {
const location = useLocation();
const path =
location.pathname === "/performers"
? "/performers/new"
: location.pathname === "/studios"
? "/studios/new"
: null;
const newButton =
path === null ? (
""
) : (
<LinkContainer to={path}>
<Button variant="primary">
<FormattedMessage id="new" defaultMessage="New" />
</Button>
</LinkContainer>
);
return (
<Navbar
collapseOnSelect
fixed="top"
variant="dark"
bg="dark"
className="top-nav"
expand="sm"
>
<Navbar.Brand as="div" className="order-1 order-sm-0">
<Link to="/">
<Button className="minimal brand-link d-none d-sm-inline-block">
Stash
</Button>
<Button className="minimal brand-icon d-inline d-sm-none">
<img src="favicon.ico" alt="" />
</Button>
</Link>
</Navbar.Brand>
<Navbar.Toggle className="order-0" />
<Navbar.Collapse className="order-3 order-sm-1">
<Nav className="mr-md-auto">
{menuItems.map(i => (
<Nav.Link eventKey={i.href} as="div" key={i.href}>
<LinkContainer
activeClassName="active"
exact
to={i.href}
key={i.href}
>
<Button className="minimal w-100">
<Icon icon={i.icon} />
<span>
<FormattedMessage id={i.messageID} />
</span>
</Button>
</LinkContainer>
</Nav.Link>
))}
</Nav>
</Navbar.Collapse>
<Nav className="order-2">
<div className="d-none d-sm-block">{newButton}</div>
<LinkContainer exact to="/settings">
<Button className="minimal">
<Icon icon="cog" />
</Button>
</LinkContainer>
</Nav>
</Navbar>
);
};

View File

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

View File

@@ -0,0 +1,46 @@
import React from "react";
import { Card } from "react-bootstrap";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { NavUtils, TextUtils } from "src/utils";
interface IPerformerCardProps {
performer: GQL.PerformerDataFragment;
ageFromDate?: string;
}
export const PerformerCard: React.FC<IPerformerCardProps> = ({
performer,
ageFromDate
}) => {
const age = TextUtils.age(performer.birthdate, ageFromDate);
const ageString = `${age} years old${ageFromDate ? " in this scene." : "."}`;
function maybeRenderFavoriteBanner() {
if (performer.favorite === false) {
return;
}
return <div className="rating-banner rating-5">FAVORITE</div>;
}
return (
<Card className="performer-card">
<Link to={`/performers/${performer.id}`}>
<img
className="image-thumbnail card-image"
alt={performer.name ?? ""}
src={performer.image_path ?? ""}
/>
{maybeRenderFavoriteBanner()}
</Link>
<div className="card-section">
<h5 className="text-truncate">{performer.name}</h5>
{age !== 0 ? <div className="text-muted">{ageString}</div> : ""}
<div className="text-muted">
Stars in {performer.scene_count}{" "}
<Link to={NavUtils.makePerformerScenesUrl(performer)}>scenes</Link>.
</div>
</div>
</Card>
);
};

View File

@@ -0,0 +1,273 @@
/* eslint-disable react/no-this-in-sfc */
import React, { useEffect, useState } from "react";
import { Button, Tabs, Tab } from "react-bootstrap";
import { useParams, useHistory } from "react-router-dom";
import cx from "classnames";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { Icon, LoadingIndicator } from "src/components/Shared";
import { useToast } from "src/hooks";
import { TextUtils } from "src/utils";
import Lightbox from "react-images";
import { PerformerDetailsPanel } from "./PerformerDetailsPanel";
import { PerformerOperationsPanel } from "./PerformerOperationsPanel";
import { PerformerScenesPanel } from "./PerformerScenesPanel";
export const Performer: React.FC = () => {
const Toast = useToast();
const history = useHistory();
const { id = "new" } = useParams();
const isNew = id === "new";
// Performer state
const [performer, setPerformer] = useState<
Partial<GQL.PerformerDataFragment>
>({});
const [imagePreview, setImagePreview] = useState<string>();
const [lightboxIsOpen, setLightboxIsOpen] = useState(false);
// Network state
const [isLoading, setIsLoading] = useState(false);
const { data, error } = StashService.useFindPerformer(id);
const [updatePerformer] = StashService.usePerformerUpdate();
const [createPerformer] = StashService.usePerformerCreate();
const [deletePerformer] = StashService.usePerformerDestroy();
useEffect(() => {
setIsLoading(false);
if (data?.findPerformer) setPerformer(data.findPerformer);
}, [data]);
useEffect(() => {
setImagePreview(performer.image_path ?? undefined);
}, [performer]);
function onImageChange(image: string) {
setImagePreview(image);
}
if ((!isNew && (!data || !data.findPerformer)) || isLoading)
return <LoadingIndicator />;
if (error) return <div>{error.message}</div>;
async function onSave(
performerInput:
| Partial<GQL.PerformerCreateInput>
| Partial<GQL.PerformerUpdateInput>
) {
setIsLoading(true);
try {
if (!isNew) {
const result = await updatePerformer({
variables: performerInput as GQL.PerformerUpdateInput
});
if (result.data?.performerUpdate)
setPerformer(result.data?.performerUpdate);
} else {
const result = await createPerformer({
variables: performerInput as GQL.PerformerCreateInput
});
if (result.data?.performerCreate) {
setPerformer(result.data.performerCreate);
history.push(`/performers/${result.data.performerCreate.id}`);
}
}
} catch (e) {
Toast.error(e);
}
setIsLoading(false);
}
async function onDelete() {
setIsLoading(true);
try {
await deletePerformer({ variables: { id } });
} catch (e) {
Toast.error(e);
}
setIsLoading(false);
// redirect to performers page
history.push("/performers");
}
function renderTabs() {
function renderEditPanel() {
return (
<PerformerDetailsPanel
performer={performer}
isEditing
isNew={isNew}
onDelete={onDelete}
onSave={onSave}
onImageChange={onImageChange}
/>
);
}
// render tabs if not new
if (!isNew) {
return (
<Tabs defaultActiveKey="details" id="performer-details">
<Tab eventKey="details" title="Details">
<PerformerDetailsPanel performer={performer} isEditing={false} />
</Tab>
<Tab eventKey="scenes" title="Scenes">
<PerformerScenesPanel performer={performer} />
</Tab>
<Tab eventKey="edit" title="Edit">
{renderEditPanel()}
</Tab>
<Tab eventKey="operations" title="Operations">
<PerformerOperationsPanel performer={performer} />
</Tab>
</Tabs>
);
}
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);
}
const renderIcons = () => (
<span className="name-icons d-block d-sm-inline">
<Button
className={cx(
"minimal",
performer.favorite ? "favorite" : "not-favorite"
)}
onClick={() => setFavorite(!performer.favorite)}
>
<Icon icon="heart" />
</Button>
{performer.url && (
<Button className="minimal">
<a
href={performer.url}
className="link"
target="_blank"
rel="noopener noreferrer"
>
<Icon icon="link" />
</a>
</Button>
)}
{performer.twitter && (
<Button className="minimal">
<a
href={`https://www.twitter.com/${performer.twitter}`}
className="twitter"
target="_blank"
rel="noopener noreferrer"
>
<Icon icon="dove" />
</a>
</Button>
)}
{performer.instagram && (
<Button className="minimal">
<a
href={`https://www.instagram.com/${performer.instagram}`}
className="instagram"
target="_blank"
rel="noopener noreferrer"
>
<Icon icon="camera" />
</a>
</Button>
)}
</span>
);
function renderNewView() {
return (
<div className="row new-view">
<div className="col-4">
<img className="photo" src={imagePreview} alt="Performer" />
</div>
<div className="col-6">
<h2>Create Performer</h2>
{renderTabs()}
</div>
</div>
);
}
const photos = [{ src: imagePreview || "", caption: "Image" }];
if (isNew) {
return renderNewView();
}
return (
<div id="performer-page" className="row">
<div className="image-container col-sm-4 offset-sm-1 d-none d-sm-block">
<Button variant="link" onClick={() => setLightboxIsOpen(true)}>
<img className="performer" src={imagePreview} alt="Performer" />
</Button>
</div>
<div className="col col-sm-6">
<div className="row">
<div className="image-container col-6 d-block d-sm-none">
<Button variant="link" onClick={() => setLightboxIsOpen(true)}>
<img className="performer" src={imagePreview} alt="Performer" />
</Button>
</div>
<div className="performer-head col-6 col-sm-12">
<h2>
{performer.name}
{renderIcons()}
</h2>
{maybeRenderAliases()}
{maybeRenderAge()}
</div>
</div>
<div className="performer-body">
<div className="performer-tabs">{renderTabs()}</div>
</div>
</div>
<Lightbox
images={photos}
onClose={() => setLightboxIsOpen(false)}
currentImage={0}
isOpen={lightboxIsOpen}
onClickImage={() => window.open(imagePreview, "_blank")}
width={9999}
/>
</div>
);
};

View File

@@ -0,0 +1,466 @@
/* eslint-disable react/no-this-in-sfc */
import React, { useEffect, useState } from "react";
import { Button, Form, Popover, OverlayTrigger, Table } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import {
Icon,
Modal,
ImageInput,
ScrapePerformerSuggest,
LoadingIndicator
} from "src/components/Shared";
import { ImageUtils, TableUtils } from "src/utils";
import { useToast } from "src/hooks";
interface IPerformerDetails {
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: React.FC<IPerformerDetails> = ({
performer,
isNew,
isEditing,
onSave,
onDelete,
onImageChange
}) => {
const Toast = useToast();
// Editing state
const [isDisplayingScraperDialog, setIsDisplayingScraperDialog] = useState<
GQL.Scraper
>();
const [scrapePerformerDetails, setScrapePerformerDetails] = useState<
GQL.ScrapedPerformerDataFragment
>();
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Editing performer state
const [image, setImage] = useState<string>();
const [name, setName] = useState<string>();
const [aliases, setAliases] = useState<string>();
const [favorite, setFavorite] = useState<boolean>();
const [birthdate, setBirthdate] = useState<string>();
const [ethnicity, setEthnicity] = useState<string>();
const [country, setCountry] = useState<string>();
const [eyeColor, setEyeColor] = useState<string>();
const [height, setHeight] = useState<string>();
const [measurements, setMeasurements] = useState<string>();
const [fakeTits, setFakeTits] = useState<string>();
const [careerLength, setCareerLength] = useState<string>();
const [tattoos, setTattoos] = useState<string>();
const [piercings, setPiercings] = useState<string>();
const [url, setUrl] = useState<string>();
const [twitter, setTwitter] = useState<string>();
const [instagram, setInstagram] = useState<string>();
// Network state
const [isLoading, setIsLoading] = useState(false);
const Scrapers = StashService.useListPerformerScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
function updatePerformerEditState(
state: Partial<GQL.PerformerDataFragment | GQL.ScrapedPerformer>
) {
if ((state as GQL.PerformerDataFragment).favorite !== undefined) {
setFavorite((state as GQL.PerformerDataFragment).favorite);
}
setName(state.name ?? undefined);
setAliases(state.aliases ?? undefined);
setBirthdate(state.birthdate ?? undefined);
setEthnicity(state.ethnicity ?? undefined);
setCountry(state.country ?? undefined);
setEyeColor(state.eye_color ?? undefined);
setHeight(state.height ?? undefined);
setMeasurements(state.measurements ?? undefined);
setFakeTits(state.fake_tits ?? undefined);
setCareerLength(state.career_length ?? undefined);
setTattoos(state.tattoos ?? undefined);
setPiercings(state.piercings ?? undefined);
setUrl(state.url ?? undefined);
setTwitter(state.twitter ?? undefined);
setInstagram(state.instagram ?? undefined);
}
useEffect(() => {
setImage(undefined);
updatePerformerEditState(performer);
}, [performer]);
function onImageLoad(this: FileReader) {
setImage(this.result as string);
if (onImageChange) {
onImageChange(this.result as string);
}
}
if (isEditing) ImageUtils.usePasteImage(onImageLoad);
useEffect(() => {
const newQueryableScrapers = (
Scrapers?.data?.listPerformerScrapers ?? []
).filter(s => s.performer?.supported_scrapes.includes(GQL.ScrapeType.Name));
setQueryableScrapers(newQueryableScrapers);
}, [Scrapers]);
if (isLoading) return <LoadingIndicator />;
function getPerformerInput() {
const performerInput: Partial<
GQL.PerformerCreateInput | GQL.PerformerUpdateInput
> = {
name,
aliases,
favorite,
birthdate,
ethnicity,
country,
eye_color: eyeColor,
height,
measurements,
fake_tits: fakeTits,
career_length: careerLength,
tattoos,
piercings,
url,
twitter,
instagram,
image
};
if (!isNew) {
(performerInput as GQL.PerformerUpdateInput).id = performer.id!;
}
return performerInput;
}
function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
}
function onDisplayFreeOnesDialog(scraper: GQL.Scraper) {
setIsDisplayingScraperDialog(scraper);
}
function getQueryScraperPerformerInput() {
if (!scrapePerformerDetails) return {};
const { __typename, ...ret } = scrapePerformerDetails;
return ret;
}
async function onScrapePerformer() {
setIsDisplayingScraperDialog(undefined);
try {
if (!scrapePerformerDetails || !isDisplayingScraperDialog) return;
setIsLoading(true);
const result = await StashService.queryScrapePerformer(
isDisplayingScraperDialog.id,
getQueryScraperPerformerInput()
);
if (!result?.data?.scrapePerformer) return;
updatePerformerEditState(result.data.scrapePerformer);
} catch (e) {
Toast.error(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;
}
updatePerformerEditState(result.data.scrapePerformerURL);
} catch (e) {
Toast.error(e);
} finally {
setIsLoading(false);
}
}
function renderEthnicity() {
return TableUtils.renderHtmlSelect({
title: "Ethnicity",
value: ethnicity,
isEditing: !!isEditing,
onChange: (value: string) => setEthnicity(value),
selectOptions: ["white", "black", "asian", "hispanic"]
});
}
function renderScraperMenu() {
if (!performer || !isEditing) {
return;
}
const popover = (
<Popover id="scraper-popover">
<Popover.Content>
<div>
{queryableScrapers
? queryableScrapers.map(s => (
<div key={s.name}>
<Button
key={s.name}
className="minimal"
onClick={() => onDisplayFreeOnesDialog(s)}
>
{s.name}
</Button>
</div>
))
: ""}
</div>
</Popover.Content>
</Popover>
);
return (
<OverlayTrigger trigger="click" placement="top" overlay={popover}>
<Button variant="secondary">Scrape with...</Button>
</OverlayTrigger>
);
}
function renderScraperDialog() {
return (
<Modal
show={!!isDisplayingScraperDialog}
onHide={() => setIsDisplayingScraperDialog(undefined)}
header="Scrape"
accept={{ onClick: onScrapePerformer, text: "Scrape" }}
>
<div className="dialog-content">
<ScrapePerformerSuggest
placeholder="Performer name"
scraperId={
isDisplayingScraperDialog ? isDisplayingScraperDialog.id : ""
}
onSelectPerformer={query => setScrapePerformerDetails(query)}
/>
</div>
</Modal>
);
}
function urlScrapable(scrapedUrl: string) {
return (
!!scrapedUrl &&
(Scrapers?.data?.listPerformerScrapers ?? []).some(s =>
(s?.performer?.urls ?? []).some(u => scrapedUrl.includes(u))
)
);
}
function maybeRenderScrapeButton() {
if (!url || !isEditing || !urlScrapable(url)) {
return undefined;
}
return (
<Button
className="minimal scrape-url-button"
onClick={() => onScrapePerformerURL()}
>
<Icon icon="file-upload" />
</Button>
);
}
function renderURLField() {
return (
<tr>
<td id="url-field">
URL
{maybeRenderScrapeButton()}
</td>
<td>
<Form.Control
value={url ?? ""}
readOnly={!isEditing}
plaintext={!isEditing}
placeholder="URL"
onChange={(event: React.FormEvent<HTMLInputElement>) =>
setUrl(event.currentTarget.value)
}
/>
</td>
</tr>
);
}
function maybeRenderButtons() {
if (isEditing) {
return (
<div className="row">
<Button
className="edit-button"
variant="primary"
onClick={() => onSave?.(getPerformerInput())}
>
Save
</Button>
{!isNew ? (
<Button
className="edit-button"
variant="danger"
onClick={() => setIsDeleteAlertOpen(true)}
>
Delete
</Button>
) : (
""
)}
{renderScraperMenu()}
<ImageInput
isEditing={!!isEditing}
onImageChange={onImageChangeHandler}
/>
</div>
);
}
}
function renderDeleteAlert() {
return (
<Modal
show={isDeleteAlertOpen}
icon="trash-alt"
accept={{ text: "Delete", variant: "danger", onClick: onDelete }}
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
>
<p>Are you sure you want to delete {name}?</p>
</Modal>
);
}
function maybeRenderName() {
if (isEditing) {
return TableUtils.renderInputGroup({
title: "Name",
value: name,
isEditing: !!isEditing,
placeholder: "Name",
onChange: setName
});
}
}
function maybeRenderAliases() {
if (isEditing) {
return TableUtils.renderInputGroup({
title: "Aliases",
value: aliases,
isEditing: !!isEditing,
placeholder: "Aliases",
onChange: setAliases
});
}
}
return (
<>
{renderDeleteAlert()}
{renderScraperDialog()}
<Table id="performer-details" className="w-100">
<tbody>
{maybeRenderName()}
{maybeRenderAliases()}
{TableUtils.renderInputGroup({
title: "Birthdate",
value: birthdate,
isEditing: !!isEditing,
onChange: setBirthdate
})}
{renderEthnicity()}
{TableUtils.renderInputGroup({
title: "Eye Color",
value: eyeColor,
isEditing: !!isEditing,
onChange: setEyeColor
})}
{TableUtils.renderInputGroup({
title: "Country",
value: country,
isEditing: !!isEditing,
onChange: setCountry
})}
{TableUtils.renderInputGroup({
title: "Height (cm)",
value: height,
isEditing: !!isEditing,
onChange: setHeight
})}
{TableUtils.renderInputGroup({
title: "Measurements",
value: measurements,
isEditing: !!isEditing,
onChange: setMeasurements
})}
{TableUtils.renderInputGroup({
title: "Fake Tits",
value: fakeTits,
isEditing: !!isEditing,
onChange: setFakeTits
})}
{TableUtils.renderInputGroup({
title: "Career Length",
value: careerLength,
isEditing: !!isEditing,
onChange: setCareerLength
})}
{TableUtils.renderInputGroup({
title: "Tattoos",
value: tattoos,
isEditing: !!isEditing,
onChange: setTattoos
})}
{TableUtils.renderInputGroup({
title: "Piercings",
value: piercings,
isEditing: !!isEditing,
onChange: setPiercings
})}
{renderURLField()}
{TableUtils.renderInputGroup({
title: "Twitter",
value: twitter,
isEditing: !!isEditing,
onChange: setTwitter
})}
{TableUtils.renderInputGroup({
title: "Instagram",
value: instagram,
isEditing: !!isEditing,
onChange: setInstagram
})}
</tbody>
</Table>
{maybeRenderButtons()}
</>
);
};

View File

@@ -0,0 +1,29 @@
import { Button } from "react-bootstrap";
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { useToast } from "src/hooks";
interface IPerformerOperationsProps {
performer: Partial<GQL.PerformerDataFragment>;
}
export const PerformerOperationsPanel: React.FC<IPerformerOperationsProps> = ({
performer
}) => {
const Toast = useToast();
async function onAutoTag() {
if (!performer?.id) {
return;
}
try {
await StashService.queryMetadataAutoTag({ performers: [performer.id] });
Toast.success({ content: "Started auto tagging" });
} catch (e) {
Toast.error(e);
}
}
return <Button onClick={onAutoTag}>Auto Tag</Button>;
};

View File

@@ -0,0 +1,47 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
import { ListFilterModel } from "src/models/list-filter/filter";
import { SceneList } from "src/components/Scenes/SceneList";
interface IPerformerDetailsProps {
performer: Partial<GQL.PerformerDataFragment>;
}
export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> = ({
performer
}) => {
function filterHook(filter: ListFilterModel) {
const performerValue = { id: performer.id!, label: performer.name! };
// if performers is already present, then we modify it, otherwise add
let performerCriterion = filter.criteria.find(c => {
return c.type === "performers";
}) as PerformersCriterion;
if (
performerCriterion &&
(performerCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
performerCriterion.modifier === GQL.CriterionModifier.Includes)
) {
// add the performer if not present
if (
!performerCriterion.value.find(p => {
return p.id === 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 subComponent filterHook={filterHook} />;
};

View File

@@ -0,0 +1,75 @@
import _ from "lodash";
import React from "react";
import { useHistory } from "react-router-dom";
import { FindPerformersQueryResult } from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { usePerformersList } from "src/hooks";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { PerformerCard } from "./PerformerCard";
import { PerformerListTable } from "./PerformerListTable";
export const PerformerList: React.FC = () => {
const history = useHistory();
const otherOperations = [
{
text: "Open Random",
onClick: getRandom
}
];
const listData = usePerformersList({
otherOperations,
renderContent
});
async function getRandom(
result: FindPerformersQueryResult,
filter: ListFilterModel
) {
if (result.data?.findPerformers) {
const { count } = result.data.findPerformers;
const index = Math.floor(Math.random() * count);
const 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
) {
const { id } = singleResult!.data!.findPerformers!.performers[0]!;
history.push(`/performers/${id}`);
}
}
}
function renderContent(
result: FindPerformersQueryResult,
filter: ListFilterModel
) {
if (!result.data?.findPerformers) {
return;
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="row justify-content-center">
{result.data.findPerformers.performers.map(p => (
<PerformerCard key={p.id} performer={p} />
))}
</div>
);
}
if (filter.displayMode === DisplayMode.List) {
return (
<PerformerListTable
performers={result.data.findPerformers.performers}
/>
);
}
}
return listData.template;
};

View File

@@ -0,0 +1,69 @@
/* eslint-disable jsx-a11y/control-has-associated-label */
import React from "react";
import { Button, Table } from "react-bootstrap";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { Icon } from "src/components/Shared";
import { NavUtils } from "src/utils";
interface IPerformerListTableProps {
performers: GQL.PerformerDataFragment[];
}
export const PerformerListTable: React.FC<IPerformerListTableProps> = (
props: IPerformerListTableProps
) => {
const renderPerformerRow = (performer: GQL.PerformerDataFragment) => (
<tr key={performer.id}>
<td>
<Link to={`/performers/${performer.id}`}>
<img
className="image-thumbnail"
alt={performer.name ?? ""}
src={performer.image_path ?? ""}
/>
</Link>
</td>
<td className="text-left">
<Link to={`/performers/${performer.id}`}>
<h5 className="text-truncate">{performer.name}</h5>
</Link>
</td>
<td>{performer.aliases ? performer.aliases : ""}</td>
<td>
{performer.favorite && (
<Button disabled className="favorite">
<Icon icon="heart" />
</Button>
)}
</td>
<td>
<Link to={NavUtils.makePerformerScenesUrl(performer)}>
<h6>{performer.scene_count}</h6>
</Link>
</td>
<td>{performer.birthdate}</td>
<td>{performer.height}</td>
</tr>
);
return (
<div className="row justify-content-center table-list">
<Table bordered striped>
<thead>
<tr>
<th />
<th>Name</th>
<th>Aliases</th>
<th>Favourite</th>
<th>Scene Count</th>
<th>Birthdate</th>
<th>Height</th>
</tr>
</thead>
<tbody>{props.performers.map(renderPerformerRow)}</tbody>
</Table>
</div>
);
};

View File

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

View File

@@ -0,0 +1,67 @@
#performer-details {
.scrape-url-button {
color: $text-color;
float: right;
margin-right: .5rem;
}
}
#url-field {
line-height: 30px;
}
#performer-page {
flex-direction: row;
margin: 10px auto;
overflow: hidden;
.image-container .performer {
max-height: 960px;
max-width: 100%;
}
.performer-head {
display: inline-block;
margin-bottom: 2rem;
vertical-align: top;
.name-icons {
margin-left: 10px;
.not-favorite {
color: rgba(191, 204, 214, .5);
}
.favorite {
color: #ff7373;
}
.link {
color: rgb(191, 204, 214);
}
.instagram {
color: pink;
}
}
}
.alias {
font-weight: bold;
}
}
.new-view {
margin-bottom: 2rem;
.photo {
padding: 1rem 1rem 1rem 2rem;
width: 100%;
}
}
.card {
&.performer-card {
padding: 0 0 1rem 0;
}
}

View File

@@ -0,0 +1,66 @@
export class ParserField {
public field: string;
public helperText?: string;
constructor(field: string, helperText?: string) {
this.field = field;
this.helperText = helperText;
}
public getFieldPattern() {
return `{${this.field}}`;
}
static Title = new ParserField("title");
static Ext = new ParserField("ext", "File extension");
static I = new ParserField("i", "Matches any ignored word");
static D = new ParserField("d", "Matches any delimiter (.-_)");
static Performer = new ParserField("performer");
static Studio = new ParserField("studio");
static Tag = new ParserField("tag");
// date fields
static Date = new ParserField("date", "YYYY-MM-DD");
static YYYY = new ParserField("yyyy", "Year");
static YY = new ParserField("yy", "Year (20YY)");
static MM = new ParserField("mm", "Two digit month");
static DD = new ParserField("dd", "Two digit date");
static YYYYMMDD = new ParserField("yyyymmdd");
static YYMMDD = new ParserField("yymmdd");
static DDMMYYYY = new ParserField("ddmmyyyy");
static DDMMYY = new ParserField("ddmmyy");
static MMDDYYYY = new ParserField("mmddyyyy");
static MMDDYY = new ParserField("mmddyy");
static validFields = [
ParserField.Title,
ParserField.Ext,
ParserField.D,
ParserField.I,
ParserField.Performer,
ParserField.Studio,
ParserField.Tag,
ParserField.Date,
ParserField.YYYY,
ParserField.YY,
ParserField.MM,
ParserField.DD,
ParserField.YYYYMMDD,
ParserField.YYMMDD,
ParserField.DDMMYYYY,
ParserField.DDMMYY,
ParserField.MMDDYYYY,
ParserField.MMDDYY
];
static fullDateFields = [
ParserField.YYYYMMDD,
ParserField.YYMMDD,
ParserField.DDMMYYYY,
ParserField.DDMMYY,
ParserField.MMDDYYYY,
ParserField.MMDDYY
];
}

View File

@@ -0,0 +1,251 @@
import React, { useState } from "react";
import {
Button,
Dropdown,
DropdownButton,
Form,
InputGroup
} from "react-bootstrap";
import { ParserField } from "./ParserField";
import { ShowFields } from "./ShowFields";
const builtInRecipes = [
{
pattern: "{title}",
ignoreWords: [],
whitespaceCharacters: "",
capitalizeTitle: false,
description: "Filename"
},
{
pattern: "{title}.{ext}",
ignoreWords: [],
whitespaceCharacters: "",
capitalizeTitle: false,
description: "Without extension"
},
{
pattern: "{}.{yy}.{mm}.{dd}.{title}.XXX.{}.{ext}",
ignoreWords: [],
whitespaceCharacters: ".",
capitalizeTitle: true,
description: ""
},
{
pattern: "{}.{yy}.{mm}.{dd}.{title}.{ext}",
ignoreWords: [],
whitespaceCharacters: ".",
capitalizeTitle: true,
description: ""
},
{
pattern: "{title}.XXX.{}.{ext}",
ignoreWords: [],
whitespaceCharacters: ".",
capitalizeTitle: true,
description: ""
},
{
pattern: "{}.{yy}.{mm}.{dd}.{title}.{i}.{ext}",
ignoreWords: ["cz", "fr"],
whitespaceCharacters: ".",
capitalizeTitle: true,
description: "Foreign language"
}
];
export interface IParserInput {
pattern: string;
ignoreWords: string[];
whitespaceCharacters: string;
capitalizeTitle: boolean;
page: number;
pageSize: number;
findClicked: boolean;
}
interface IParserRecipe {
pattern: string;
ignoreWords: string[];
whitespaceCharacters: string;
capitalizeTitle: boolean;
description: string;
}
interface IParserInputProps {
input: IParserInput;
onFind: (input: IParserInput) => void;
onPageSizeChanged: (newSize: number) => void;
showFields: Map<string, boolean>;
setShowFields: (fields: Map<string, boolean>) => void;
}
export const ParserInput: React.FC<IParserInputProps> = (
props: IParserInputProps
) => {
const [pattern, setPattern] = useState<string>(props.input.pattern);
const [ignoreWords, setIgnoreWords] = useState<string>(
props.input.ignoreWords.join(" ")
);
const [whitespaceCharacters, setWhitespaceCharacters] = useState<string>(
props.input.whitespaceCharacters
);
const [capitalizeTitle, setCapitalizeTitle] = useState<boolean>(
props.input.capitalizeTitle
);
function onFind() {
props.onFind({
pattern,
ignoreWords: ignoreWords.split(" "),
whitespaceCharacters,
capitalizeTitle,
page: 1,
pageSize: props.input.pageSize,
findClicked: props.input.findClicked
});
}
function setParserRecipe(recipe: IParserRecipe) {
setPattern(recipe.pattern);
setIgnoreWords(recipe.ignoreWords.join(" "));
setWhitespaceCharacters(recipe.whitespaceCharacters);
setCapitalizeTitle(recipe.capitalizeTitle);
}
const validFields = [new ParserField("", "Wildcard")].concat(
ParserField.validFields
);
function addParserField(field: ParserField) {
setPattern(pattern + field.getFieldPattern());
}
const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120"];
return (
<Form.Group>
<Form.Group className="row">
<Form.Label htmlFor="filename-pattern" className="col-2">
Filename Pattern
</Form.Label>
<InputGroup className="col-8">
<Form.Control
id="filename-pattern"
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setPattern(e.currentTarget.value)
}
value={pattern}
/>
<InputGroup.Append>
<DropdownButton id="parser-field-select" title="Add Field">
{validFields.map(item => (
<Dropdown.Item
key={item.field}
onSelect={() => addParserField(item)}
>
<span>{item.field}</span>
<span className="ml-auto">{item.helperText}</span>
</Dropdown.Item>
))}
</DropdownButton>
</InputGroup.Append>
</InputGroup>
<Form.Text className="text-muted row col-10 offset-2">
Use &apos;\&apos; to escape literal {} characters
</Form.Text>
</Form.Group>
<Form.Group className="row" controlId="ignored-words">
<Form.Label className="col-2">Ignored words</Form.Label>
<InputGroup className="col-8">
<Form.Control
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setIgnoreWords(e.currentTarget.value)
}
value={ignoreWords}
/>
</InputGroup>
<Form.Text className="text-muted col-10 offset-2">
Matches with {"{i}"}
</Form.Text>
</Form.Group>
<h5>Title</h5>
<Form.Group className="row">
<Form.Label htmlFor="whitespace-characters" className="col-2">
Whitespace characters:
</Form.Label>
<InputGroup className="col-8">
<Form.Control
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setWhitespaceCharacters(e.currentTarget.value)
}
value={whitespaceCharacters}
/>
</InputGroup>
<Form.Text className="text-muted col-10 offset-2">
These characters will be replaced with whitespace in the title
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Check
inline
className="m-0"
id="capitalize-title"
checked={capitalizeTitle}
onChange={() => setCapitalizeTitle(!capitalizeTitle)}
/>
<Form.Label htmlFor="capitalize-title">Capitalize title</Form.Label>
</Form.Group>
{/* TODO - mapping stuff will go here */}
<Form.Group>
<DropdownButton
variant="secondary"
id="recipe-select"
title="Select Parser Recipe"
>
{builtInRecipes.map(item => (
<Dropdown.Item
key={item.pattern}
onSelect={() => setParserRecipe(item)}
>
<span>{item.pattern}</span>
<span className="mr-auto">{item.description}</span>
</Dropdown.Item>
))}
</DropdownButton>
</Form.Group>
<Form.Group>
<ShowFields
fields={props.showFields}
onShowFieldsChanged={fields => props.setShowFields(fields)}
/>
</Form.Group>
<Form.Group className="row">
<Button variant="secondary" className="ml-3 col-1" onClick={onFind}>
Find
</Button>
<Form.Control
as="select"
options={PAGE_SIZE_OPTIONS}
onChange={(e: React.FormEvent<HTMLInputElement>) =>
props.onPageSizeChanged(parseInt(e.currentTarget.value, 10))
}
defaultValue={props.input.pageSize}
className="col-1 filter-item"
>
{PAGE_SIZE_OPTIONS.map(val => (
<option key={val} value={val}>
{val}
</option>
))}
</Form.Control>
</Form.Group>
</Form.Group>
);
};

View File

@@ -0,0 +1,347 @@
/* eslint-disable no-param-reassign, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
import React, { useEffect, useState, useCallback } from "react";
import { Button, Card, Form, Table } from "react-bootstrap";
import _ from "lodash";
import { StashService } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { LoadingIndicator } from "src/components/Shared";
import { useToast } from "src/hooks";
import { Pagination } from "src/components/List/Pagination";
import { IParserInput, ParserInput } from "./ParserInput";
import { ParserField } from "./ParserField";
import { SceneParserResult, SceneParserRow } from "./SceneParserRow";
const initialParserInput = {
pattern: "{title}.{ext}",
ignoreWords: [],
whitespaceCharacters: "._",
capitalizeTitle: true,
page: 1,
pageSize: 20,
findClicked: false
};
const initialShowFieldsState = new Map<string, boolean>([
["Title", true],
["Date", true],
["Performers", true],
["Tags", true],
["Studio", true]
]);
export const SceneFilenameParser: React.FC = () => {
const Toast = useToast();
const [parserResult, setParserResult] = useState<SceneParserResult[]>([]);
const [parserInput, setParserInput] = useState<IParserInput>(
initialParserInput
);
const [allTitleSet, setAllTitleSet] = useState<boolean>(false);
const [allDateSet, setAllDateSet] = useState<boolean>(false);
const [allPerformerSet, setAllPerformerSet] = useState<boolean>(false);
const [allTagSet, setAllTagSet] = useState<boolean>(false);
const [allStudioSet, setAllStudioSet] = useState<boolean>(false);
const [showFields, setShowFields] = useState<Map<string, boolean>>(
initialShowFieldsState
);
const [totalItems, setTotalItems] = useState<number>(0);
// Network state
const [isLoading, setIsLoading] = useState(false);
const [updateScenes] = StashService.useScenesUpdate(getScenesUpdateData());
const determineFieldsToHide = useCallback(() => {
const { pattern } = parserInput;
const titleSet = pattern.includes("{title}");
const dateSet =
pattern.includes("{date}") ||
pattern.includes("{dd}") || // don't worry about other partial date fields since this should be implied
ParserField.fullDateFields.some(f => {
return pattern.includes(`{${f.field}}`);
});
const performerSet = pattern.includes("{performer}");
const tagSet = pattern.includes("{tag}");
const studioSet = pattern.includes("{studio}");
const newShowFields = new Map<string, boolean>([
["Title", titleSet],
["Date", dateSet],
["Performers", performerSet],
["Tags", tagSet],
["Studio", studioSet]
]);
setShowFields(newShowFields);
}, [parserInput]);
const parseResults = useCallback(
(
results: GQL.ParseSceneFilenamesQuery["parseSceneFilenames"]["results"]
) => {
if (results) {
const result = results
.map(r => {
return new SceneParserResult(r);
})
.filter(r => !!r) as SceneParserResult[];
setParserResult(result);
determineFieldsToHide();
}
},
[determineFieldsToHide]
);
useEffect(() => {
if (parserInput.findClicked) {
setParserResult([]);
setIsLoading(true);
const parserFilter = {
q: parserInput.pattern,
page: parserInput.page,
per_page: parserInput.pageSize,
sort: "path",
direction: GQL.SortDirectionEnum.Asc
};
const parserInputData = {
ignoreWords: parserInput.ignoreWords,
whitespaceCharacters: parserInput.whitespaceCharacters,
capitalizeTitle: parserInput.capitalizeTitle
};
StashService.queryParseSceneFilenames(parserFilter, parserInputData)
.then(response => {
const result = response.data.parseSceneFilenames;
if (result) {
parseResults(result.results);
setTotalItems(result.count);
}
})
.catch(err => Toast.error(err))
.finally(() => setIsLoading(false));
}
}, [parserInput, parseResults, Toast]);
function onPageSizeChanged(newSize: number) {
const newInput = _.clone(parserInput);
newInput.page = 1;
newInput.pageSize = newSize;
setParserInput(newInput);
}
function onPageChanged(newPage: number) {
if (newPage !== parserInput.page) {
const newInput = _.clone(parserInput);
newInput.page = newPage;
setParserInput(newInput);
}
}
function onFindClicked(input: IParserInput) {
input.page = 1;
input.findClicked = true;
setParserInput(input);
setTotalItems(0);
}
function getScenesUpdateData() {
return parserResult
.filter(result => result.isChanged())
.map(result => result.toSceneUpdateInput());
}
async function onApply() {
setIsLoading(true);
try {
await updateScenes();
Toast.success({ content: "Updated scenes" });
} catch (e) {
Toast.error(e);
}
setIsLoading(false);
}
useEffect(() => {
const newAllTitleSet = !parserResult.some(r => {
return !r.title.isSet;
});
const newAllDateSet = !parserResult.some(r => {
return !r.date.isSet;
});
const newAllPerformerSet = !parserResult.some(r => {
return !r.performers.isSet;
});
const newAllTagSet = !parserResult.some(r => {
return !r.tags.isSet;
});
const newAllStudioSet = !parserResult.some(r => {
return !r.studio.isSet;
});
setAllTitleSet(newAllTitleSet);
setAllDateSet(newAllDateSet);
setAllTagSet(newAllPerformerSet);
setAllTagSet(newAllTagSet);
setAllStudioSet(newAllStudioSet);
}, [parserResult]);
function onSelectAllTitleSet(selected: boolean) {
const newResult = [...parserResult];
newResult.forEach(r => {
r.title.isSet = selected;
});
setParserResult(newResult);
setAllTitleSet(selected);
}
function onSelectAllDateSet(selected: boolean) {
const newResult = [...parserResult];
newResult.forEach(r => {
r.date.isSet = selected;
});
setParserResult(newResult);
setAllDateSet(selected);
}
function onSelectAllPerformerSet(selected: boolean) {
const newResult = [...parserResult];
newResult.forEach(r => {
r.performers.isSet = selected;
});
setParserResult(newResult);
setAllPerformerSet(selected);
}
function onSelectAllTagSet(selected: boolean) {
const newResult = [...parserResult];
newResult.forEach(r => {
r.tags.isSet = selected;
});
setParserResult(newResult);
setAllTagSet(selected);
}
function onSelectAllStudioSet(selected: boolean) {
const newResult = [...parserResult];
newResult.forEach(r => {
r.studio.isSet = selected;
});
setParserResult(newResult);
setAllStudioSet(selected);
}
function onChange(scene: SceneParserResult, changedScene: SceneParserResult) {
const newResult = [...parserResult];
const index = newResult.indexOf(scene);
newResult[index] = changedScene;
setParserResult(newResult);
}
function renderHeader(
fieldName: string,
allSet: boolean,
onAllSet: (set: boolean) => void
) {
if (!showFields.get(fieldName)) {
return null;
}
return (
<>
<th className="w-15">
<Form.Check
checked={allSet}
onChange={() => {
onAllSet(!allSet);
}}
/>
</th>
<th>{fieldName}</th>
</>
);
}
function renderTable() {
if (parserResult.length === 0) {
return undefined;
}
return (
<>
<div className="scene-parser-results">
<Table>
<thead>
<tr className="scene-parser-row">
<th className="parser-field-filename">Filename</th>
{renderHeader("Title", allTitleSet, onSelectAllTitleSet)}
{renderHeader("Date", allDateSet, onSelectAllDateSet)}
{renderHeader(
"Performers",
allPerformerSet,
onSelectAllPerformerSet
)}
{renderHeader("Tags", allTagSet, onSelectAllTagSet)}
{renderHeader("Studio", allStudioSet, onSelectAllStudioSet)}
</tr>
</thead>
<tbody>
{parserResult.map(scene => (
<SceneParserRow
scene={scene}
key={scene.id}
onChange={changedScene => onChange(scene, changedScene)}
showFields={showFields}
/>
))}
</tbody>
</Table>
</div>
<Pagination
currentPage={parserInput.page}
itemsPerPage={parserInput.pageSize}
totalItems={totalItems}
onChangePage={page => onPageChanged(page)}
/>
<Button variant="primary" onClick={onApply}>
Apply
</Button>
</>
);
}
return (
<Card id="parser-container" className="col col-sm-9 mx-auto">
<h4>Scene Filename Parser</h4>
<ParserInput
input={parserInput}
onFind={input => onFindClicked(input)}
onPageSizeChanged={onPageSizeChanged}
showFields={showFields}
setShowFields={setShowFields}
/>
{isLoading && <LoadingIndicator />}
{renderTable()}
</Card>
);
};

View File

@@ -0,0 +1,374 @@
import React from "react";
import _ from "lodash";
import { Form } from "react-bootstrap";
import {
ParseSceneFilenamesQuery,
SlimSceneDataFragment
} from "src/core/generated-graphql";
import {
PerformerSelect,
TagSelect,
StudioSelect
} from "src/components/Shared";
import { TextUtils } from "src/utils";
class ParserResult<T> {
public value?: T;
public originalValue?: T;
public isSet: boolean = false;
public setOriginalValue(value?: T) {
this.originalValue = value;
this.value = value;
}
public setValue(value?: T) {
if (value) {
this.value = value;
this.isSet = !_.isEqual(this.value, this.originalValue);
}
}
}
export class SceneParserResult {
public id: string;
public filename: string;
public title: ParserResult<string> = new ParserResult<string>();
public date: ParserResult<string> = new ParserResult<string>();
public studio: ParserResult<string> = new ParserResult<string>();
public tags: ParserResult<string[]> = new ParserResult<string[]>();
public performers: ParserResult<string[]> = new ParserResult<string[]>();
public scene: SlimSceneDataFragment;
constructor(
result: ParseSceneFilenamesQuery["parseSceneFilenames"]["results"][0]
) {
this.scene = result.scene;
this.id = this.scene.id;
this.filename = TextUtils.fileNameFromPath(this.scene.path);
this.title.setOriginalValue(this.scene.title ?? undefined);
this.date.setOriginalValue(this.scene.date ?? undefined);
this.performers.setOriginalValue(this.scene.performers.map(p => p.id));
this.tags.setOriginalValue(this.scene.tags.map(t => t.id));
this.studio.setOriginalValue(this.scene.studio?.id);
this.title.setValue(result.title ?? undefined);
this.date.setValue(result.date ?? undefined);
}
// returns true if any of its fields have set == true
public isChanged() {
return (
this.title.isSet ||
this.date.isSet ||
this.performers.isSet ||
this.studio.isSet ||
this.tags.isSet
);
}
public toSceneUpdateInput() {
return {
id: this.id,
details: this.scene.details,
url: this.scene.url,
rating: this.scene.rating,
gallery_id: this.scene.gallery?.id,
title: this.title.isSet ? this.title.value : this.scene.title,
date: this.date.isSet ? this.date.value : this.scene.date,
studio_id: this.studio.isSet ? this.studio.value : this.scene.studio?.id,
performer_ids: this.performers.isSet
? this.performers.value
: this.scene.performers.map(performer => performer.id),
tag_ids: this.tags.isSet
? this.tags.value
: this.scene.tags.map(tag => tag.id)
};
}
}
interface ISceneParserFieldProps<T> {
parserResult: ParserResult<T>;
className?: string;
fieldName: string;
onSetChanged: (isSet: boolean) => void;
onValueChanged: (value: T) => void;
originalParserResult?: ParserResult<T>;
}
function SceneParserStringField(props: ISceneParserFieldProps<string>) {
function maybeValueChanged(value: string) {
if (value !== props.parserResult.value) {
props.onValueChanged(value);
}
}
const result = props.originalParserResult || props.parserResult;
return (
<>
<td>
<Form.Check
checked={props.parserResult.isSet}
onChange={() => {
props.onSetChanged(!props.parserResult.isSet);
}}
/>
</td>
<td>
<Form.Group>
<Form.Control
disabled
className={props.className}
defaultValue={result.originalValue || ""}
/>
<Form.Control
disabled={!props.parserResult.isSet}
className={props.className}
value={props.parserResult.value || ""}
onChange={(event: React.FormEvent<HTMLInputElement>) =>
maybeValueChanged(event.currentTarget.value)
}
/>
</Form.Group>
</td>
</>
);
}
function SceneParserPerformerField(props: ISceneParserFieldProps<string[]>) {
function maybeValueChanged(value: string[]) {
if (value !== props.parserResult.value) {
props.onValueChanged(value);
}
}
const originalPerformers = (props.originalParserResult?.originalValue ??
[]) as string[];
const newPerformers = props.parserResult.value ?? [];
return (
<>
<td>
<Form.Check
checked={props.parserResult.isSet}
onChange={() => {
props.onSetChanged(!props.parserResult.isSet);
}}
/>
</td>
<td>
<Form.Group className={props.className}>
<PerformerSelect isDisabled isMulti ids={originalPerformers} />
<PerformerSelect
isMulti
onSelect={items => {
maybeValueChanged(items.map(i => i.id));
}}
ids={newPerformers}
/>
</Form.Group>
</td>
</>
);
}
function SceneParserTagField(props: ISceneParserFieldProps<string[]>) {
function maybeValueChanged(value: string[]) {
if (value !== props.parserResult.value) {
props.onValueChanged(value);
}
}
const originalTags = props.originalParserResult?.originalValue ?? [];
const newTags = props.parserResult.value ?? [];
return (
<>
<td>
<Form.Check
checked={props.parserResult.isSet}
onChange={() => {
props.onSetChanged(!props.parserResult.isSet);
}}
/>
</td>
<td>
<Form.Group className={props.className}>
<TagSelect isDisabled isMulti ids={originalTags} />
<TagSelect
isMulti
onSelect={items => {
maybeValueChanged(items.map(i => i.id));
}}
ids={newTags}
/>
</Form.Group>
</td>
</>
);
}
function SceneParserStudioField(props: ISceneParserFieldProps<string>) {
function maybeValueChanged(value: string) {
if (value !== props.parserResult.value) {
props.onValueChanged(value);
}
}
const originalStudio = props.originalParserResult?.originalValue
? [props.originalParserResult?.originalValue]
: [];
const newStudio = props.parserResult.value ? [props.parserResult.value] : [];
return (
<>
<td>
<Form.Check
checked={props.parserResult.isSet}
onChange={() => {
props.onSetChanged(!props.parserResult.isSet);
}}
/>
</td>
<td>
<Form.Group className={props.className}>
<StudioSelect isDisabled ids={originalStudio} />
<StudioSelect
onSelect={items => {
maybeValueChanged(items[0].id);
}}
ids={newStudio}
/>
</Form.Group>
</td>
</>
);
}
interface ISceneParserRowProps {
scene: SceneParserResult;
onChange: (changedScene: SceneParserResult) => void;
showFields: Map<string, boolean>;
}
export const SceneParserRow = (props: ISceneParserRowProps) => {
function changeParser<T>(result: ParserResult<T>, isSet: boolean, value: T) {
const newParser = _.clone(result);
newParser.isSet = isSet;
newParser.value = value;
return newParser;
}
function onTitleChanged(set: boolean, value: string) {
const newResult = _.clone(props.scene);
newResult.title = changeParser(newResult.title, set, value);
props.onChange(newResult);
}
function onDateChanged(set: boolean, value: string) {
const newResult = _.clone(props.scene);
newResult.date = changeParser(newResult.date, set, value);
props.onChange(newResult);
}
function onPerformerIdsChanged(set: boolean, value: string[]) {
const newResult = _.clone(props.scene);
newResult.performers = changeParser(newResult.performers, set, value);
props.onChange(newResult);
}
function onTagIdsChanged(set: boolean, value: string[]) {
const newResult = _.clone(props.scene);
newResult.tags = changeParser(newResult.tags, set, value);
props.onChange(newResult);
}
function onStudioIdChanged(set: boolean, value: string) {
const newResult = _.clone(props.scene);
newResult.studio = changeParser(newResult.studio, set, value);
props.onChange(newResult);
}
return (
<tr className="scene-parser-row">
<td className="text-left parser-field-filename">
{props.scene.filename}
</td>
{props.showFields.get("Title") && (
<SceneParserStringField
key="title"
fieldName="Title"
className="parser-field-title"
parserResult={props.scene.title}
onSetChanged={isSet =>
onTitleChanged(isSet, props.scene.title.value ?? "")
}
onValueChanged={value =>
onTitleChanged(props.scene.title.isSet, value)
}
/>
)}
{props.showFields.get("Date") && (
<SceneParserStringField
key="date"
fieldName="Date"
className="parser-field-date"
parserResult={props.scene.date}
onSetChanged={isSet =>
onDateChanged(isSet, props.scene.date.value ?? "")
}
onValueChanged={value => onDateChanged(props.scene.date.isSet, value)}
/>
)}
{props.showFields.get("Performers") && (
<SceneParserPerformerField
key="performers"
fieldName="Performers"
className="parser-field-performers"
parserResult={props.scene.performers}
originalParserResult={props.scene.performers}
onSetChanged={set =>
onPerformerIdsChanged(set, props.scene.performers.value ?? [])
}
onValueChanged={value =>
onPerformerIdsChanged(props.scene.performers.isSet, value)
}
/>
)}
{props.showFields.get("Tags") && (
<SceneParserTagField
key="tags"
fieldName="Tags"
className="parser-field-tags"
parserResult={props.scene.tags}
originalParserResult={props.scene.tags}
onSetChanged={isSet =>
onTagIdsChanged(isSet, props.scene.tags.value ?? [])
}
onValueChanged={value =>
onTagIdsChanged(props.scene.tags.isSet, value)
}
/>
)}
{props.showFields.get("Studio") && (
<SceneParserStudioField
key="studio"
fieldName="Studio"
className="parser-field-studio"
parserResult={props.scene.studio}
originalParserResult={props.scene.studio}
onSetChanged={set =>
onStudioIdChanged(set, props.scene.studio.value ?? "")
}
onValueChanged={value =>
onStudioIdChanged(props.scene.studio.isSet, value)
}
/>
)}
</tr>
);
};

View File

@@ -0,0 +1,43 @@
import React, { useState } from "react";
import { Button, Collapse } from "react-bootstrap";
import { Icon } from "src/components/Shared";
interface IShowFieldsProps {
fields: Map<string, boolean>;
onShowFieldsChanged: (fields: Map<string, boolean>) => void;
}
export const ShowFields = (props: IShowFieldsProps) => {
const [open, setOpen] = useState(false);
function handleClick(label: string) {
const copy = new Map<string, boolean>(props.fields);
copy.set(label, !props.fields.get(label));
props.onShowFieldsChanged(copy);
}
const fieldRows = [...props.fields.entries()].map(([label, enabled]) => (
<Button
className="minimal d-block"
key={label}
onClick={() => {
handleClick(label);
}}
>
<Icon icon={enabled ? "check" : "times"} />
<span>{label}</span>
</Button>
));
return (
<div>
<Button onClick={() => setOpen(!open)} className="minimal">
<Icon icon={open ? "chevron-down" : "chevron-right"} />
<span>Display fields</span>
</Button>
<Collapse in={open}>
<div>{fieldRows}</div>
</Collapse>
</div>
);
};

View File

@@ -0,0 +1,45 @@
.scene-parser-results {
margin-left: 31ch;
overflow-x: auto;
}
.scene-parser-row {
.parser-field-filename {
left: 1ch;
position: absolute;
width: 30ch;
}
.parser-field-title {
width: 40ch;
}
.parser-field-date {
width: 13ch;
}
.parser-field-performers {
width: 30ch;
}
.parser-field-tags {
width: 30ch;
}
.parser-field-studio {
width: 20ch;
}
.form-control {
min-width: 10ch;
}
.form-control + .form-control {
margin-top: .5rem;
}
.badge-items {
background-color: #e9ecef;
margin-bottom: .25rem;
}
}

View File

@@ -0,0 +1,254 @@
import React from "react";
import ReactJWPlayer from "react-jw-player";
import { HotKeys } from "react-hotkeys";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { JWUtils } from "src/utils";
import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
interface IScenePlayerProps {
scene: GQL.SceneDataFragment;
timestamp: number;
autoplay?: boolean;
onReady?: () => void;
onSeeked?: () => void;
onTime?: () => void;
config?: GQL.ConfigInterfaceDataFragment;
}
interface IScenePlayerState {
scrubberPosition: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
config: Record<string, any>;
}
const KeyMap = {
NUM0: "0",
NUM1: "1",
NUM2: "2",
SPACE: " "
};
export class ScenePlayerImpl extends React.Component<
IScenePlayerProps,
IScenePlayerState
> {
// Typings for jwplayer are, unfortunately, very lacking
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private player: any;
private lastTime = 0;
private KeyHandlers = {
NUM0: () => {
this.onReset();
},
NUM1: () => {
this.onDecrease();
},
NUM2: () => {
this.onIncrease();
},
SPACE: () => {
this.onPause();
}
};
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,
config: this.makeJWPlayerConfig(props.scene)
};
}
public UNSAFE_componentWillReceiveProps(props: IScenePlayerProps) {
if (props.scene !== this.props.scene) {
this.setState(state => ({
...state,
config: this.makeJWPlayerConfig(this.props.scene)
}));
}
}
public componentDidUpdate(prevProps: IScenePlayerProps) {
if (prevProps.timestamp !== this.props.timestamp) {
this.player.seek(this.props.timestamp);
}
}
onIncrease() {
const currentPlaybackRate = this.player ? this.player.getPlaybackRate() : 1;
this.player.setPlaybackRate(currentPlaybackRate + 0.5);
}
onDecrease() {
const currentPlaybackRate = this.player ? this.player.getPlaybackRate() : 1;
this.player.setPlaybackRate(currentPlaybackRate - 0.5);
}
onReset() {
this.player.setPlaybackRate(1);
}
onPause() {
if (this.player.getState().paused) this.player.play();
else this.player.pause();
}
private onReady() {
this.player = JWUtils.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() {
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();
}
private shouldRepeat(scene: GQL.SceneDataFragment) {
const maxLoopDuration = this.state?.config.maximumLoopDuration ?? 0;
return (
!!scene.file.duration &&
!!maxLoopDuration &&
scene.file.duration < maxLoopDuration
);
}
private makeJWPlayerConfig(scene: GQL.SceneDataFragment) {
if (!scene.paths.stream) {
return {};
}
const repeat = this.shouldRepeat(scene);
let getDurationHook: (() => GQL.Maybe<number>) | undefined;
let seekHook:
| ((seekToPosition: number, _videoTag: HTMLVideoElement) => void)
| undefined;
let getCurrentTimeHook:
| ((_videoTag: HTMLVideoElement) => number)
| undefined;
if (!this.props.scene.is_streamable) {
getDurationHook = () => {
return this.props.scene.file.duration ?? null;
};
seekHook = (seekToPosition: number, _videoTag: HTMLVideoElement) => {
/* eslint-disable no-param-reassign */
_videoTag.dataset.start = seekToPosition.toString();
_videoTag.src = `${this.props.scene.paths.stream}?start=${seekToPosition}`;
/* eslint-enable no-param-reassign */
_videoTag.play();
};
getCurrentTimeHook = (_videoTag: HTMLVideoElement) => {
const start = Number.parseInt(_videoTag.dataset?.start ?? "0", 10);
return _videoTag.currentTime + start;
};
}
const 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,
playbackRateControls: true,
playbackRates: [0.75, 1, 1.5, 2, 3, 4],
getDurationHook,
seekHook,
getCurrentTimeHook
};
return ret;
}
public render() {
return (
<HotKeys
keyMap={KeyMap}
handlers={this.KeyHandlers}
className="row scene-player"
>
<div
id="jwplayer-container"
className="w-100 col-sm-9 m-sm-auto no-gutter"
>
<ReactJWPlayer
playerId={JWUtils.playerID}
playerScript="/jwplayer/jwplayer.js"
customProps={this.state.config}
onReady={this.onReady}
onSeeked={this.onSeeked}
onTime={this.onTime}
/>
<ScenePlayerScrubber
scene={this.props.scene}
position={this.state.scrubberPosition}
onSeek={this.onScrubberSeek}
onScrolled={this.onScrubberScrolled}
/>
</div>
</HotKeys>
);
}
}
export const ScenePlayer: React.FC<IScenePlayerProps> = (
props: IScenePlayerProps
) => {
const config = StashService.useConfiguration();
return (
<ScenePlayerImpl
{...props}
config={
config.data && config.data.configuration
? config.data.configuration.interface
: undefined
}
/>
);
};

View File

@@ -0,0 +1,389 @@
/* eslint-disable react/no-array-index-key */
import React, {
CSSProperties,
useEffect,
useRef,
useState,
useCallback
} from "react";
import { Button } from "react-bootstrap";
import axios from "axios";
import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils";
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;
}
async function fetchSpriteInfo(vttPath: string) {
const response = await axios.get<string>(vttPath, { responseType: "text" });
// 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) {
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];
}
}
}
return newSpriteItems;
}
export const ScenePlayerScrubber: React.FC<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<MouseEvent | null>(null);
const startMouseEvent = useRef<MouseEvent | null>(null);
const velocity = useRef(0);
const _position = useRef(0);
const getPosition = useCallback(() => _position.current, []);
const setPosition = useCallback(
(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)`;
},
[props]
);
const [spriteItems, setSpriteItems] = useState<ISceneSpriteItem[]>([]);
useEffect(() => {
if (!scrubberSliderEl.current) {
return;
}
scrubberSliderEl.current.style.transform = `translateX(${scrubberSliderEl
.current.clientWidth / 2}px)`;
}, [scrubberSliderEl]);
useEffect(() => {
if (!props.scene.paths.vtt) return;
fetchSpriteInfo(props.scene.paths.vtt).then(sprites => {
if (sprites) setSpriteItems(sprites);
});
}, [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, props.scene.file.duration, setPosition]);
useEffect(() => {
window.addEventListener("mouseup", onMouseUp, false);
return () => {
window.removeEventListener("mouseup", onMouseUp);
};
});
useEffect(() => {
if (!contentEl.current) {
return;
}
const el = contentEl.current;
el.addEventListener("mousedown", onMouseDown, false);
return () => {
if (!el) {
return;
}
el.removeEventListener("mousedown", onMouseDown);
};
});
useEffect(() => {
if (!contentEl.current) {
return;
}
const el = contentEl.current;
el.addEventListener("mousemove", onMouseMove, false);
return () => {
if (!el) {
return;
}
el.removeEventListener("mousemove", onMouseMove);
};
});
function onMouseUp(this: Window, event: MouseEvent) {
if (!startMouseEvent.current || !scrubberSliderEl.current) {
return;
}
mouseDown.current = false;
const delta = Math.abs(event.clientX - startMouseEvent.current.clientX);
if (delta < 1 && event.target instanceof HTMLDivElement) {
const { target } = event;
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 = getPosition() + 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 ?? 0);
const movement = event.movementX;
velocity.current = movement;
const newPostion = getPosition() + 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 = getPosition() + scrubberSliderEl.current.clientWidth;
setPosition(newPosition);
}
function goForward() {
if (!scrubberSliderEl.current) {
return;
}
const newPosition = getPosition() - 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: Element | null;
for (let index = 0; index < tags.length; index++) {
tag = tags.item(index);
const id = tag?.getAttribute("data-marker-id") ?? null;
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 className="scrubber-item-time">
{TextUtils.secondsToTimestamp(spriteItem.start)} -{" "}
{TextUtils.secondsToTimestamp(spriteItem.end)}
</span>
</div>
);
});
}
return (
<div className="scrubber-wrapper d-none d-sm-block">
<Button
variant="link"
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

@@ -0,0 +1 @@
export { ScenePlayer } from "./ScenePlayer";

View File

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

View File

@@ -0,0 +1,248 @@
import React, { useState } from "react";
import { Button, ButtonGroup, Card, Form } from "react-bootstrap";
import { Link } from "react-router-dom";
import cx from "classnames";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { VideoHoverHook } from "src/hooks";
import { Icon, TagLink, HoverPopover, SweatDrops } from "src/components/Shared";
import { TextUtils } from "src/utils";
interface ISceneCardProps {
scene: GQL.SlimSceneDataFragment;
selected: boolean | undefined;
zoomIndex: number;
onSelectedChanged: (selected: boolean, shiftKey: boolean) => void;
}
export const SceneCard: React.FC<ISceneCardProps> = (
props: ISceneCardProps
) => {
const [previewPath, setPreviewPath] = useState<string>();
const videoHoverHook = VideoHoverHook.useVideoHover({
resetOnMouseLeave: false
});
const config = StashService.useConfiguration();
const showStudioAsText =
config?.data?.configuration.interface.showStudioAsText ?? false;
function maybeRenderRatingBanner() {
if (!props.scene.rating) {
return;
}
return (
<div
className={`rating-banner ${
props.scene.rating ? `rating-${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>
) : (
""
)}
{(props.scene.file.duration ?? 0) >= 1
? TextUtils.secondsToTimestamp(props.scene.file.duration ?? 0)
: ""}
</div>
);
}
function maybeRenderSceneStudioOverlay() {
if (!props.scene.studio) return;
return (
<div className="scene-studio-overlay">
<Link to={`/studios/${props.scene.studio.id}`}>
{showStudioAsText ? (
props.scene.studio.name
) : (
<img
className="image-thumbnail"
alt={props.scene.studio.name}
src={props.scene.studio.image_path ?? ""}
/>
)}
</Link>
</div>
);
}
function maybeRenderTagPopoverButton() {
if (props.scene.tags.length <= 0) return;
const popoverContent = props.scene.tags.map(tag => (
<TagLink key={tag.id} tag={tag} />
));
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal">
<Icon icon="tag" />
<span>{props.scene.tags.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderPerformerPopoverButton() {
if (props.scene.performers.length <= 0) return;
const popoverContent = props.scene.performers.map(performer => (
<div className="performer-tag-container row" key="performer">
<Link
to={`/performers/${performer.id}`}
className="performer-tag col m-auto zoom-2"
>
<img
className="image-thumbnail"
alt={performer.name ?? ""}
src={performer.image_path ?? ""}
/>
</Link>
<TagLink key={performer.id} performer={performer} className="d-block" />
</div>
));
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal">
<Icon icon="user" />
<span>{props.scene.performers.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderSceneMarkerPopoverButton() {
if (props.scene.scene_markers.length <= 0) return;
const popoverContent = props.scene.scene_markers.map(marker => {
const markerPopover = { ...marker, scene: { id: props.scene.id } };
return <TagLink key={marker.id} marker={markerPopover} />;
});
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal">
<Icon icon="map-marker-alt" />
<span>{props.scene.scene_markers.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderOCounter() {
if (props.scene.o_counter) {
return (
<div>
<Button className="minimal">
<SweatDrops />
<span>{props.scene.o_counter}</span>
</Button>
</div>
);
}
}
function maybeRenderPopoverButtonGroup() {
if (
props.scene.tags.length > 0 ||
props.scene.performers.length > 0 ||
props.scene.scene_markers.length > 0 ||
props.scene?.o_counter
) {
return (
<>
<hr />
<ButtonGroup className="scene-popovers">
{maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()}
{maybeRenderSceneMarkerPopoverButton()}
{maybeRenderOCounter()}
</ButtonGroup>
</>
);
}
}
function onMouseEnter() {
if (!previewPath || previewPath === "") {
setPreviewPath(props.scene.paths.preview || "");
}
VideoHoverHook.onMouseEnter(videoHoverHook);
}
function onMouseLeave() {
VideoHoverHook.onMouseLeave(videoHoverHook);
setPreviewPath("");
}
function isPortrait() {
const { file } = props.scene;
const width = file.width ? file.width : 0;
const height = file.height ? file.height : 0;
return height > width;
}
let shiftKey = false;
return (
<Card
className={`scene-card zoom-${props.zoomIndex}`}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<Form.Control
type="checkbox"
className="scene-card-check d-none d-sm-block"
checked={props.selected}
onChange={() => props.onSelectedChanged(!props.selected, shiftKey)}
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
// eslint-disable-next-line prefer-destructuring
shiftKey = event.shiftKey;
event.stopPropagation();
}}
/>
{maybeRenderSceneStudioOverlay()}
<Link to={`/scenes/${props.scene.id}`} className="scene-card-link">
{maybeRenderRatingBanner()}
{maybeRenderSceneSpecsOverlay()}
<video
loop
className={cx("scene-card-video", { portrait: isPortrait() })}
poster={props.scene.paths.screenshot || ""}
ref={videoHoverHook.videoEl}
>
{previewPath ? <source src={previewPath} /> : ""}
</video>
</Link>
<div className="card-section">
<h5 className="card-section-title">
{props.scene.title
? props.scene.title
: TextUtils.fileNameFromPath(props.scene.path)}
</h5>
<span>{props.scene.date}</span>
{props.scene.details && (
<p>
{TextUtils.truncate(props.scene.details, 100, "... (continued)")}
</p>
)}
</div>
{maybeRenderPopoverButtonGroup()}
</Card>
);
};

View File

@@ -0,0 +1,64 @@
import React from "react";
import { Button, Spinner } from "react-bootstrap";
import { Icon, HoverPopover, SweatDrops } from "src/components/Shared";
export interface IOCounterButtonProps {
loading: boolean;
value: number;
onIncrement: () => void;
onDecrement: () => void;
onReset: () => void;
onMenuOpened?: () => void;
onMenuClosed?: () => void;
}
export const OCounterButton: React.FC<IOCounterButtonProps> = (
props: IOCounterButtonProps
) => {
if (props.loading) return <Spinner animation="border" role="status" />;
const renderButton = () => (
<Button className="minimal" onClick={props.onIncrement} variant="secondary">
<SweatDrops />
<span className="ml-2">{props.value}</span>
</Button>
);
if (props.value) {
return (
<HoverPopover
content={
<div>
<div>
<Button
className="minimal"
onClick={props.onDecrement}
variant="secondary"
>
<Icon icon="minus" />
<span>Decrement</span>
</Button>
</div>
<div>
<Button
className="minimal"
onClick={props.onReset}
variant="secondary"
>
<Icon icon="ban" />
<span>Reset</span>
</Button>
</div>
</div>
}
enterDelay={1000}
placement="bottom"
onOpen={props.onMenuOpened}
onClose={props.onMenuClosed}
>
{renderButton()}
</HoverPopover>
);
}
return renderButton();
};

View File

@@ -0,0 +1,67 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { Button, Badge, Card } from "react-bootstrap";
import { TextUtils } from "src/utils";
interface IPrimaryTags {
sceneMarkers: GQL.SceneMarkerDataFragment[];
onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;
onEdit: (marker: GQL.SceneMarkerDataFragment) => void;
}
export const PrimaryTags: React.FC<IPrimaryTags> = ({
sceneMarkers,
onClickMarker,
onEdit
}) => {
if (!sceneMarkers?.length) return <div />;
const primaries: Record<string, GQL.Tag> = {};
const primaryTags: Record<string, GQL.SceneMarkerDataFragment[]> = {};
sceneMarkers.forEach(m => {
if (primaryTags[m.primary_tag.id]) primaryTags[m.primary_tag.id].push(m);
else {
primaryTags[m.primary_tag.id] = [m];
primaries[m.primary_tag.id] = m.primary_tag;
}
});
const primaryCards = Object.keys(primaryTags).map(id => {
const markers = primaryTags[id].map(marker => {
const tags = marker.tags.map(tag => (
<Badge key={tag.id} variant="secondary" className="tag-item">
{tag.name}
</Badge>
));
return (
<div key={marker.id}>
<hr />
<div className="row">
<Button variant="link" onClick={() => onClickMarker(marker)}>
{marker.title}
</Button>
<Button
variant="link"
className="ml-auto"
onClick={() => onEdit(marker)}
>
Edit
</Button>
</div>
<div>{TextUtils.secondsToTimestamp(marker.seconds)}</div>
<div className="card-section centered">{tags}</div>
</div>
);
});
return (
<Card className="primary-card col-12 col-sm-3" key={id}>
<h3>{primaries[id].name}</h3>
<Card.Body className="primary-card-body">{markers}</Card.Body>
</Card>
);
});
return <div className="primary-tag row">{primaryCards}</div>;
};

View File

@@ -0,0 +1,155 @@
import { Tab, Tabs } from "react-bootstrap";
import queryString from "query-string";
import React, { useEffect, useState } from "react";
import { useParams, useLocation, useHistory } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { GalleryViewer } from "src/components/Galleries/GalleryViewer";
import { LoadingIndicator } from "src/components/Shared";
import { useToast } from "src/hooks";
import { ScenePlayer } from "src/components/ScenePlayer";
import { ScenePerformerPanel } from "./ScenePerformerPanel";
import { SceneMarkersPanel } from "./SceneMarkersPanel";
import { SceneFileInfoPanel } from "./SceneFileInfoPanel";
import { SceneEditPanel } from "./SceneEditPanel";
import { SceneDetailPanel } from "./SceneDetailPanel";
import { OCounterButton } from "./OCounterButton";
export const Scene: React.FC = () => {
const { id = "new" } = useParams();
const location = useLocation();
const history = useHistory();
const Toast = useToast();
const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp());
const [scene, setScene] = useState<GQL.SceneDataFragment | undefined>();
const { data, error, loading } = StashService.useFindScene(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");
const queryParams = queryString.parse(location.search);
const autoplay = queryParams?.autoplay === "true";
useEffect(() => {
if (data?.findScene) setScene(data.findScene);
}, [data]);
function getInitialTimestamp() {
const params = queryString.parse(location.search);
const initialTimestamp = params?.t ?? "0";
return Number.parseInt(
Array.isArray(initialTimestamp) ? initialTimestamp[0] : initialTimestamp,
10
);
}
const updateOCounter = (newValue: number) => {
const modifiedScene = { ...scene } as GQL.SceneDataFragment;
modifiedScene.o_counter = newValue;
setScene(modifiedScene);
};
const onIncrementClick = async () => {
try {
setOLoading(true);
const result = await incrementO();
if (result.data) updateOCounter(result.data.sceneIncrementO);
} catch (e) {
Toast.error(e);
} finally {
setOLoading(false);
}
};
const onDecrementClick = async () => {
try {
setOLoading(true);
const result = await decrementO();
if (result.data) updateOCounter(result.data.sceneDecrementO);
} catch (e) {
Toast.error(e);
} finally {
setOLoading(false);
}
};
const onResetClick = async () => {
try {
setOLoading(true);
const result = await resetO();
if (result.data) updateOCounter(result.data.sceneResetO);
} catch (e) {
Toast.error(e);
} finally {
setOLoading(false);
}
};
function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
setTimestamp(marker.seconds);
}
if (loading || !scene || !data?.findScene) {
return <LoadingIndicator />;
}
if (error) return <div>{error.message}</div>;
return (
<>
<ScenePlayer scene={scene} timestamp={timestamp} autoplay={autoplay} />
<div id="scene-details-container" className="col col-sm-9 m-sm-auto">
<div className="float-right">
<OCounterButton
loading={oLoading}
value={scene.o_counter || 0}
onIncrement={onIncrementClick}
onDecrement={onDecrementClick}
onReset={onResetClick}
/>
</div>
<Tabs id="scene-tabs" mountOnEnter>
<Tab eventKey="scene-details-panel" title="Details">
<SceneDetailPanel scene={scene} />
</Tab>
<Tab eventKey="scene-markers-panel" title="Markers">
<SceneMarkersPanel scene={scene} onClickMarker={onClickMarker} />
</Tab>
{scene.performers.length > 0 ? (
<Tab eventKey="scene-performer-panel" title="Performers">
<ScenePerformerPanel scene={scene} />
</Tab>
) : (
""
)}
{scene.gallery ? (
<Tab eventKey="scene-gallery-panel" title="Gallery">
<GalleryViewer gallery={scene.gallery} />
</Tab>
) : (
""
)}
<Tab
className="file-info-panel"
eventKey="scene-file-info-panel"
title="File Info"
>
<SceneFileInfoPanel scene={scene} />
</Tab>
<Tab
eventKey="scene-edit-panel"
title="Edit"
tabClassName="d-none d-sm-block"
>
<SceneEditPanel
scene={scene}
onUpdate={newScene => setScene(newScene)}
onDelete={() => history.push("/scenes")}
/>
</Tab>
</Tabs>
</div>
</>
);
};

View File

@@ -0,0 +1,62 @@
import React from "react";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils";
import { TagLink } from "src/components/Shared";
interface ISceneDetailProps {
scene: GQL.SceneDataFragment;
}
export const SceneDetailPanel: React.FC<ISceneDetailProps> = props => {
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 (
<div className="row">
<h3 className="col scene-header text-truncate">
{props.scene.title ?? TextUtils.fileNameFromPath(props.scene.path)}
</h3>
<div className="col-6 scene-details">
<h4>{props.scene.date ?? ""}</h4>
{props.scene.rating ? <h6>Rating: {props.scene.rating}</h6> : ""}
{props.scene.file.height && (
<h6>Resolution: {TextUtils.resolution(props.scene.file.height)}</h6>
)}
{renderDetails()}
{renderTags()}
</div>
<div className="col-4 offset-2">
{props.scene.studio && (
<Link to={`/studios/${props.scene.studio.id}`}>
<img
src={props.scene.studio.image_path ?? ""}
alt={`${props.scene.studio.name} logo`}
className="studio-logo"
/>
</Link>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,416 @@
/* eslint-disable react/no-this-in-sfc */
import React, { useEffect, useState } from "react";
import { Button, Dropdown, DropdownButton, Form, Table } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import {
PerformerSelect,
TagSelect,
StudioSelect,
SceneGallerySelect,
Modal,
Icon,
LoadingIndicator,
ImageInput
} from "src/components/Shared";
import { useToast } from "src/hooks";
import { ImageUtils, TableUtils } from "src/utils";
interface IProps {
scene: GQL.SceneDataFragment;
onUpdate: (scene: GQL.SceneDataFragment) => void;
onDelete: () => void;
}
export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
const Toast = useToast();
const [title, setTitle] = useState<string>();
const [details, setDetails] = useState<string>();
const [url, setUrl] = useState<string>();
const [date, setDate] = useState<string>();
const [rating, setRating] = useState<number>();
const [galleryId, setGalleryId] = useState<string>();
const [studioId, setStudioId] = useState<string>();
const [performerIds, setPerformerIds] = useState<string[]>();
const [tagIds, setTagIds] = useState<string[]>();
const [coverImage, setCoverImage] = useState<string>();
const Scrapers = StashService.useListSceneScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [deleteFile, setDeleteFile] = useState<boolean>(false);
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
const [coverImagePreview, setCoverImagePreview] = useState<string>();
// Network state
const [isLoading, setIsLoading] = useState(true);
const [updateScene] = StashService.useSceneUpdate(getSceneInput());
const [deleteScene] = StashService.useSceneDestroy(getSceneDeleteInput());
useEffect(() => {
const newQueryableScrapers = (
Scrapers?.data?.listSceneScrapers ?? []
).filter(s => s.scene?.supported_scrapes.includes(GQL.ScrapeType.Fragment));
setQueryableScrapers(newQueryableScrapers);
}, [Scrapers]);
function updateSceneEditState(state: Partial<GQL.SceneDataFragment>) {
const perfIds = state.performers?.map(performer => performer.id);
const tIds = state.tags ? state.tags.map(tag => tag.id) : undefined;
setTitle(state.title ?? undefined);
setDetails(state.details ?? undefined);
setUrl(state.url ?? undefined);
setDate(state.date ?? undefined);
setRating(state.rating === null ? NaN : state.rating);
setGalleryId(state?.gallery?.id ?? undefined);
setStudioId(state?.studio?.id ?? undefined);
setPerformerIds(perfIds);
setTagIds(tIds);
}
useEffect(() => {
updateSceneEditState(props.scene);
setCoverImagePreview(props.scene?.paths?.screenshot ?? undefined);
setIsLoading(false);
}, [props.scene]);
ImageUtils.usePasteImage(onImageLoad);
function getSceneInput(): GQL.SceneUpdateInput {
return {
id: props.scene.id,
title,
details,
url,
date,
rating,
gallery_id: galleryId,
studio_id: studioId,
performer_ids: performerIds,
tag_ids: tagIds,
cover_image: coverImage
};
}
async function onSave() {
setIsLoading(true);
try {
const result = await updateScene();
if (result.data?.sceneUpdate) {
props.onUpdate(result.data.sceneUpdate);
Toast.success({ content: "Updated scene" });
}
} catch (e) {
Toast.error(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();
Toast.success({ content: "Deleted scene" });
} catch (e) {
Toast.error(e);
}
setIsLoading(false);
props.onDelete();
}
function renderDeleteAlert() {
return (
<Modal
show={isDeleteAlertOpen}
icon="trash-alt"
header="Delete Scene?"
accept={{ variant: "danger", onClick: onDelete, text: "Delete" }}
cancel={{ onClick: () => setIsDeleteAlertOpen(false), text: "Cancel" }}
>
<p>
Are you sure you want to delete this scene? Unless the file is also
deleted, this scene will be re-added when scan is performed.
</p>
<Form>
<Form.Check
checked={deleteFile}
label="Delete file"
onChange={() => setDeleteFile(!deleteFile)}
/>
<Form.Check
checked={deleteGenerated}
label="Delete generated supporting files"
onChange={() => setDeleteGenerated(!deleteGenerated)}
/>
</Form>
</Modal>
);
}
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.Scraper) {
setIsLoading(true);
try {
const result = await StashService.queryScrapeScene(
scraper.id,
getSceneInput()
);
if (!result.data || !result.data.scrapeScene) {
return;
}
updateSceneFromScrapedScene(result.data.scrapeScene);
} catch (e) {
Toast.error(e);
} finally {
setIsLoading(false);
}
}
function renderScraperMenu() {
if (!queryableScrapers || queryableScrapers.length === 0) {
return;
}
return (
<DropdownButton id="scene-scrape" title="Scrape with...">
{queryableScrapers.map(s => (
<Dropdown.Item onClick={() => onScrapeClicked(s)}>
{s.name}
</Dropdown.Item>
))}
</DropdownButton>
);
}
function urlScrapable(scrapedUrl: string): boolean {
return (Scrapers?.data?.listSceneScrapers ?? []).some(s =>
(s?.scene?.urls ?? []).some(u => scrapedUrl.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
) {
const idPerfs = scene.performers.filter(p => {
return p.id !== undefined && p.id !== null;
});
if (idPerfs.length > 0) {
const newIds = idPerfs.map(p => p.id);
setPerformerIds(newIds as string[]);
}
}
if (!tagIds?.length && scene?.tags?.length) {
const idTags = scene.tags.filter(p => {
return p.id !== undefined && p.id !== null;
});
if (idTags.length > 0) {
const newIds = idTags.map(p => p.id);
setTagIds(newIds as string[]);
}
}
}
async function onScrapeSceneURL() {
if (!url) {
return;
}
setIsLoading(true);
try {
const result = await StashService.queryScrapeSceneURL(url);
if (!result.data || !result.data.scrapeSceneURL) {
return;
}
updateSceneFromScrapedScene(result.data.scrapeSceneURL);
} catch (e) {
Toast.error(e);
} finally {
setIsLoading(false);
}
}
function maybeRenderScrapeButton() {
if (!url || !urlScrapable(url)) {
return undefined;
}
return (
<Button id="scrape-url-button" onClick={onScrapeSceneURL}>
<Icon icon="file-download" />
</Button>
);
}
if (isLoading) return <LoadingIndicator />;
return (
<div className="form-container row">
<div className="col-12 col-lg-6">
<Table id="scene-edit-details">
<tbody>
{TableUtils.renderInputGroup({
title: "Title",
value: title,
onChange: setTitle,
isEditing: true
})}
<tr>
<td>URL</td>
<td>
<Form.Control
onChange={(newValue: React.FormEvent<HTMLInputElement>) =>
setUrl(newValue.currentTarget.value)
}
value={url}
placeholder="URL"
/>
{maybeRenderScrapeButton()}
</td>
</tr>
{TableUtils.renderInputGroup({
title: "Date",
value: date,
isEditing: true,
onChange: setDate
})}
{TableUtils.renderHtmlSelect({
title: "Rating",
value: rating,
isEditing: true,
onChange: (value: string) =>
setRating(Number.parseInt(value, 10)),
selectOptions: ["", 1, 2, 3, 4, 5]
})}
<tr>
<td>Gallery</td>
<td>
<SceneGallerySelect
sceneId={props.scene.id}
initialId={galleryId}
onSelect={item => setGalleryId(item ? item.id : undefined)}
/>
</td>
</tr>
<tr>
<td>Studio</td>
<td>
<StudioSelect
onSelect={items => items.length && setStudioId(items[0]?.id)}
ids={studioId ? [studioId] : []}
/>
</td>
</tr>
<tr>
<td>Performers</td>
<td>
<PerformerSelect
isMulti
onSelect={items =>
setPerformerIds(items.map(item => item.id))
}
ids={performerIds}
/>
</td>
</tr>
<tr>
<td>Tags</td>
<td>
<TagSelect
isMulti
onSelect={items => setTagIds(items.map(item => item.id))}
ids={tagIds}
/>
</td>
</tr>
</tbody>
</Table>
</div>
<div className="col-12 col-lg-6">
<Form.Group controlId="details">
<Form.Label>Details</Form.Label>
<Form.Control
as="textarea"
className="scene-description"
onChange={(newValue: React.FormEvent<HTMLTextAreaElement>) =>
setDetails(newValue.currentTarget.value)
}
value={details}
/>
</Form.Group>
<div>
<Form.Group className="test" controlId="cover">
<Form.Label>Cover Image</Form.Label>
<img
className="scene-cover"
src={coverImagePreview}
alt="Scene cover"
/>
<ImageInput isEditing onImageChange={onCoverImageChange} />
</Form.Group>
</div>
</div>
<div className="col edit-buttons">
<Button className="edit-button" variant="primary" onClick={onSave}>
Save
</Button>
<Button
className="edit-button"
variant="danger"
onClick={() => setIsDeleteAlertOpen(true)}
>
Delete
</Button>
</div>
{renderScraperMenu()}
{renderDeleteAlert()}
</div>
);
};

View File

@@ -0,0 +1,173 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils";
interface ISceneFileInfoPanelProps {
scene: GQL.SceneDataFragment;
}
export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
props: ISceneFileInfoPanelProps
) => {
function renderChecksum() {
return (
<div className="row">
<span className="col-4">Checksum</span>
<span className="col-8 text-truncate">{props.scene.checksum}</span>
</div>
);
}
function renderPath() {
const {
scene: { path }
} = props;
return (
<div className="row">
<span className="col-4">Path</span>
<span className="col-8 text-truncate">
<a href={`file://${path}`}>{`file://${props.scene.path}`}</a>{" "}
</span>
</div>
);
}
function renderStream() {
return (
<div className="row">
<span className="col-4">Stream</span>
<span className="col-8 text-truncate">
<a href={props.scene.paths.stream ?? ""}>
{props.scene.paths.stream}
</a>{" "}
</span>
</div>
);
}
function renderFileSize() {
if (props.scene.file.size === undefined) {
return;
}
return (
<div className="row">
<span className="col-4">File Size</span>
<span className="col-8 text-truncate">
{TextUtils.fileSize(parseInt(props.scene.file.size ?? "0", 10))}
</span>
</div>
);
}
function renderDuration() {
if (props.scene.file.duration === undefined) {
return;
}
return (
<div className="row">
<span className="col-4">Duration</span>
<span className="col-8 text-truncate">
{TextUtils.secondsToTimestamp(props.scene.file.duration ?? 0)}
</span>
</div>
);
}
function renderDimensions() {
if (props.scene.file.duration === undefined) {
return;
}
return (
<div className="row">
<span className="col-4">Dimensions</span>
<span className="col-8 text-truncate">
{props.scene.file.width} x {props.scene.file.height}
</span>
</div>
);
}
function renderFrameRate() {
if (props.scene.file.framerate === undefined) {
return;
}
return (
<div className="row">
<span className="col-4">Frame Rate</span>
<span className="col-8 text-truncate">
{props.scene.file.framerate} frames per second
</span>
</div>
);
}
function renderbitrate() {
if (props.scene.file.bitrate === undefined) {
return;
}
return (
<div className="row">
<span className="col-4">Bit Rate</span>
<span className="col-8 text-truncate">
{TextUtils.bitRate(props.scene.file.bitrate ?? 0)}
</span>
</div>
);
}
function renderVideoCodec() {
if (props.scene.file.video_codec === undefined) {
return;
}
return (
<div className="row">
<span className="col-4">Video Codec</span>
<span className="col-8 text-truncate">
{props.scene.file.video_codec}
</span>
</div>
);
}
function renderAudioCodec() {
if (props.scene.file.audio_codec === undefined) {
return;
}
return (
<div className="row">
<span className="col-4">Audio Codec</span>
<span className="col-8 text-truncate">
{props.scene.file.audio_codec}
</span>
</div>
);
}
function renderUrl() {
if (!props.scene.url || props.scene.url === "") {
return;
}
return (
<div className="row">
<span className="col-4">Downloaded From</span>
<span className="col-8 text-truncate">{props.scene.url}</span>
</div>
);
}
return (
<div className="container scene-file-info">
{renderChecksum()}
{renderPath()}
{renderStream()}
{renderFileSize()}
{renderDuration()}
{renderDimensions()}
{renderFrameRate()}
{renderbitrate()}
{renderVideoCodec()}
{renderAudioCodec()}
{renderUrl()}
</div>
);
};

View File

@@ -0,0 +1,181 @@
import React from "react";
import { Button, Form } from "react-bootstrap";
import { Field, FieldProps, Form as FormikForm, Formik } from "formik";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import {
DurationInput,
TagSelect,
MarkerTitleSuggest
} from "src/components/Shared";
import { useToast } from "src/hooks";
interface IFormFields {
title: string;
seconds: string;
primaryTagId: string;
tagIds: string[];
}
interface ISceneMarkerForm {
sceneID: string;
editingMarker?: GQL.SceneMarkerDataFragment;
playerPosition?: number;
onClose: () => void;
}
export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
sceneID,
editingMarker,
playerPosition,
onClose
}) => {
const [sceneMarkerCreate] = StashService.useSceneMarkerCreate();
const [sceneMarkerUpdate] = StashService.useSceneMarkerUpdate();
const [sceneMarkerDestroy] = StashService.useSceneMarkerDestroy();
const Toast = useToast();
const onSubmit = (values: IFormFields) => {
const variables: GQL.SceneMarkerUpdateInput | GQL.SceneMarkerCreateInput = {
title: values.title,
seconds: parseFloat(values.seconds),
scene_id: sceneID,
primary_tag_id: values.primaryTagId,
tag_ids: values.tagIds
};
if (!editingMarker) {
sceneMarkerCreate({ variables })
.then(onClose)
.catch(err => Toast.error(err));
} else {
const updateVariables = variables as GQL.SceneMarkerUpdateInput;
updateVariables.id = editingMarker!.id;
sceneMarkerUpdate({ variables: updateVariables })
.then(onClose)
.catch(err => Toast.error(err));
}
};
const onDelete = () => {
if (!editingMarker) return;
sceneMarkerDestroy({ variables: { id: editingMarker.id } })
.then(onClose)
.catch(err => Toast.error(err));
};
const renderTitleField = (fieldProps: FieldProps<string>) => (
<div className="col-10">
<MarkerTitleSuggest
initialMarkerTitle={fieldProps.field.value}
onChange={(query: string) =>
fieldProps.form.setFieldValue("title", query)
}
/>
</div>
);
const renderSecondsField = (fieldProps: FieldProps<string>) => (
<div className="col-3">
<DurationInput
onValueChange={s => fieldProps.form.setFieldValue("seconds", s)}
onReset={() =>
fieldProps.form.setFieldValue(
"seconds",
Math.round(playerPosition ?? 0)
)
}
numericValue={Number.parseInt(fieldProps.field.value ?? "0", 10)}
/>
</div>
);
const renderPrimaryTagField = (fieldProps: FieldProps<string>) => (
<TagSelect
onSelect={tags =>
fieldProps.form.setFieldValue("primaryTagId", tags[0]?.id)
}
ids={fieldProps.field.value ? [fieldProps.field.value] : []}
noSelectionString="Select or create tag..."
/>
);
const renderTagsField = (fieldProps: FieldProps<string[]>) => (
<TagSelect
isMulti
onSelect={tags =>
fieldProps.form.setFieldValue(
"tagIds",
tags.map(tag => tag.id)
)
}
ids={fieldProps.field.value}
noSelectionString="Select or create tags..."
/>
);
const values: IFormFields = {
title: editingMarker?.title ?? "",
seconds: (
editingMarker?.seconds ?? Math.round(playerPosition ?? 0)
).toString(),
primaryTagId: editingMarker?.primary_tag.id ?? "",
tagIds: editingMarker?.tags.map(tag => tag.id) ?? []
};
return (
<Formik initialValues={values} onSubmit={onSubmit}>
<FormikForm>
<div>
<Form.Group className="row">
<Form.Label htmlFor="title" className="col-2">
Scene Marker Title
</Form.Label>
<Field name="title">{renderTitleField}</Field>
</Form.Group>
<Form.Group className="row">
<Form.Label htmlFor="primaryTagId" className="col-2">
Primary Tag
</Form.Label>
<div className="col-6">
<Field name="primaryTagId">{renderPrimaryTagField}</Field>
</div>
<Form.Label htmlFor="seconds" className="col-1">
Time
</Form.Label>
<Field name="seconds">{renderSecondsField}</Field>
</Form.Group>
<Form.Group className="row">
<Form.Label htmlFor="tagIds" className="col-2">
Tags
</Form.Label>
<div className="col-10">
<Field name="tagIds">{renderTagsField}</Field>
</div>
</Form.Group>
</div>
<div className="buttons-container row">
<Button variant="primary" type="submit">
Submit
</Button>
<Button
variant="secondary"
type="button"
onClick={onClose}
className="ml-2"
>
Cancel
</Button>
{editingMarker && (
<Button
variant="danger"
className="ml-auto"
onClick={() => onDelete()}
>
Delete
</Button>
)}
</div>
</FormikForm>
</Formik>
);
};

View File

@@ -0,0 +1,69 @@
import React, { useState } from "react";
import { Button } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { WallPanel } from "src/components/Wall/WallPanel";
import { JWUtils } from "src/utils";
import { PrimaryTags } from "./PrimaryTags";
import { SceneMarkerForm } from "./SceneMarkerForm";
interface ISceneMarkersPanelProps {
scene: GQL.SceneDataFragment;
onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;
}
export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
props: ISceneMarkersPanelProps
) => {
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
const [editingMarker, setEditingMarker] = useState<
GQL.SceneMarkerDataFragment
>();
const jwplayer = JWUtils.getPlayer();
function onOpenEditor(marker?: GQL.SceneMarkerDataFragment) {
setIsEditorOpen(true);
setEditingMarker(marker ?? undefined);
}
function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
props.onClickMarker(marker);
}
const closeEditor = () => {
setEditingMarker(undefined);
setIsEditorOpen(false);
};
if (isEditorOpen)
return (
<SceneMarkerForm
sceneID={props.scene.id}
editingMarker={editingMarker}
playerPosition={jwplayer.getPlayer?.().playerPosition}
onClose={closeEditor}
/>
);
return (
<>
<Button onClick={() => onOpenEditor()}>Create Marker</Button>
<div className="container">
<PrimaryTags
sceneMarkers={props.scene.scene_markers ?? []}
onClickMarker={onClickMarker}
onEdit={onOpenEditor}
/>
</div>
<div className="row">
<WallPanel
sceneMarkers={props.scene.scene_markers}
clickHandler={marker => {
window.scrollTo(0, 0);
onClickMarker(marker as GQL.SceneMarkerDataFragment);
}}
/>
</div>
</>
);
};

View File

@@ -0,0 +1,25 @@
import React, { FunctionComponent } from "react";
import * as GQL from "src/core/generated-graphql";
import { PerformerCard } from "src/components/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 ?? undefined}
/>
));
return (
<>
<div className="row justify-content-center">{cards}</div>
</>
);
};

View File

@@ -0,0 +1,144 @@
import React from "react";
import _ from "lodash";
import { useHistory } from "react-router-dom";
import {
FindScenesQueryResult,
SlimSceneDataFragment
} from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { useScenesList } from "src/hooks";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { WallPanel } from "../Wall/WallPanel";
import { SceneCard } from "./SceneCard";
import { SceneListTable } from "./SceneListTable";
import { SceneSelectedOptions } from "./SceneSelectedOptions";
interface ISceneList {
subComponent?: boolean;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
}
export const SceneList: React.FC<ISceneList> = ({
subComponent,
filterHook
}) => {
const history = useHistory();
const otherOperations = [
{
text: "Play Random",
onClick: playRandom
}
];
const listData = useScenesList({
zoomable: true,
otherOperations,
renderContent,
renderSelectedOptions,
subComponent,
filterHook
});
async function playRandom(
result: FindScenesQueryResult,
filter: ListFilterModel
) {
// query for a random scene
if (result.data && result.data.findScenes) {
const { count } = result.data.findScenes;
const index = Math.floor(Math.random() * count);
const 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
) {
const { id } = singleResult!.data!.findScenes!.scenes[0];
// navigate to the scene player page
history.push(`/scenes/${id}?autoplay=true`);
}
}
}
function renderSelectedOptions(
result: FindScenesQueryResult,
selectedIds: Set<string>
) {
// find the selected items from the ids
if (!result.data || !result.data.findScenes) {
return undefined;
}
const { scenes } = result.data.findScenes;
const selectedScenes: SlimSceneDataFragment[] = [];
selectedIds.forEach(id => {
const scene = scenes.find(s => s.id === id);
if (scene) {
selectedScenes.push(scene);
}
});
return (
<>
<SceneSelectedOptions
selected={selectedScenes}
onScenesUpdated={() => {}}
/>
</>
);
}
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: FindScenesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
zoomIndex: number
) {
if (!result.data || !result.data.findScenes) {
return;
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<div className="row justify-content-center">
{result.data.findScenes.scenes.map(scene =>
renderSceneCard(scene, selectedIds, zoomIndex)
)}
</div>
);
}
if (filter.displayMode === DisplayMode.List) {
return <SceneListTable scenes={result.data.findScenes.scenes} />;
}
if (filter.displayMode === DisplayMode.Wall) {
return <WallPanel scenes={result.data.findScenes.scenes} />;
}
}
return listData.template;
};

View File

@@ -0,0 +1,82 @@
/* eslint-disable jsx-a11y/control-has-associated-label */
import React from "react";
import { Table } from "react-bootstrap";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { NavUtils, TextUtils } from "src/utils";
interface ISceneListTableProps {
scenes: GQL.SlimSceneDataFragment[];
}
export const SceneListTable: React.FC<ISceneListTableProps> = (
props: ISceneListTableProps
) => {
const renderTags = (tags: GQL.Tag[]) =>
tags.map(tag => (
<Link key={tag.id} to={NavUtils.makeTagScenesUrl(tag)}>
<h6>{tag.name}</h6>
</Link>
));
const renderPerformers = (performers: Partial<GQL.Performer>[]) =>
performers.map(performer => (
<Link key={performer.id} to={NavUtils.makePerformerScenesUrl(performer)}>
<h6>{performer.name}</h6>
</Link>
));
const renderSceneRow = (scene: GQL.SlimSceneDataFragment) => (
<tr key={scene.id}>
<td>
<Link to={`/scenes/${scene.id}`}>
<img
className="image-thumbnail"
alt={scene.title ?? ""}
src={scene.paths.screenshot ?? ""}
/>
</Link>
</td>
<td className="text-left">
<Link to={`/scenes/${scene.id}`}>
<h5 className="text-truncate">
{scene.title ?? TextUtils.fileNameFromPath(scene.path)}
</h5>
</Link>
</td>
<td>{scene.rating ? scene.rating : ""}</td>
<td>
{scene.file.duration &&
TextUtils.secondsToTimestamp(scene.file.duration)}
</td>
<td>{renderTags(scene.tags)}</td>
<td>{renderPerformers(scene.performers)}</td>
<td>
{scene.studio && (
<Link to={NavUtils.makeStudioScenesUrl(scene.studio)}>
<h6>{scene.studio.name}</h6>
</Link>
)}
</td>
</tr>
);
return (
<div className="row table-list justify-content-center">
<Table striped bordered>
<thead>
<tr>
<th />
<th className="text-left">Title</th>
<th>Rating</th>
<th>Duration</th>
<th>Tags</th>
<th>Performers</th>
<th>Studio</th>
</tr>
</thead>
<tbody>{props.scenes.map(renderSceneRow)}</tbody>
</Table>
</div>
);
};

View File

@@ -0,0 +1,62 @@
import _ from "lodash";
import React from "react";
import { useHistory } from "react-router-dom";
import { FindSceneMarkersQueryResult } from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { NavUtils } from "src/utils";
import { useSceneMarkersList } from "src/hooks";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { WallPanel } from "../Wall/WallPanel";
export const SceneMarkerList: React.FC = () => {
const history = useHistory();
const otherOperations = [
{
text: "Play Random",
onClick: playRandom
}
];
const listData = useSceneMarkersList({
otherOperations,
renderContent
});
async function playRandom(
result: FindSceneMarkersQueryResult,
filter: ListFilterModel
) {
// query for a random scene
if (result.data?.findSceneMarkers) {
const { count } = result.data.findSceneMarkers;
const index = Math.floor(Math.random() * count);
const filterCopy = _.cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await StashService.queryFindSceneMarkers(filterCopy);
if (singleResult?.data?.findSceneMarkers?.scene_markers?.length === 1) {
// navigate to the scene player page
const url = NavUtils.makeSceneMarkerUrl(
singleResult.data.findSceneMarkers.scene_markers[0]
);
history.push(url);
}
}
}
function renderContent(
result: FindSceneMarkersQueryResult,
filter: ListFilterModel
) {
if (!result?.data?.findSceneMarkers) return;
if (filter.displayMode === DisplayMode.Wall) {
return (
<WallPanel sceneMarkers={result.data.findSceneMarkers.scene_markers} />
);
}
}
return listData.template;
};

View File

@@ -0,0 +1,306 @@
import React, { useEffect, useState } from "react";
import { Button, Form } from "react-bootstrap";
import _ from "lodash";
import { StashService } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import {
FilterSelect,
StudioSelect,
LoadingIndicator
} from "src/components/Shared";
import { useToast } from "src/hooks";
interface IListOperationProps {
selected: GQL.SlimSceneDataFragment[];
onScenesUpdated: () => void;
}
export const SceneSelectedOptions: React.FC<IListOperationProps> = (
props: IListOperationProps
) => {
const Toast = useToast();
const [rating, setRating] = useState<string>("");
const [studioId, setStudioId] = useState<string>();
const [performerIds, setPerformerIds] = useState<string[]>();
const [tagIds, setTagIds] = useState<string[]>();
const [updateScenes] = StashService.useBulkSceneUpdate(getSceneInput());
// Network state
const [isLoading, setIsLoading] = useState(true);
function getSceneInput(): GQL.BulkSceneUpdateInput {
// need to determine what we are actually setting on each scene
const aggregateRating = getRating(props.selected);
const aggregateStudioId = getStudioId(props.selected);
const aggregatePerformerIds = getPerformerIds(props.selected);
const aggregateTagIds = getTagIds(props.selected);
const 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, 10);
}
// if studioId is undefined
if (studioId === undefined) {
// and all scenes have the same studioId,
// then unset the studioId, otherwise ignoring studioId
if (aggregateStudioId) {
// an undefined studio_id is ignored in the server, so set it to empty string instead
sceneInput.studio_id = "";
}
} else {
// if studioId is set, then we are setting it
sceneInput.studio_id = studioId;
}
// if performerIds are empty
if (!performerIds || performerIds.length === 0) {
// and all scenes have the same ids,
if (aggregatePerformerIds.length > 0) {
// then unset the performerIds, otherwise ignore
sceneInput.performer_ids = performerIds;
}
} else {
// if performerIds non-empty, then we are setting them
sceneInput.performer_ids = performerIds;
}
// if tagIds non-empty, then we are setting them
if (!tagIds || tagIds.length === 0) {
// and all scenes have the same ids,
if (aggregateTagIds.length > 0) {
// then unset the tagIds, otherwise ignore
sceneInput.tag_ids = tagIds;
}
} else {
// if tagIds non-empty, then we are setting them
sceneInput.tag_ids = tagIds;
}
return sceneInput;
}
async function onSave() {
setIsLoading(true);
try {
await updateScenes();
Toast.success({ content: "Updated scenes" });
} catch (e) {
Toast.error(e);
}
setIsLoading(false);
props.onScenesUpdated();
}
function getRating(state: GQL.SlimSceneDataFragment[]) {
let ret: number | undefined;
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
if (first) {
ret = scene.rating ?? undefined;
first = false;
} else if (ret !== scene.rating) {
ret = undefined;
}
});
return ret;
}
function getStudioId(state: GQL.SlimSceneDataFragment[]) {
let ret: string | undefined;
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
if (first) {
ret = scene?.studio?.id;
first = false;
} else {
const studio = scene?.studio?.id;
if (ret !== studio) {
ret = undefined;
}
}
});
return ret;
}
function getPerformerIds(state: GQL.SlimSceneDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
if (first) {
ret = scene.performers ? scene.performers.map(p => p.id).sort() : [];
first = false;
} else {
const perfIds = scene.performers
? scene.performers.map(p => p.id).sort()
: [];
if (!_.isEqual(ret, perfIds)) {
ret = [];
}
}
});
return ret;
}
function getTagIds(state: GQL.SlimSceneDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
if (first) {
ret = scene.tags ? scene.tags.map(t => t.id).sort() : [];
first = false;
} else {
const tIds = scene.tags ? scene.tags.map(t => t.id).sort() : [];
if (!_.isEqual(ret, tIds)) {
ret = [];
}
}
});
return ret;
}
useEffect(() => {
const state = props.selected;
let updateRating = "";
let updateStudioID: string | undefined;
let updatePerformerIds: string[] = [];
let updateTagIds: string[] = [];
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
const sceneRating = scene.rating?.toString() ?? "";
const sceneStudioID = scene?.studio?.id;
const scenePerformerIDs = (scene.performers ?? []).map(p => p.id).sort();
const sceneTagIDs = (scene.tags ?? []).map(p => p.id).sort();
if (first) {
updateRating = sceneRating;
updateStudioID = sceneStudioID;
updatePerformerIds = scenePerformerIDs;
updateTagIds = sceneTagIDs;
first = false;
} else {
if (sceneRating !== updateRating) {
updateRating = "";
}
if (sceneStudioID !== updateStudioID) {
updateStudioID = undefined;
}
if (!_.isEqual(scenePerformerIDs, updatePerformerIds)) {
updatePerformerIds = [];
}
if (!_.isEqual(sceneTagIDs, updateTagIds)) {
updateTagIds = [];
}
}
});
setRating(updateRating);
setStudioId(updateStudioID);
setPerformerIds(updatePerformerIds);
setTagIds(updateTagIds);
setIsLoading(false);
}, [props.selected]);
function renderMultiSelect(
type: "performers" | "tags",
ids: string[] | undefined
) {
return (
<FilterSelect
type={type}
isMulti
isClearable={false}
onSelect={items => {
const itemIDs = items.map(i => i.id);
switch (type) {
case "performers":
setPerformerIds(itemIDs);
break;
case "tags":
setTagIds(itemIDs);
break;
}
}}
ids={ids ?? []}
/>
);
}
if (isLoading) return <LoadingIndicator />;
function render() {
return (
<div className="operation-container">
<Form.Group
controlId="rating"
className="operation-item rating-operation"
>
<Form.Label>Rating</Form.Label>
<Form.Control
as="select"
value={rating}
onChange={(event: React.FormEvent<HTMLSelectElement>) =>
setRating(event.currentTarget.value)
}
>
{["", "1", "2", "3", "4", "5"].map(opt => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</Form.Control>
</Form.Group>
<Form.Group controlId="studio" className="operation-item">
<Form.Label>Studio</Form.Label>
<StudioSelect
onSelect={items => setStudioId(items[0]?.id)}
ids={studioId ? [studioId] : []}
/>
</Form.Group>
<Form.Group className="operation-item" controlId="performers">
<Form.Label>Performers</Form.Label>
{renderMultiSelect("performers", performerIds)}
</Form.Group>
<Form.Group className="operation-item" controlId="performers">
<Form.Label>Tags</Form.Label>
{renderMultiSelect("tags", tagIds)}
</Form.Group>
<Button variant="primary" onClick={onSave} className="apply-operation">
Apply
</Button>
</div>
);
}
return render();
};

View File

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

View File

@@ -0,0 +1,214 @@
.scene-popovers {
display: flex;
justify-content: center;
margin-bottom: 10px;
.btn {
padding-bottom: 3px;
padding-top: 3px;
}
.fa-icon {
margin-right: 7px;
}
}
.card-section {
margin-bottom: 0;
padding: .5rem 1rem 0 1rem;
&-title {
overflow: hidden;
overflow-wrap: normal;
text-overflow: ellipsis;
}
}
.scene-card-check {
left: .5rem;
margin-top: -12px;
opacity: .5;
padding-left: 15px;
position: absolute;
top: .7rem;
width: 1.2rem;
z-index: 1;
}
.performer-tag-container {
display: inline-block;
margin: 5px;
}
.performer-tag.image {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
height: 150px;
margin: 0 auto;
width: 100%;
}
.operation-container {
.operation-item {
min-width: 240px;
}
.rating-operation {
min-width: 20px;
}
.apply-operation {
margin-top: 2rem;
}
}
.marker-container {
display: "flex";
flex-wrap: "nowrap";
margin-bottom: "20px";
overflow-x: "scroll";
overflow-y: "hidden";
white-space: "nowrap";
}
.studio-logo {
margin-top: 1rem;
max-width: 100%;
}
.scene-header {
flex-basis: auto;
}
#scene-details-container {
.tab-content {
min-height: 15rem;
}
.scene-description {
width: 100%;
}
}
.file-info-panel {
div {
margin-bottom: .5rem;
}
}
#details {
min-height: 150px;
}
.primary-card {
margin: 1rem 0;
&-body {
max-height: 15rem;
overflow-y: auto;
}
}
.studio-card {
padding: .5rem;
&-header {
height: 150px;
line-height: 150px;
text-align: center;
}
&-image {
max-height: 150px;
object-fit: contain;
vertical-align: middle;
width: 320px;
@media (max-width: 576px) {
width: 100%;
}
}
}
.scene-specs-overlay {
bottom: 1rem;
color: $text-color;
display: block;
font-weight: 400;
letter-spacing: -.03rem;
position: absolute;
right: .7rem;
text-shadow: 0 0 3px #000;
}
.scene-studio-overlay {
display: block;
font-weight: 900;
height: 10%;
max-width: 40%;
opacity: .75;
position: absolute;
right: .7rem;
top: .7rem;
z-index: 9;
.image-thumbnail {
height: auto;
max-height: 50px;
max-width: 100%;
}
a {
color: $text-color;
display: inline-block;
letter-spacing: -.03rem;
text-align: right;
text-decoration: none;
text-shadow: 0 0 3px #000;
}
}
.overlay-resolution {
font-weight: 900;
margin-right: .3rem;
text-transform: uppercase;
}
.scene-card {
&.card {
overflow: hidden;
padding: 0;
}
&-link {
position: relative;
}
.scene-specs-overlay,
.rating-banner,
.scene-studio-overlay {
transition: opacity .5s;
}
&:hover {
.scene-specs-overlay,
.rating-banner,
.scene-studio-overlay {
opacity: 0;
transition: opacity .5s;
}
.scene-studio-overlay:hover {
opacity: .75;
transition: opacity .5s;
}
}
}
.scene-cover {
display: block;
margin-bottom: 10px;
margin-top: 10px;
max-width: 100%;
}

View File

@@ -0,0 +1,69 @@
import React from "react";
import queryString from "query-string";
import { Card, Tab, Nav, Row, Col } from "react-bootstrap";
import { useHistory, useLocation } from "react-router-dom";
import { SettingsAboutPanel } from "./SettingsAboutPanel";
import { SettingsConfigurationPanel } from "./SettingsConfigurationPanel";
import { SettingsInterfacePanel } from "./SettingsInterfacePanel";
import { SettingsLogsPanel } from "./SettingsLogsPanel";
import { SettingsTasksPanel } from "./SettingsTasksPanel/SettingsTasksPanel";
export const Settings: React.FC = () => {
const location = useLocation();
const history = useHistory();
const defaultTab = queryString.parse(location.search).tab ?? "configuration";
const onSelect = (val: string) => history.push(`?tab=${val}`);
return (
<Card className="col col-lg-9 mx-auto">
<Tab.Container
defaultActiveKey={defaultTab}
id="configuration-tabs"
onSelect={onSelect}
>
<Row>
<Col sm={2}>
<Nav variant="pills" className="flex-column">
<Nav.Item>
<Nav.Link eventKey="configuration">Configuration</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="interface">Interface</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="tasks">Tasks</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="logs">Logs</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="about">About</Nav.Link>
</Nav.Item>
<hr className="d-sm-none" />
</Nav>
</Col>
<Col sm={10}>
<Tab.Content>
<Tab.Pane eventKey="configuration">
<SettingsConfigurationPanel />
</Tab.Pane>
<Tab.Pane eventKey="interface">
<SettingsInterfacePanel />
</Tab.Pane>
<Tab.Pane eventKey="tasks">
<SettingsTasksPanel />
</Tab.Pane>
<Tab.Pane eventKey="logs">
<SettingsLogsPanel />
</Tab.Pane>
<Tab.Pane eventKey="about">
<SettingsAboutPanel />
</Tab.Pane>
</Tab.Content>
</Col>
</Row>
</Tab.Container>
</Card>
);
};

View File

@@ -0,0 +1,162 @@
import React from "react";
import { Button, Table } from "react-bootstrap";
import { LoadingIndicator } from "src/components/Shared";
import { StashService } from "src/core/StashService";
export const SettingsAboutPanel: React.FC = () => {
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?.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 (
<Table>
<tbody>
<tr>
<td>Latest Version Build Hash: </td>
<td>{maybeRenderLatestVersion()} </td>
</tr>
<tr>
<td>
<Button onClick={() => refetch()}>Check for new version</Button>
</td>
</tr>
</tbody>
</Table>
);
}
function renderVersion() {
if (!data || !data.version) {
return;
}
return (
<>
<Table>
<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>
</Table>
</>
);
}
return (
<>
<h4>About</h4>
<Table>
<tbody>
<tr>
<td>
Stash home at{" "}
<a
href="https://github.com/stashapp/stash"
rel="noopener noreferrer"
target="_blank"
>
Github
</a>
</td>
</tr>
<tr>
<td>
Stash{" "}
<a
href="https://github.com/stashapp/stash/wiki"
rel="noopener noreferrer"
target="_blank"
>
Wiki
</a>{" "}
page
</td>
</tr>
<tr>
<td>
Join our{" "}
<a
href="https://discord.gg/2TsNFKt"
rel="noopener noreferrer"
target="_blank"
>
Discord
</a>{" "}
channel
</td>
</tr>
<tr>
<td>
Support us through{" "}
<a
href="https://opencollective.com/stashapp"
rel="noopener noreferrer"
target="_blank"
>
Open Collective
</a>
</td>
</tr>
</tbody>
</Table>
{!data || loading ? <LoadingIndicator inline /> : ""}
{error && <span>{error.message}</span>}
{errorLatest && <span>{errorLatest.message}</span>}
{renderVersion()}
{!dataLatest || loadingLatest || networkStatus === 4 ? (
<LoadingIndicator inline />
) : (
renderLatestVersion()
)}
</>
);
};

View File

@@ -0,0 +1,393 @@
import React, { useEffect, useState } from "react";
import { Button, Form, InputGroup } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { useToast } from "src/hooks";
import { Icon, LoadingIndicator } from "src/components/Shared";
import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect";
export const SettingsConfigurationPanel: React.FC = () => {
const Toast = useToast();
// Editing config state
const [stashes, setStashes] = useState<string[]>([]);
const [databasePath, setDatabasePath] = useState<string | undefined>(
undefined
);
const [generatedPath, setGeneratedPath] = useState<string | undefined>(
undefined
);
const [maxTranscodeSize, setMaxTranscodeSize] = useState<
GQL.StreamingResolutionEnum | undefined
>(undefined);
const [maxStreamingTranscodeSize, setMaxStreamingTranscodeSize] = useState<
GQL.StreamingResolutionEnum | undefined
>(undefined);
const [username, setUsername] = useState<string | undefined>(undefined);
const [password, setPassword] = useState<string | undefined>(undefined);
const [logFile, setLogFile] = useState<string | undefined>();
const [logOut, setLogOut] = useState<boolean>(true);
const [logLevel, setLogLevel] = useState<string>("Info");
const [logAccess, setLogAccess] = useState<boolean>(true);
const [excludes, setExcludes] = useState<string[]>([]);
const { data, error, loading } = StashService.useConfiguration();
const [updateGeneralConfig] = StashService.useConfigureGeneral({
stashes,
databasePath,
generatedPath,
maxTranscodeSize,
maxStreamingTranscodeSize,
username,
password,
logFile,
logOut,
logLevel,
logAccess,
excludes
});
useEffect(() => {
if (!data?.configuration || error) return;
const conf = data.configuration;
if (conf.general) {
setStashes(conf.general.stashes ?? []);
setDatabasePath(conf.general.databasePath);
setGeneratedPath(conf.general.generatedPath);
setMaxTranscodeSize(conf.general.maxTranscodeSize ?? undefined);
setMaxStreamingTranscodeSize(
conf.general.maxStreamingTranscodeSize ?? undefined
);
setUsername(conf.general.username);
setPassword(conf.general.password);
setLogFile(conf.general.logFile ?? undefined);
setLogOut(conf.general.logOut);
setLogLevel(conf.general.logLevel);
setLogAccess(conf.general.logAccess);
setExcludes(conf.general.excludes);
}
}, [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();
// eslint-disable-next-line no-console
console.log(result);
Toast.success({ content: "Updated config" });
} catch (e) {
Toast.error(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;
}
if (error) return <h1>{error.message}</h1>;
if (!data?.configuration || loading) return <LoadingIndicator />;
return (
<>
<h4>Library</h4>
<Form.Group>
<Form.Group id="stashes">
<h6>Stashes</h6>
<FolderSelect
directories={stashes}
onDirectoriesChanged={onStashesChanged}
/>
<Form.Text className="text-muted">
Directory locations to your content
</Form.Text>
</Form.Group>
<Form.Group id="database-path">
<h6>Database Path</h6>
<Form.Control
className="col col-sm-6"
defaultValue={databasePath}
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setDatabasePath(e.currentTarget.value)
}
/>
<Form.Text className="text-muted">
File location for the SQLite database (requires restart)
</Form.Text>
</Form.Group>
<Form.Group id="generated-path">
<h6>Generated Path</h6>
<Form.Control
className="col col-sm-6"
defaultValue={generatedPath}
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setGeneratedPath(e.currentTarget.value)
}
/>
<Form.Text className="text-muted">
Directory location for the generated files (scene markers, scene
previews, sprites, etc)
</Form.Text>
</Form.Group>
<Form.Group>
<h6>Excluded Patterns</h6>
<Form.Group>
{excludes &&
excludes.map((regexp, i) => (
<InputGroup>
<Form.Control
className="col col-sm-6"
value={regexp}
onChange={(e: React.FormEvent<HTMLInputElement>) =>
excludeRegexChanged(i, e.currentTarget.value)
}
/>
<InputGroup.Append>
<Button
variant="danger"
onClick={() => excludeRemoveRegex(i)}
>
<Icon icon="minus" />
</Button>
</InputGroup.Append>
</InputGroup>
))}
</Form.Group>
<Button className="minimal" onClick={() => excludeAddRegex()}>
<Icon icon="plus" />
</Button>
<Form.Text>
<a
href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration"
rel="noopener noreferrer"
target="_blank"
>
<span>
Regexps of files/paths to exclude from Scan and add to Clean
</span>
<Icon icon="question-circle" />
</a>
</Form.Text>
</Form.Group>
</Form.Group>
<hr />
<Form.Group>
<h4>Video</h4>
<Form.Group id="transcode-size">
<h6>Maximum transcode size</h6>
<Form.Control
className="col col-sm-6"
as="select"
onChange={(event: React.FormEvent<HTMLSelectElement>) =>
setMaxTranscodeSize(translateQuality(event.currentTarget.value))
}
value={resolutionToString(maxTranscodeSize)}
>
{transcodeQualities.map(q => (
<option key={q} value={q}>
{q}
</option>
))}
</Form.Control>
<Form.Text className="text-muted">
Maximum size for generated transcodes
</Form.Text>
</Form.Group>
<Form.Group id="streaming-transcode-size">
<h6>Maximum streaming transcode size</h6>
<Form.Control
className="col col-sm-6"
as="select"
onChange={(event: React.FormEvent<HTMLSelectElement>) =>
setMaxStreamingTranscodeSize(
translateQuality(event.currentTarget.value)
)
}
value={resolutionToString(maxStreamingTranscodeSize)}
>
{transcodeQualities.map(q => (
<option key={q} value={q}>
{q}
</option>
))}
</Form.Control>
<Form.Text className="text-muted">
Maximum size for transcoded streams
</Form.Text>
</Form.Group>
</Form.Group>
<hr />
<Form.Group>
<h4>Authentication</h4>
<Form.Group id="username">
<h6>Username</h6>
<Form.Control
className="col col-sm-6"
defaultValue={username}
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setUsername(e.currentTarget.value)
}
/>
<Form.Text className="text-muted">
Username to access Stash. Leave blank to disable user authentication
</Form.Text>
</Form.Group>
<Form.Group id="password">
<h6>Password</h6>
<Form.Control
className="col col-sm-6"
type="password"
defaultValue={password}
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setPassword(e.currentTarget.value)
}
/>
<Form.Text className="text-muted">
Password to access Stash. Leave blank to disable user authentication
</Form.Text>
</Form.Group>
</Form.Group>
<hr />
<h4>Logging</h4>
<Form.Group id="log-file">
<h6>Log file</h6>
<Form.Control
className="col col-sm-6"
defaultValue={logFile}
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setLogFile(e.currentTarget.value)
}
/>
<Form.Text className="text-muted">
Path to the file to output logging to. Blank to disable file logging.
Requires restart.
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Check
id="log-terminal"
checked={logOut}
label="Log to terminal"
onChange={() => setLogOut(!logOut)}
/>
<Form.Text className="text-muted">
Logs to the terminal in addition to a file. Always true if file
logging is disabled. Requires restart.
</Form.Text>
</Form.Group>
<Form.Group id="log-level">
<h6>Log Level</h6>
<Form.Control
className="col col-sm-6"
as="select"
onChange={(event: React.FormEvent<HTMLSelectElement>) =>
setLogLevel(event.currentTarget.value)
}
value={logLevel}
>
{["Debug", "Info", "Warning", "Error"].map(o => (
<option key={o} value={o}>
{o}
</option>
))}
</Form.Control>
</Form.Group>
<Form.Group>
<Form.Check
id="log-http"
checked={logAccess}
label="Log http access"
onChange={() => setLogAccess(!logAccess)}
/>
<Form.Text className="text-muted">
Logs http access to the terminal. Requires restart.
</Form.Text>
</Form.Group>
<hr />
<Button variant="primary" onClick={() => onSave()}>
Save
</Button>
</>
);
};

View File

@@ -0,0 +1,161 @@
import React, { useEffect, useState } from "react";
import { Button, Form } from "react-bootstrap";
import { DurationInput, LoadingIndicator } from "src/components/Shared";
import { StashService } from "src/core/StashService";
import { useToast } from "src/hooks";
export const SettingsInterfacePanel: React.FC = () => {
const Toast = useToast();
const { data: config, error, loading } = StashService.useConfiguration();
const [soundOnPreview, setSoundOnPreview] = useState<boolean>(true);
const [wallShowTitle, setWallShowTitle] = useState<boolean>(true);
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
const [autostartVideo, setAutostartVideo] = useState<boolean>(false);
const [showStudioAsText, setShowStudioAsText] = useState<boolean>(false);
const [css, setCSS] = useState<string>();
const [cssEnabled, setCSSEnabled] = useState<boolean>(false);
const [language, setLanguage] = useState<string>("en");
const [updateInterfaceConfig] = StashService.useConfigureInterface({
soundOnPreview,
wallShowTitle,
maximumLoopDuration,
autostartVideo,
showStudioAsText,
css,
cssEnabled,
language
});
useEffect(() => {
const iCfg = config?.configuration?.interface;
setSoundOnPreview(iCfg?.soundOnPreview ?? true);
setWallShowTitle(iCfg?.wallShowTitle ?? true);
setMaximumLoopDuration(iCfg?.maximumLoopDuration ?? 0);
setAutostartVideo(iCfg?.autostartVideo ?? false);
setShowStudioAsText(iCfg?.showStudioAsText ?? false);
setCSS(iCfg?.css ?? "");
setCSSEnabled(iCfg?.cssEnabled ?? false);
setLanguage(iCfg?.language ?? "en-US");
}, [config]);
async function onSave() {
try {
const result = await updateInterfaceConfig();
// eslint-disable-next-line no-console
console.log(result);
Toast.success({ content: "Updated config" });
} catch (e) {
Toast.error(e);
}
}
if (error) return <h1>{error.message}</h1>;
if (loading) return <LoadingIndicator />;
return (
<>
<h4>User Interface</h4>
<Form.Group controlId="language" className="row">
<Form.Label className="col-2">Language</Form.Label>
<Form.Control
as="select"
className="col-4"
value={language}
onChange={(e: React.FormEvent<HTMLSelectElement>) =>
setLanguage(e.currentTarget.value)
}
>
<option value="en-US">English (United States)</option>
<option value="en-GB">English (United Kingdom)</option>
<option value="de-DE">Deutsch</option>
</Form.Control>
</Form.Group>
<Form.Group>
<Form.Label>Scene / Marker Wall</Form.Label>
<Form.Check
id="wall-show-title"
checked={wallShowTitle}
label="Display title and tags"
onChange={() => setWallShowTitle(!wallShowTitle)}
/>
<Form.Check
id="wall-sound-enabled"
checked={soundOnPreview}
label="Enable sound"
onChange={() => setSoundOnPreview(!soundOnPreview)}
/>
<Form.Text className="text-muted">
Configuration for wall items
</Form.Text>
</Form.Group>
<Form.Group>
<h5>Scene List</h5>
<Form.Check
id="show-text-studios"
checked={showStudioAsText}
label="Show Studios as text"
onChange={() => {
setShowStudioAsText(!showStudioAsText);
}}
/>
</Form.Group>
<Form.Group>
<h5>Scene Player</h5>
<Form.Check
id="auto-start-video"
checked={autostartVideo}
label="Auto-start video"
onChange={() => {
setAutostartVideo(!autostartVideo);
}}
/>
<Form.Group id="max-loop-duration">
<Form.Label>Maximum loop duration</Form.Label>
<DurationInput
className="col col-sm-4"
numericValue={maximumLoopDuration}
onValueChange={duration => setMaximumLoopDuration(duration)}
/>
<Form.Text className="text-muted">
Maximum scene duration where scene player will loop the video - 0 to
disable
</Form.Text>
</Form.Group>
</Form.Group>
<Form.Group>
<h5>Custom CSS</h5>
<Form.Check
id="custom-css"
checked={cssEnabled}
label="Custom CSS enabled"
onChange={() => {
setCSSEnabled(!cssEnabled);
}}
/>
<Form.Control
as="textarea"
value={css}
onChange={(e: React.FormEvent<HTMLTextAreaElement>) =>
setCSS(e.currentTarget.value)
}
rows={16}
className="col col-sm-6"
></Form.Control>
<Form.Text className="text-muted">
Page must be reloaded for changes to take effect.
</Form.Text>
</Form.Group>
<hr />
<Button variant="primary" onClick={() => onSave()}>
Save
</Button>
</>
);
};

View File

@@ -0,0 +1,124 @@
import React, { useState } from "react";
import { Form } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
function convertTime(logEntry: GQL.LogEntryDataFragment) {
function pad(val: number) {
let ret = val.toString();
if (val <= 9) {
ret = `0${ret}`;
}
return ret;
}
const date = new Date(logEntry.time);
const month = date.getMonth() + 1;
const day = date.getDate();
let dateStr = `${date.getFullYear()}-${pad(month)}-${pad(day)}`;
dateStr += ` ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
date.getSeconds()
)}`;
return dateStr;
}
function levelClass(level: string) {
return level.toLowerCase().trim();
}
interface ILogElementProps {
logEntry: LogEntry;
}
const LogElement: React.FC<ILogElementProps> = ({ logEntry }) => {
// pad to maximum length of level enum
const level = logEntry.level.padEnd(GQL.LogLevel.Progress.length);
return (
<div className="row">
<span className="log-time">{logEntry.time}</span>
<span className={`${levelClass(logEntry.level)}`}>{level}</span>
<span className="col col-sm-9">{logEntry.message}</span>
</div>
);
};
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;
const id = LogEntry.nextId++;
this.id = id.toString();
}
}
// maximum number of log entries to display. Subsequent entries will truncate
// the list, dropping off the oldest entries first.
const MAX_LOG_ENTRIES = 200;
const logLevels = ["Debug", "Info", "Warning", "Error"];
export const SettingsLogsPanel: React.FC = () => {
const { data, error } = StashService.useLoggingSubscribe();
const { data: existingData } = StashService.useLogs();
const [logLevel, setLogLevel] = useState<string>("Info");
const oldData = (existingData?.logs ?? []).map(e => new LogEntry(e));
const newData = (data?.loggingSubscribe ?? []).map(e => new LogEntry(e));
const filteredLogEntries = [...newData.reverse(), ...oldData]
.filter(filterByLogLevel)
.slice(0, MAX_LOG_ENTRIES);
const maybeRenderError = error ? (
<div className="error">Error connecting to log server: {error.message}</div>
) : (
""
);
function filterByLogLevel(logEntry: LogEntry) {
if (logLevel === "Debug") return true;
const logLevelIndex = logLevels.indexOf(logLevel);
const levelIndex = logLevels.indexOf(logEntry.level);
return levelIndex >= logLevelIndex;
}
return (
<>
<h4>Logs</h4>
<Form.Row id="log-level">
<Form.Label className="col-6 col-sm-2">Log Level</Form.Label>
<Form.Control
className="col-6 col-sm-2"
as="select"
defaultValue={logLevel}
onChange={event => setLogLevel(event.currentTarget.value)}
>
{logLevels.map(level => (
<option key={level} value={level}>
{level}
</option>
))}
</Form.Control>
</Form.Row>
<div className="logs">
{maybeRenderError}
{filteredLogEntries.map(logEntry => (
<LogElement logEntry={logEntry} key={logEntry.id} />
))}
</div>
</>
);
};

View File

@@ -0,0 +1,70 @@
import React, { useState } from "react";
import { Button, Form } from "react-bootstrap";
import { StashService } from "src/core/StashService";
import { useToast } from "src/hooks";
export const GenerateButton: React.FC = () => {
const Toast = useToast();
const [sprites, setSprites] = useState(true);
const [previews, setPreviews] = useState(true);
const [markers, setMarkers] = useState(true);
const [transcodes, setTranscodes] = useState(true);
async function onGenerate() {
try {
await StashService.queryMetadataGenerate({
sprites,
previews,
markers,
transcodes
});
Toast.success({ content: "Started generating" });
} catch (e) {
Toast.error(e);
}
}
return (
<>
<Form.Group>
<Form.Check
id="sprite-task"
checked={sprites}
label="Sprites (for the scene scrubber)"
onChange={() => setSprites(!sprites)}
/>
<Form.Check
id="preview-task"
checked={previews}
label="Previews (video previews which play when hovering over a scene)"
onChange={() => setPreviews(!previews)}
/>
<Form.Check
id="marker-task"
checked={markers}
label="Markers (20 second videos which begin at the given timecode)"
onChange={() => setMarkers(!markers)}
/>
<Form.Check
id="transcode-task"
checked={transcodes}
label="Transcodes (MP4 conversions of unsupported video formats)"
onChange={() => setTranscodes(!transcodes)}
/>
</Form.Group>
<Form.Group>
<Button
id="generate"
variant="secondary"
type="submit"
onClick={() => onGenerate()}
>
Generate
</Button>
<Form.Text className="text-muted">
Generate supporting image, sprite, video, vtt and other files.
</Form.Text>
</Form.Group>
</>
);
};

View File

@@ -0,0 +1,302 @@
import React, { useState, useEffect } from "react";
import { Button, Form, ProgressBar } from "react-bootstrap";
import { Link } from "react-router-dom";
import { StashService } from "src/core/StashService";
import { useToast } from "src/hooks";
import { Modal } from "src/components/Shared";
import { GenerateButton } from "./GenerateButton";
export const SettingsTasksPanel: React.FC = () => {
const Toast = useToast();
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>(0);
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(s: string) {
switch (s) {
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";
default:
return "Idle";
}
}
useEffect(() => {
if (jobStatus?.data?.jobStatus) {
setStatus(statusToText(jobStatus.data.jobStatus.status));
const newProgress = jobStatus.data.jobStatus.progress;
if (newProgress < 0) {
setProgress(0);
} else {
setProgress(newProgress * 100);
}
}
}, [jobStatus]);
useEffect(() => {
if (metadataUpdate?.data?.metadataUpdate) {
setStatus(statusToText(metadataUpdate.data.metadataUpdate.status));
const newProgress = metadataUpdate.data.metadataUpdate.progress;
if (newProgress < 0) {
setProgress(0);
} else {
setProgress(newProgress * 100);
}
}
}, [metadataUpdate]);
function onImport() {
setIsImportAlertOpen(false);
StashService.queryMetadataImport().then(() => {
jobStatus.refetch();
});
}
function renderImportAlert() {
return (
<Modal
show={isImportAlertOpen}
icon="trash-alt"
accept={{ text: "Import", variant: "danger", onClick: onImport }}
cancel={{ onClick: () => setIsImportAlertOpen(false) }}
>
<p>
Are you sure you want to import? This will delete the database and
re-import from your exported metadata.
</p>
</Modal>
);
}
function onClean() {
setIsCleanAlertOpen(false);
StashService.queryMetadataClean().then(() => {
jobStatus.refetch();
});
}
function renderCleanAlert() {
return (
<Modal
show={isCleanAlertOpen}
icon="trash-alt"
accept={{ text: "Clean", variant: "danger", onClick: onClean }}
cancel={{ onClick: () => setIsCleanAlertOpen(false) }}
>
<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>
</Modal>
);
}
async function onScan() {
try {
await StashService.queryMetadataScan({ useFileMetadata });
Toast.success({ content: "Started scan" });
jobStatus.refetch();
} catch (e) {
Toast.error(e);
}
}
function getAutoTagInput() {
const wildcard = ["*"];
return {
performers: autoTagPerformers ? wildcard : [],
studios: autoTagStudios ? wildcard : [],
tags: autoTagTags ? wildcard : []
};
}
async function onAutoTag() {
try {
await StashService.queryMetadataAutoTag(getAutoTagInput());
Toast.success({ content: "Started auto tagging" });
jobStatus.refetch();
} catch (e) {
Toast.error(e);
}
}
function maybeRenderStop() {
if (!status || status === "Idle") {
return undefined;
}
return (
<Form.Group>
<Button
id="stop"
variant="danger"
onClick={() =>
StashService.queryStopJob().then(() => jobStatus.refetch())
}
>
Stop
</Button>
</Form.Group>
);
}
function renderJobStatus() {
return (
<>
<Form.Group>
<h5>Status: {status}</h5>
{status !== "Idle" ? (
<ProgressBar now={progress} label={`${progress}%`} />
) : (
""
)}
</Form.Group>
{maybeRenderStop()}
</>
);
}
return (
<>
{renderImportAlert()}
{renderCleanAlert()}
<h5>Running Jobs</h5>
{renderJobStatus()}
<hr />
<h5>Library</h5>
<Form.Group>
<Form.Check
id="use-file-metadata"
checked={useFileMetadata}
label="Set name, date, details from metadata (if present)"
onChange={() => setUseFileMetadata(!useFileMetadata)}
/>
</Form.Group>
<Form.Group>
<Button variant="secondary" type="submit" onClick={() => onScan()}>
Scan
</Button>
<Form.Text className="text-muted">
Scan for new content and add it to the database.
</Form.Text>
</Form.Group>
<hr />
<h5>Auto Tagging</h5>
<Form.Group>
<Form.Check
id="autotag-performers"
checked={autoTagPerformers}
label="Performers"
onChange={() => setAutoTagPerformers(!autoTagPerformers)}
/>
<Form.Check
id="autotag-studios"
checked={autoTagStudios}
label="Studios"
onChange={() => setAutoTagStudios(!autoTagStudios)}
/>
<Form.Check
id="autotag-tags"
checked={autoTagTags}
label="Tags"
onChange={() => setAutoTagTags(!autoTagTags)}
/>
</Form.Group>
<Form.Group>
<Button variant="secondary" type="submit" onClick={() => onAutoTag()}>
Auto Tag
</Button>
<Form.Text className="text-muted">
Auto-tag content based on filenames.
</Form.Text>
</Form.Group>
<Form.Group>
<Link to="/sceneFilenameParser">
<Button variant="secondary">Scene Filename Parser</Button>
</Link>
</Form.Group>
<hr />
<h5>Generated Content</h5>
<GenerateButton />
<Form.Group>
<Button
id="clean"
variant="danger"
onClick={() => setIsCleanAlertOpen(true)}
>
Clean
</Button>
<Form.Text className="text-muted">
Check for missing files and remove them from the database. This is a
destructive action.
</Form.Text>
</Form.Group>
<hr />
<h5>Metadata</h5>
<Form.Group>
<Button
id="export"
variant="secondary"
type="submit"
onClick={() =>
StashService.queryMetadataExport().then(() => {
jobStatus.refetch();
})
}
>
Export
</Button>
<Form.Text className="text-muted">
Export the database content into JSON format.
</Form.Text>
</Form.Group>
<Form.Group>
<Button
id="import"
variant="danger"
onClick={() => setIsImportAlertOpen(true)}
>
Import
</Button>
<Form.Text className="text-muted">
Import from exported JSON. This is a destructive action.
</Form.Text>
</Form.Group>
</>
);
};

View File

@@ -0,0 +1,33 @@
.logs {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: smaller;
max-height: 100vh;
overflow-x: hidden;
overflow-y: auto;
padding-top: 1rem;
white-space: pre-wrap;
.debug {
color: lightgreen;
font-weight: bold;
}
.info {
color: white;
font-weight: bold;
}
.warning {
color: orange;
font-weight: bold;
}
.error {
color: red;
font-weight: bold;
}
}
.log-time {
margin-right: 1rem;
}

View File

@@ -0,0 +1,110 @@
import { Button, Modal } from "react-bootstrap";
import React, { useState } from "react";
import * as GQL from "src/core/generated-graphql";
import { ImageInput } from "src/components/Shared";
interface IProps {
performer?: Partial<GQL.PerformerDataFragment>;
studio?: Partial<GQL.StudioDataFragment>;
isNew: boolean;
isEditing: boolean;
onToggleEdit: () => void;
onSave: () => void;
onDelete: () => void;
onAutoTag?: () => void;
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
}
export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
function renderEditButton() {
if (props.isNew) return;
return (
<Button
variant="primary"
className="edit"
onClick={() => props.onToggleEdit()}
>
{props.isEditing ? "Cancel" : "Edit"}
</Button>
);
}
function renderSaveButton() {
if (!props.isEditing) return;
return (
<Button variant="success" className="save" onClick={() => props.onSave()}>
Save
</Button>
);
}
function renderDeleteButton() {
if (props.isNew || props.isEditing) return;
return (
<Button
variant="danger"
className="delete d-none d-sm-block"
onClick={() => setIsDeleteAlertOpen(true)}
>
Delete
</Button>
);
}
function renderAutoTagButton() {
if (props.isNew || props.isEditing) return;
if (props.onAutoTag) {
return (
<Button
variant="secondary"
onClick={() => {
if (props.onAutoTag) {
props.onAutoTag();
}
}}
>
Auto Tag
</Button>
);
}
}
function renderDeleteAlert() {
const name = props?.studio?.name ?? props?.performer?.name;
return (
<Modal show={isDeleteAlertOpen}>
<Modal.Body>Are you sure you want to delete {name}?</Modal.Body>
<Modal.Footer>
<Button variant="danger" onClick={props.onDelete}>
Delete
</Button>
<Button
variant="secondary"
onClick={() => setIsDeleteAlertOpen(false)}
>
Cancel
</Button>
</Modal.Footer>
</Modal>
);
}
return (
<div className="details-edit">
{renderEditButton()}
<ImageInput
isEditing={props.isEditing}
onImageChange={props.onImageChange}
/>
{renderAutoTagButton()}
{renderSaveButton()}
{renderDeleteButton()}
{renderDeleteAlert()}
</div>
);
};

View File

@@ -0,0 +1,96 @@
import React, { useState, useEffect } from "react";
import { Button, ButtonGroup, InputGroup, Form } from "react-bootstrap";
import { Icon } from "src/components/Shared";
import { DurationUtils } from "src/utils";
interface IProps {
disabled?: boolean;
numericValue: number;
onValueChange(valueAsNumber: number): void;
onReset?(): void;
className?: string;
}
export const DurationInput: React.FC<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>
<Button
variant="secondary"
className="duration-button"
disabled={props.disabled}
onClick={() => increment()}
>
<Icon icon="chevron-up" />
</Button>
<Button
variant="secondary"
className="duration-button"
disabled={props.disabled}
onClick={() => decrement()}
>
<Icon icon="chevron-down" />
</Button>
</ButtonGroup>
);
}
function onReset() {
if (props.onReset) {
props.onReset();
}
}
function maybeRenderReset() {
if (props.onReset) {
return (
<Button variant="secondary" onClick={onReset}>
<Icon icon="clock" />
</Button>
);
}
}
return (
<Form.Group className={`duration-input ${props.className}`}>
<InputGroup>
<Form.Control
className="duration-control"
disabled={props.disabled}
value={value}
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setValue(e.currentTarget.value)
}
onBlur={() =>
props.onValueChange(DurationUtils.stringToSeconds(value))
}
placeholder="hh:mm:ss"
/>
<InputGroup.Append>
{maybeRenderReset()}
{renderButtons()}
</InputGroup.Append>
</InputGroup>
</Form.Group>
);
};

View File

@@ -0,0 +1,115 @@
import React, { useEffect, useState } from "react";
import { Button, InputGroup, Form, Modal } from "react-bootstrap";
import { LoadingIndicator } from "src/components/Shared";
import { StashService } from "src/core/StashService";
interface IProps {
directories: string[];
onDirectoriesChanged: (directories: string[]) => void;
}
export const FolderSelect: React.FC<IProps> = (props: IProps) => {
const [currentDirectory, setCurrentDirectory] = useState<string>("");
const [isDisplayingDialog, setIsDisplayingDialog] = useState<boolean>(false);
const [selectedDirectories, setSelectedDirectories] = useState<string[]>([]);
const { data, error, loading } = StashService.useDirectories(
currentDirectory
);
useEffect(() => {
setSelectedDirectories(props.directories);
}, [props.directories]);
const selectableDirectories: string[] = data?.directories ?? [];
function onSelectDirectory() {
selectedDirectories.push(currentDirectory);
setSelectedDirectories(selectedDirectories);
setCurrentDirectory("");
setIsDisplayingDialog(false);
props.onDirectoriesChanged(selectedDirectories);
}
function onRemoveDirectory(directory: string) {
const newSelectedDirectories = selectedDirectories.filter(
dir => dir !== directory
);
setSelectedDirectories(newSelectedDirectories);
props.onDirectoriesChanged(newSelectedDirectories);
}
function renderDialog() {
return (
<Modal
show={isDisplayingDialog}
onHide={() => setIsDisplayingDialog(false)}
title=""
>
<Modal.Header>Select Directory</Modal.Header>
<Modal.Body>
<div className="dialog-content">
<InputGroup>
<Form.Control
placeholder="File path"
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setCurrentDirectory(e.currentTarget.value)
}
defaultValue={currentDirectory}
/>
<InputGroup.Append>
{!data || !data.directories || loading ? (
<LoadingIndicator inline />
) : (
""
)}
</InputGroup.Append>
</InputGroup>
<ul className="folder-list">
{selectableDirectories.map(path => {
return (
<li key={path} className="folder-item">
<Button
variant="link"
key={path}
onClick={() => setCurrentDirectory(path)}
>
{path}
</Button>
</li>
);
})}
</ul>
</div>
</Modal.Body>
<Modal.Footer>
<Button variant="success" onClick={() => onSelectDirectory()}>
Add
</Button>
</Modal.Footer>
</Modal>
);
}
return (
<>
{error ? <h1>{error.message}</h1> : ""}
{renderDialog()}
<Form.Group>
{selectedDirectories.map(path => {
return (
<div key={path}>
{path}{" "}
<Button variant="link" onClick={() => onRemoveDirectory(path)}>
Remove
</Button>
</div>
);
})}
</Form.Group>
<Button variant="secondary" onClick={() => setIsDisplayingDialog(true)}>
Add Directory
</Button>
</>
);
};

View File

@@ -0,0 +1,76 @@
import React, { useState, useCallback, useEffect, useRef } from "react";
import { Overlay, Popover, OverlayProps } from "react-bootstrap";
interface IHoverPopover {
enterDelay?: number;
leaveDelay?: number;
content: JSX.Element[] | JSX.Element | string;
className?: string;
placement?: OverlayProps["placement"];
onOpen?: () => void;
onClose?: () => void;
}
export const HoverPopover: React.FC<IHoverPopover> = ({
enterDelay = 0,
leaveDelay = 400,
content,
children,
className,
placement = "top",
onOpen,
onClose
}) => {
const [show, setShow] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const enterTimer = useRef<number>();
const leaveTimer = useRef<number>();
const handleMouseEnter = useCallback(() => {
window.clearTimeout(leaveTimer.current);
enterTimer.current = window.setTimeout(() => {
setShow(true);
onOpen?.();
}, enterDelay);
}, [enterDelay, onOpen]);
const handleMouseLeave = useCallback(() => {
window.clearTimeout(enterTimer.current);
leaveTimer.current = window.setTimeout(() => {
setShow(false);
onClose?.();
}, leaveDelay);
}, [leaveDelay, onClose]);
useEffect(
() => () => {
window.clearTimeout(enterTimer.current);
window.clearTimeout(leaveTimer.current);
},
[]
);
return (
<>
<div
className={className}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={triggerRef}
>
{children}
</div>
{triggerRef.current && (
<Overlay show={show} placement={placement} target={triggerRef.current}>
<Popover
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
id="popover"
>
{content}
</Popover>
</Overlay>
)}
</>
);
};

View File

@@ -0,0 +1,19 @@
import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconName } from "@fortawesome/fontawesome-svg-core";
interface IIcon {
icon: IconName;
className?: string;
color?: string;
}
const Icon: React.FC<IIcon> = ({ icon, className, color }) => (
<FontAwesomeIcon
icon={icon}
className={`fa-icon ${className}`}
color={color}
/>
);
export default Icon;

View File

@@ -0,0 +1,25 @@
import React from "react";
import { Button, Form } from "react-bootstrap";
interface IImageInput {
isEditing: boolean;
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
}
export const ImageInput: React.FC<IImageInput> = ({
isEditing,
onImageChange
}) => {
if (!isEditing) return <div />;
return (
<Form.Label className="image-input">
<Button variant="secondary">Browse for image...</Button>
<Form.Control
type="file"
onChange={onImageChange}
accept=".jpg,.jpeg,.png"
/>
</Form.Label>
);
};

View File

@@ -0,0 +1,25 @@
import React from "react";
import { Spinner } from "react-bootstrap";
import cx from "classnames";
interface ILoadingProps {
message?: string;
inline?: boolean;
}
const CLASSNAME = "LoadingIndicator";
const CLASSNAME_MESSAGE = `${CLASSNAME}-message`;
const LoadingIndicator: React.FC<ILoadingProps> = ({
message,
inline = false
}) => (
<div className={cx(CLASSNAME, { inline })}>
<Spinner animation="border" role="status">
<span className="sr-only">Loading...</span>
</Spinner>
<h4 className={CLASSNAME_MESSAGE}>{message ?? "Loading..."}</h4>
</div>
);
export default LoadingIndicator;

View File

@@ -0,0 +1,59 @@
import React from "react";
import { Button, Modal } from "react-bootstrap";
import { Icon } from "src/components/Shared";
import { IconName } from "@fortawesome/fontawesome-svg-core";
interface IButton {
text?: string;
variant?: "danger" | "primary";
onClick?: () => void;
}
interface IModal {
show: boolean;
onHide?: () => void;
header?: string;
icon?: IconName;
cancel?: IButton;
accept?: IButton;
}
const ModalComponent: React.FC<IModal> = ({
children,
show,
icon,
header,
cancel,
accept,
onHide
}) => (
<Modal keyboard={false} onHide={onHide} show={show}>
<Modal.Header>
{icon ? <Icon icon={icon} /> : ""}
<span>{header ?? ""}</span>
</Modal.Header>
<Modal.Body>{children}</Modal.Body>
<Modal.Footer>
<div>
{cancel ? (
<Button
variant={cancel.variant ?? "primary"}
onClick={cancel.onClick}
>
{cancel.text ?? "Cancel"}
</Button>
) : (
""
)}
<Button
variant={accept?.variant ?? "primary"}
onClick={accept?.onClick}
>
{accept?.text ?? "Close"}
</Button>
</div>
</Modal.Footer>
</Modal>
);
export default ModalComponent;

View File

@@ -0,0 +1,387 @@
import React, { useState, useCallback, CSSProperties } from "react";
import Select, { ValueType } from "react-select";
import CreatableSelect from "react-select/creatable";
import { debounce } from "lodash";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { useToast } from "src/hooks";
type ValidTypes =
| GQL.SlimPerformerDataFragment
| GQL.Tag
| GQL.SlimStudioDataFragment;
type Option = { value: string; label: string };
interface ITypeProps {
type?: "performers" | "studios" | "tags";
}
interface IFilterProps {
ids?: string[];
initialIds?: string[];
onSelect?: (item: ValidTypes[]) => void;
noSelectionString?: string;
className?: string;
isMulti?: boolean;
isClearable?: boolean;
isDisabled?: boolean;
}
interface ISelectProps {
className?: string;
items: Option[];
selectedOptions?: Option[];
creatable?: boolean;
onCreateOption?: (value: string) => void;
isLoading: boolean;
isDisabled?: boolean;
onChange: (item: ValueType<Option>) => void;
initialIds?: string[];
isMulti?: boolean;
isClearable?: boolean;
onInputChange?: (input: string) => void;
placeholder?: string;
showDropdown?: boolean;
groupHeader?: string;
}
interface ISceneGallerySelect {
initialId?: string;
sceneId: string;
onSelect: (
item:
| GQL.ValidGalleriesForSceneQuery["validGalleriesForScene"][0]
| undefined
) => void;
}
const getSelectedValues = (selectedItems: ValueType<Option>) =>
selectedItems
? (Array.isArray(selectedItems) ? selectedItems : [selectedItems]).map(
item => item.value
)
: [];
export const SceneGallerySelect: React.FC<ISceneGallerySelect> = props => {
const { data, loading } = StashService.useValidGalleriesForScene(
props.sceneId
);
const galleries = data?.validGalleriesForScene ?? [];
const items = (galleries.length > 0
? [{ path: "None", id: "0" }, ...galleries]
: []
).map(g => ({ label: g.path, value: g.id }));
const onChange = (selectedItems: ValueType<Option>) => {
const selectedItem = getSelectedValues(selectedItems)[0];
props.onSelect(galleries.find(g => g.id === selectedItem.value));
};
const initialId = props.initialId ? [props.initialId] : [];
return (
<SelectComponent
onChange={onChange}
isLoading={loading}
items={items}
initialIds={initialId}
/>
);
};
interface IScrapePerformerSuggestProps {
scraperId: string;
onSelectPerformer: (performer: GQL.ScrapedPerformerDataFragment) => void;
placeholder?: string;
}
export const ScrapePerformerSuggest: React.FC<IScrapePerformerSuggestProps> = props => {
const [query, setQuery] = React.useState<string>("");
const { data, loading } = StashService.useScrapePerformerList(
props.scraperId,
query
);
const performers = data?.scrapePerformerList ?? [];
const items = performers.map(item => ({
label: item.name ?? "",
value: item.name ?? ""
}));
const onInputChange = useCallback(
debounce((input: string) => {
setQuery(input);
}, 500),
[]
);
const onChange = (selectedItems: ValueType<Option>) => {
const name = getSelectedValues(selectedItems)[0];
const performer = performers.find(p => p.name === name);
if (performer) props.onSelectPerformer(performer);
};
return (
<SelectComponent
onChange={onChange}
onInputChange={onInputChange}
isLoading={loading}
items={items}
initialIds={[]}
placeholder={props.placeholder}
className="select-suggest"
showDropdown={false}
/>
);
};
interface IMarkerSuggestProps {
initialMarkerTitle?: string;
onChange: (title: string) => void;
}
export const MarkerTitleSuggest: React.FC<IMarkerSuggestProps> = props => {
const { data, loading } = StashService.useMarkerStrings();
const suggestions = data?.markerStrings ?? [];
const onChange = (selectedItems: ValueType<Option>) =>
props.onChange(getSelectedValues(selectedItems)[0]);
const items = suggestions.map(item => ({
label: item?.title ?? "",
value: item?.title ?? ""
}));
const initialIds = props.initialMarkerTitle ? [props.initialMarkerTitle] : [];
return (
<SelectComponent
creatable
onChange={onChange}
isLoading={loading}
items={items}
initialIds={initialIds}
placeholder="Marker title..."
className="select-suggest"
showDropdown={false}
groupHeader="Previously used titles..."
/>
);
};
export const FilterSelect: React.FC<IFilterProps & ITypeProps> = props =>
props.type === "performers" ? (
<PerformerSelect {...(props as IFilterProps)} />
) : props.type === "studios" ? (
<StudioSelect {...(props as IFilterProps)} />
) : (
<TagSelect {...(props as IFilterProps)} />
);
export const PerformerSelect: React.FC<IFilterProps> = props => {
const { data, loading } = StashService.useAllPerformersForFilter();
const normalizedData = data?.allPerformers ?? [];
const items: Option[] = normalizedData.map(item => ({
value: item.id,
label: item.name ?? ""
}));
const placeholder = props.noSelectionString ?? "Select performer...";
const selectedOptions: Option[] = props.ids
? items.filter(item => props.ids?.indexOf(item.value) !== -1)
: [];
const onChange = (selectedItems: ValueType<Option>) => {
const selectedIds = getSelectedValues(selectedItems);
props.onSelect?.(
normalizedData.filter(item => selectedIds.indexOf(item.id) !== -1)
);
};
return (
<SelectComponent
{...props}
selectedOptions={selectedOptions}
onChange={onChange}
type="performers"
isLoading={loading}
items={items}
placeholder={placeholder}
/>
);
};
export const StudioSelect: React.FC<IFilterProps> = props => {
const { data, loading } = StashService.useAllStudiosForFilter();
const normalizedData = data?.allStudios ?? [];
const items: Option[] = normalizedData.map(item => ({
value: item.id,
label: item.name
}));
const placeholder = props.noSelectionString ?? "Select studio...";
const selectedOptions: Option[] = props.ids
? items.filter(item => props.ids?.indexOf(item.value) !== -1)
: [];
const onChange = (selectedItems: ValueType<Option>) => {
const selectedIds = getSelectedValues(selectedItems);
props.onSelect?.(
normalizedData.filter(item => selectedIds.indexOf(item.id) !== -1)
);
};
return (
<SelectComponent
{...props}
onChange={onChange}
type="studios"
isLoading={loading}
items={items}
placeholder={placeholder}
selectedOptions={selectedOptions}
/>
);
};
export const TagSelect: React.FC<IFilterProps> = props => {
const [loading, setLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>(props.ids ?? []);
const { data, loading: dataLoading } = StashService.useAllTagsForFilter();
const [createTag] = StashService.useTagCreate({ name: "" });
const Toast = useToast();
const placeholder = props.noSelectionString ?? "Select tags...";
const selectedTags = props.ids ?? selectedIds;
const tags = data?.allTags ?? [];
const selected = tags
.filter(tag => selectedTags.indexOf(tag.id) !== -1)
.map(tag => ({ value: tag.id, label: tag.name }));
const items: Option[] = tags.map(item => ({
value: item.id,
label: item.name
}));
const onCreate = async (tagName: string) => {
try {
setLoading(true);
const result = await createTag({
variables: { name: tagName }
});
if (result?.data?.tagCreate) {
setSelectedIds([...selectedIds, result.data.tagCreate.id]);
props.onSelect?.(
[...tags, result.data.tagCreate].filter(
item => selectedIds.indexOf(item.id) !== -1
)
);
setLoading(false);
Toast.success({
content: (
<span>
Created tag: <b>{tagName}</b>
</span>
)
});
}
} catch (e) {
Toast.error(e);
}
};
const onChange = (selectedItems: ValueType<Option>) => {
const selectedValues = getSelectedValues(selectedItems);
setSelectedIds(selectedValues);
props.onSelect?.(
tags.filter(item => selectedValues.indexOf(item.id) !== -1)
);
};
return (
<SelectComponent
{...props}
onChange={onChange}
creatable
type="tags"
placeholder={placeholder}
isLoading={loading || dataLoading}
items={items}
onCreateOption={onCreate}
selectedOptions={selected}
/>
);
};
const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
type,
initialIds,
onChange,
className,
items,
selectedOptions,
isLoading,
isDisabled = false,
onCreateOption,
isClearable = true,
creatable = false,
isMulti = false,
onInputChange,
placeholder,
showDropdown = true,
groupHeader
}) => {
const defaultValue =
items.filter(item => initialIds?.indexOf(item.value) !== -1) ?? null;
const options = groupHeader
? [
{
label: groupHeader,
options: items
}
]
: items;
const styles = {
option: (base: CSSProperties) => ({
...base,
color: "#000"
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
container: (base: CSSProperties, state: any) => ({
...base,
zIndex: state.isFocused ? 10 : base.zIndex
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
multiValueRemove: (base: CSSProperties, state: any) => ({
...base,
color: state.isFocused ? base.color : "#333333"
})
};
const props = {
options,
value: selectedOptions,
className,
onChange,
isMulti,
isClearable,
defaultValue,
noOptionsMessage: () => (type !== "tags" ? "None" : null),
placeholder: isDisabled ? "" : placeholder,
onInputChange,
isDisabled,
isLoading,
styles,
components: {
IndicatorSeparator: () => null,
...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }),
...(isDisabled && { MultiValueRemove: () => null })
}
};
return creatable ? (
<CreatableSelect
{...props}
isDisabled={isLoading || isDisabled}
onCreateOption={onCreateOption}
/>
) : (
<Select {...props} />
);
};

View File

@@ -0,0 +1,23 @@
import React from "react";
export const SweatDrops = () => (
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
focusable="false"
width="1em"
height="1em"
style={{ transform: "rotate(360deg)" }}
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 36 36"
>
<path
fill="currentColor"
d="M22.855.758L7.875 7.024l12.537 9.733c2.633 2.224 6.377 2.937 9.77 1.518c4.826-2.018 7.096-7.576 5.072-12.413C33.232 1.024 27.68-1.261 22.855.758zm-9.962 17.924L2.05 10.284L.137 23.529a7.993 7.993 0 0 0 2.958 7.803a8.001 8.001 0 0 0 9.798-12.65zm15.339 7.015l-8.156-4.69l-.033 9.223c-.088 2 .904 3.98 2.75 5.041a5.462 5.462 0 0 0 7.479-2.051c1.499-2.644.589-6.013-2.04-7.523z"
/>
<rect x="0" y="0" width="36" height="36" fill="rgba(0, 0, 0, 0)" />
</svg>
</span>
);

View File

@@ -0,0 +1,38 @@
import { Badge } from "react-bootstrap";
import React from "react";
import { Link } from "react-router-dom";
import {
PerformerDataFragment,
SceneMarkerDataFragment,
TagDataFragment
} from "src/core/generated-graphql";
import { NavUtils, TextUtils } from "src/utils";
interface IProps {
tag?: Partial<TagDataFragment>;
performer?: Partial<PerformerDataFragment>;
marker?: Partial<SceneMarkerDataFragment>;
className?: string;
}
export const TagLink: React.FC<IProps> = (props: IProps) => {
let link: string = "#";
let title: string = "";
if (props.tag) {
link = NavUtils.makeTagScenesUrl(props.tag);
title = props.tag.name || "";
} else if (props.performer) {
link = NavUtils.makePerformerScenesUrl(props.performer);
title = props.performer.name || "";
} else if (props.marker) {
link = NavUtils.makeSceneMarkerUrl(props.marker);
title = `${props.marker.title} - ${TextUtils.secondsToTimestamp(
props.marker.seconds || 0
)}`;
}
return (
<Badge className={`tag-item ${props.className}`} variant="secondary">
<Link to={link}>{title}</Link>
</Badge>
);
};

View File

@@ -0,0 +1,19 @@
export {
SceneGallerySelect,
ScrapePerformerSuggest,
MarkerTitleSuggest,
FilterSelect,
PerformerSelect,
StudioSelect,
TagSelect
} from "./Select";
export { default as Icon } from "./Icon";
export { default as Modal } from "./Modal";
export { DetailsEditNavbar } from "./DetailsEditNavbar";
export { DurationInput } from "./DurationInput";
export { TagLink } from "./TagLink";
export { HoverPopover } from "./HoverPopover";
export { default as LoadingIndicator } from "./LoadingIndicator";
export { ImageInput } from "./ImageInput";
export { SweatDrops } from "./SweatDrops";

View File

@@ -0,0 +1,70 @@
.LoadingIndicator {
align-items: center;
display: flex;
flex-direction: column;
height: 70vh;
justify-content: center;
width: 100%;
&-message {
margin-top: 1rem;
}
.spinner-border {
height: 3rem;
width: 3rem;
}
&.inline {
height: inherit;
}
}
.details-edit {
display: flex;
justify-content: left;
.btn {
margin-right: .5rem;
}
.delete,
.save {
margin-left: auto;
}
}
.select-suggest {
&:hover {
cursor: text;
}
}
.duration-input {
.duration-control {
min-width: 3rem;
}
.duration-button {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
line-height: 10px;
padding: 1px 7px;
}
.btn + .btn {
margin-left: 0;
}
}
.folder-list {
margin-top: .5rem 0 0 0;
max-height: 30vw;
overflow-x: auto;
}
.folder-item {
button {
padding: 0;
}
}

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