mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
Add new v2.5 UI (#357)
This commit is contained in:
@@ -21,6 +21,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
|
|||||||
showStudioAsText
|
showStudioAsText
|
||||||
css
|
css
|
||||||
cssEnabled
|
cssEnabled
|
||||||
|
language
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment ConfigData on ConfigResult {
|
fragment ConfigData on ConfigResult {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ input ConfigInterfaceInput {
|
|||||||
"""Custom CSS"""
|
"""Custom CSS"""
|
||||||
css: String
|
css: String
|
||||||
cssEnabled: Boolean
|
cssEnabled: Boolean
|
||||||
|
language: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigInterfaceResult {
|
type ConfigInterfaceResult {
|
||||||
@@ -91,6 +92,8 @@ type ConfigInterfaceResult {
|
|||||||
"""Custom CSS"""
|
"""Custom CSS"""
|
||||||
css: String
|
css: String
|
||||||
cssEnabled: Boolean
|
cssEnabled: Boolean
|
||||||
|
"""Interface language"""
|
||||||
|
language: String
|
||||||
}
|
}
|
||||||
|
|
||||||
"""All configuration settings"""
|
"""All configuration settings"""
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
|
|||||||
config.Set(config.ShowStudioAsText, *input.ShowStudioAsText)
|
config.Set(config.ShowStudioAsText, *input.ShowStudioAsText)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.Language != nil {
|
||||||
|
config.Set(config.Language, *input.Language)
|
||||||
|
}
|
||||||
|
|
||||||
css := ""
|
css := ""
|
||||||
|
|
||||||
if input.CSS != nil {
|
if input.CSS != nil {
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
|||||||
showStudioAsText := config.GetShowStudioAsText()
|
showStudioAsText := config.GetShowStudioAsText()
|
||||||
css := config.GetCSS()
|
css := config.GetCSS()
|
||||||
cssEnabled := config.GetCSSEnabled()
|
cssEnabled := config.GetCSSEnabled()
|
||||||
|
language := config.GetLanguage()
|
||||||
|
|
||||||
|
|
||||||
return &models.ConfigInterfaceResult{
|
return &models.ConfigInterfaceResult{
|
||||||
SoundOnPreview: &soundOnPreview,
|
SoundOnPreview: &soundOnPreview,
|
||||||
@@ -66,5 +68,6 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
|||||||
ShowStudioAsText: &showStudioAsText,
|
ShowStudioAsText: &showStudioAsText,
|
||||||
CSS: &css,
|
CSS: &css,
|
||||||
CSSEnabled: &cssEnabled,
|
CSSEnabled: &cssEnabled,
|
||||||
|
Language: &language,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ const Host = "host"
|
|||||||
const Port = "port"
|
const Port = "port"
|
||||||
const ExternalHost = "external_host"
|
const ExternalHost = "external_host"
|
||||||
|
|
||||||
|
// i18n
|
||||||
|
const Language = "language"
|
||||||
|
|
||||||
// Interface options
|
// Interface options
|
||||||
const SoundOnPreview = "sound_on_preview"
|
const SoundOnPreview = "sound_on_preview"
|
||||||
const WallShowTitle = "wall_show_title"
|
const WallShowTitle = "wall_show_title"
|
||||||
@@ -97,6 +100,17 @@ func GetExcludes() []string {
|
|||||||
return viper.GetStringSlice(Exclude)
|
return viper.GetStringSlice(Exclude)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetLanguage() string {
|
||||||
|
ret := viper.GetString(Language)
|
||||||
|
|
||||||
|
// default to English
|
||||||
|
if ret == "" {
|
||||||
|
return "en-US"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
func GetScrapersPath() string {
|
func GetScrapersPath() string {
|
||||||
return viper.GetString(ScrapersPath)
|
return viper.GetString(ScrapersPath)
|
||||||
}
|
}
|
||||||
|
|||||||
3
ui/v2.5/.babelrc
Normal file
3
ui/v2.5/.babelrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"presets": ["react-app"]
|
||||||
|
}
|
||||||
9
ui/v2.5/.editorconfig
Normal file
9
ui/v2.5/.editorconfig
Normal 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
2
ui/v2.5/.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
BROWSER=none
|
||||||
|
PORT=3001
|
||||||
53
ui/v2.5/.eslintrc.json
Normal file
53
ui/v2.5/.eslintrc.json
Normal 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
25
ui/v2.5/.gitignore
vendored
Executable 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
96
ui/v2.5/.stylelintrc
Normal 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
18
ui/v2.5/.vscode/launch.json
vendored
Normal 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
10
ui/v2.5/.vscode/settings.json
vendored
Normal 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
47
ui/v2.5/README.md
Executable 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 can’t go back!**
|
||||||
|
|
||||||
|
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||||
|
|
||||||
|
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||||
|
|
||||||
|
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||||
|
|
||||||
|
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||||
15
ui/v2.5/codegen.yml
Normal file
15
ui/v2.5/codegen.yml
Normal 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
97
ui/v2.5/package.json
Normal 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
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
41
ui/v2.5/public/index.html
Executable 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>
|
||||||
95
ui/v2.5/public/jwplayer/jwplayer.controls.js
Normal file
95
ui/v2.5/public/jwplayer/jwplayer.controls.js
Normal file
File diff suppressed because one or more lines are too long
95
ui/v2.5/public/jwplayer/jwplayer.core.controls.html5.js
Normal file
95
ui/v2.5/public/jwplayer/jwplayer.core.controls.html5.js
Normal file
File diff suppressed because one or more lines are too long
95
ui/v2.5/public/jwplayer/jwplayer.core.controls.js
Normal file
95
ui/v2.5/public/jwplayer/jwplayer.core.controls.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
95
ui/v2.5/public/jwplayer/jwplayer.core.controls.polyfills.js
Normal file
95
ui/v2.5/public/jwplayer/jwplayer.core.controls.polyfills.js
Normal file
File diff suppressed because one or more lines are too long
95
ui/v2.5/public/jwplayer/jwplayer.core.js
Normal file
95
ui/v2.5/public/jwplayer/jwplayer.core.js
Normal file
File diff suppressed because one or more lines are too long
95
ui/v2.5/public/jwplayer/jwplayer.js
Normal file
95
ui/v2.5/public/jwplayer/jwplayer.js
Normal file
File diff suppressed because one or more lines are too long
92
ui/v2.5/public/jwplayer/notice.txt
Normal file
92
ui/v2.5/public/jwplayer/notice.txt
Normal 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.
|
||||||
95
ui/v2.5/public/jwplayer/polyfills.intersection-observer.js
Normal file
95
ui/v2.5/public/jwplayer/polyfills.intersection-observer.js
Normal file
File diff suppressed because one or more lines are too long
95
ui/v2.5/public/jwplayer/polyfills.webvtt.js
Normal file
95
ui/v2.5/public/jwplayer/polyfills.webvtt.js
Normal file
File diff suppressed because one or more lines are too long
95
ui/v2.5/public/jwplayer/provider.html5.js
Normal file
95
ui/v2.5/public/jwplayer/provider.html5.js
Normal file
File diff suppressed because one or more lines are too long
95
ui/v2.5/public/jwplayer/vttparser.js
Normal file
95
ui/v2.5/public/jwplayer/vttparser.js
Normal 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
15
ui/v2.5/public/manifest.json
Executable 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
58
ui/v2.5/src/App.tsx
Executable 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
50
ui/v2.5/src/components/ErrorBoundary.tsx
Normal file
50
ui/v2.5/src/components/ErrorBoundary.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
ui/v2.5/src/components/Galleries/Galleries.tsx
Normal file
13
ui/v2.5/src/components/Galleries/Galleries.tsx
Normal 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;
|
||||||
21
ui/v2.5/src/components/Galleries/Gallery.tsx
Normal file
21
ui/v2.5/src/components/Galleries/Gallery.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
64
ui/v2.5/src/components/Galleries/GalleryList.tsx
Normal file
64
ui/v2.5/src/components/Galleries/GalleryList.tsx
Normal 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;
|
||||||
|
};
|
||||||
59
ui/v2.5/src/components/Galleries/GalleryViewer.tsx
Normal file
59
ui/v2.5/src/components/Galleries/GalleryViewer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
7
ui/v2.5/src/components/Galleries/styles.scss
Normal file
7
ui/v2.5/src/components/Galleries/styles.scss
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/* stylelint-disable selector-class-pattern */
|
||||||
|
.react-photo-gallery--gallery {
|
||||||
|
img {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* stylelint-enable selector-class-pattern */
|
||||||
255
ui/v2.5/src/components/List/AddFilter.tsx
Normal file
255
ui/v2.5/src/components/List/AddFilter.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
329
ui/v2.5/src/components/List/ListFilter.tsx
Normal file
329
ui/v2.5/src/components/List/ListFilter.tsx
Normal 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();
|
||||||
|
};
|
||||||
101
ui/v2.5/src/components/List/Pagination.tsx
Normal file
101
ui/v2.5/src/components/List/Pagination.tsx
Normal 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">《</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">》</span>
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
15
ui/v2.5/src/components/List/styles.scss
Normal file
15
ui/v2.5/src/components/List/styles.scss
Normal 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;
|
||||||
|
}
|
||||||
120
ui/v2.5/src/components/MainNavbar.tsx
Normal file
120
ui/v2.5/src/components/MainNavbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
ui/v2.5/src/components/PageNotFound.tsx
Normal file
5
ui/v2.5/src/components/PageNotFound.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
|
||||||
|
export const PageNotFound: FunctionComponent = () => {
|
||||||
|
return <h1>Page not found.</h1>;
|
||||||
|
};
|
||||||
46
ui/v2.5/src/components/Performers/PerformerCard.tsx
Normal file
46
ui/v2.5/src/components/Performers/PerformerCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
273
ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx
Normal file
273
ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>;
|
||||||
|
};
|
||||||
@@ -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} />;
|
||||||
|
};
|
||||||
75
ui/v2.5/src/components/Performers/PerformerList.tsx
Normal file
75
ui/v2.5/src/components/Performers/PerformerList.tsx
Normal 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;
|
||||||
|
};
|
||||||
69
ui/v2.5/src/components/Performers/PerformerListTable.tsx
Normal file
69
ui/v2.5/src/components/Performers/PerformerListTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
13
ui/v2.5/src/components/Performers/Performers.tsx
Normal file
13
ui/v2.5/src/components/Performers/Performers.tsx
Normal 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;
|
||||||
67
ui/v2.5/src/components/Performers/styles.scss
Normal file
67
ui/v2.5/src/components/Performers/styles.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
ui/v2.5/src/components/SceneFilenameParser/ParserField.ts
Normal file
66
ui/v2.5/src/components/SceneFilenameParser/ParserField.ts
Normal 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
|
||||||
|
];
|
||||||
|
}
|
||||||
251
ui/v2.5/src/components/SceneFilenameParser/ParserInput.tsx
Normal file
251
ui/v2.5/src/components/SceneFilenameParser/ParserInput.tsx
Normal 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 '\' 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
374
ui/v2.5/src/components/SceneFilenameParser/SceneParserRow.tsx
Normal file
374
ui/v2.5/src/components/SceneFilenameParser/SceneParserRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
ui/v2.5/src/components/SceneFilenameParser/ShowFields.tsx
Normal file
43
ui/v2.5/src/components/SceneFilenameParser/ShowFields.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
45
ui/v2.5/src/components/SceneFilenameParser/styles.scss
Normal file
45
ui/v2.5/src/components/SceneFilenameParser/styles.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
254
ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx
Normal file
254
ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx
Normal 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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
389
ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx
Normal file
389
ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx
Normal 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()}
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</Button>
|
||||||
|
<div ref={contentEl} className="scrubber-content">
|
||||||
|
<div className="scrubber-tags-background" />
|
||||||
|
<div ref={positionIndicatorEl} id="scrubber-position-indicator" />
|
||||||
|
<div id="scrubber-current-position" />
|
||||||
|
<div className="scrubber-viewport">
|
||||||
|
<div ref={scrubberSliderEl} className="scrubber-slider">
|
||||||
|
<div className="scrubber-tags">{renderTags()}</div>
|
||||||
|
{renderSprites()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="scrubber-button"
|
||||||
|
id="scrubber-forward"
|
||||||
|
onClick={() => goForward()}
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
ui/v2.5/src/components/ScenePlayer/index.ts
Normal file
1
ui/v2.5/src/components/ScenePlayer/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ScenePlayer } from "./ScenePlayer";
|
||||||
135
ui/v2.5/src/components/ScenePlayer/styles.scss
Normal file
135
ui/v2.5/src/components/ScenePlayer/styles.scss
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
248
ui/v2.5/src/components/Scenes/SceneCard.tsx
Normal file
248
ui/v2.5/src/components/Scenes/SceneCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
67
ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx
Normal file
67
ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx
Normal 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>;
|
||||||
|
};
|
||||||
155
ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx
Normal file
155
ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
416
ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx
Normal file
416
ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
181
ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx
Normal file
181
ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
144
ui/v2.5/src/components/Scenes/SceneList.tsx
Normal file
144
ui/v2.5/src/components/Scenes/SceneList.tsx
Normal 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;
|
||||||
|
};
|
||||||
82
ui/v2.5/src/components/Scenes/SceneListTable.tsx
Normal file
82
ui/v2.5/src/components/Scenes/SceneListTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
62
ui/v2.5/src/components/Scenes/SceneMarkerList.tsx
Normal file
62
ui/v2.5/src/components/Scenes/SceneMarkerList.tsx
Normal 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;
|
||||||
|
};
|
||||||
306
ui/v2.5/src/components/Scenes/SceneSelectedOptions.tsx
Normal file
306
ui/v2.5/src/components/Scenes/SceneSelectedOptions.tsx
Normal 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();
|
||||||
|
};
|
||||||
15
ui/v2.5/src/components/Scenes/Scenes.tsx
Normal file
15
ui/v2.5/src/components/Scenes/Scenes.tsx
Normal 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;
|
||||||
214
ui/v2.5/src/components/Scenes/styles.scss
Normal file
214
ui/v2.5/src/components/Scenes/styles.scss
Normal 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%;
|
||||||
|
}
|
||||||
69
ui/v2.5/src/components/Settings/Settings.tsx
Normal file
69
ui/v2.5/src/components/Settings/Settings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
162
ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx
Normal file
162
ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx
Normal 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()
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
393
ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx
Normal file
393
ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
161
ui/v2.5/src/components/Settings/SettingsInterfacePanel.tsx
Normal file
161
ui/v2.5/src/components/Settings/SettingsInterfacePanel.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
124
ui/v2.5/src/components/Settings/SettingsLogsPanel.tsx
Normal file
124
ui/v2.5/src/components/Settings/SettingsLogsPanel.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
33
ui/v2.5/src/components/Settings/styles.scss
Normal file
33
ui/v2.5/src/components/Settings/styles.scss
Normal 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;
|
||||||
|
}
|
||||||
110
ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx
Normal file
110
ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
96
ui/v2.5/src/components/Shared/DurationInput.tsx
Normal file
96
ui/v2.5/src/components/Shared/DurationInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
115
ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx
Normal file
115
ui/v2.5/src/components/Shared/FolderSelect/FolderSelect.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
76
ui/v2.5/src/components/Shared/HoverPopover.tsx
Normal file
76
ui/v2.5/src/components/Shared/HoverPopover.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
ui/v2.5/src/components/Shared/Icon.tsx
Normal file
19
ui/v2.5/src/components/Shared/Icon.tsx
Normal 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;
|
||||||
25
ui/v2.5/src/components/Shared/ImageInput.tsx
Normal file
25
ui/v2.5/src/components/Shared/ImageInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
25
ui/v2.5/src/components/Shared/LoadingIndicator.tsx
Normal file
25
ui/v2.5/src/components/Shared/LoadingIndicator.tsx
Normal 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;
|
||||||
59
ui/v2.5/src/components/Shared/Modal.tsx
Normal file
59
ui/v2.5/src/components/Shared/Modal.tsx
Normal 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;
|
||||||
387
ui/v2.5/src/components/Shared/Select.tsx
Normal file
387
ui/v2.5/src/components/Shared/Select.tsx
Normal 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} />
|
||||||
|
);
|
||||||
|
};
|
||||||
23
ui/v2.5/src/components/Shared/SweatDrops.tsx
Normal file
23
ui/v2.5/src/components/Shared/SweatDrops.tsx
Normal 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>
|
||||||
|
);
|
||||||
38
ui/v2.5/src/components/Shared/TagLink.tsx
Normal file
38
ui/v2.5/src/components/Shared/TagLink.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
ui/v2.5/src/components/Shared/index.ts
Normal file
19
ui/v2.5/src/components/Shared/index.ts
Normal 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";
|
||||||
70
ui/v2.5/src/components/Shared/styles.scss
Normal file
70
ui/v2.5/src/components/Shared/styles.scss
Normal 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
Reference in New Issue
Block a user