mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
UI V2
Squashed commits: [e74bbf9] stuff [28476de] stuff [c7efb7b] stuff [2c78f94] stuff [f79338e] stuff [a697876] stuff [85bb60e] stuff [9f108b2] stuff [d8e00c0] stuff [7787ef9] stuff [f7f10b7] stuff [aa266f7] stuff [511ba6b] stuff [7453747] stuff [db55e2d] stuff [b362623] stuff [7288c17] stuff [86638cd] stuff [879dac4] stuff [65a4996] stuff [c6fb361] stuff [d449ce7] stuff [349dffa] stuff [84206ab] stuff [0253c65] stuff [cc0992e] stuff [3289e7d] stuff [d9ab290] stuff [dcc980d] stuff [7787da8] stuff [5bcf7cd] stuff [00e9316] stuff [54c9398] stuff [72b6ee1] stuff [4b4b26c] stuff [4cbdb06] stuff [1a240b3] stuff [650ea08] stuff [37440ef] stuff [9ee66ba] stuff [b430c86] stuff [37159c3] stuff [deba837] stuff [6ac65f6] stuff [a2ca1a1] stuff [c010229] stuff [3fd7306] stuff [cbe6efc] stuff [997a8d0] stuff [d0708a2] stuff [d316aba] stuff [4fe9900] Added initial files
This commit is contained in:
@@ -58,7 +58,7 @@ func (g *Gallery) GetThumbnail(index int) []byte {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
resizedImage := imaging.Resize(srcImage, 512, 0, imaging.Lanczos)
|
resizedImage := imaging.Resize(srcImage, 100, 0, imaging.NearestNeighbor)
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
err = jpeg.Encode(buf, resizedImage, nil)
|
err = jpeg.Encode(buf, resizedImage, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
5
schema/documents/data/studio-slim.graphql
Normal file
5
schema/documents/data/studio-slim.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fragment SlimStudioData on Studio {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
image_path
|
||||||
|
}
|
||||||
@@ -25,34 +25,6 @@ query FindScene($id: ID!, $checksum: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
query FindSceneForEditing($id: ID) {
|
|
||||||
findScene(id: $id) {
|
|
||||||
...SceneData
|
|
||||||
}
|
|
||||||
|
|
||||||
allPerformers {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
birthdate
|
|
||||||
image_path
|
|
||||||
}
|
|
||||||
|
|
||||||
allTags {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
}
|
|
||||||
|
|
||||||
allStudios {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
}
|
|
||||||
|
|
||||||
validGalleriesForScene(scene_id: $id) {
|
|
||||||
id
|
|
||||||
path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
query FindSceneMarkers($filter: FindFilterType, $scene_marker_filter: SceneMarkerFilterType) {
|
query FindSceneMarkers($filter: FindFilterType, $scene_marker_filter: SceneMarkerFilterType) {
|
||||||
findSceneMarkers(filter: $filter, scene_marker_filter: $scene_marker_filter) {
|
findSceneMarkers(filter: $filter, scene_marker_filter: $scene_marker_filter) {
|
||||||
count
|
count
|
||||||
@@ -169,6 +141,12 @@ query AllPerformersForFilter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query AllStudiosForFilter {
|
||||||
|
allStudios {
|
||||||
|
...SlimStudioData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
query AllTagsForFilter {
|
query AllTagsForFilter {
|
||||||
allTags {
|
allTags {
|
||||||
id
|
id
|
||||||
@@ -176,6 +154,13 @@ query AllTagsForFilter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query ValidGalleriesForScene($scene_id: ID!) {
|
||||||
|
validGalleriesForScene(scene_id: $scene_id) {
|
||||||
|
id
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
query Stats {
|
query Stats {
|
||||||
stats {
|
stats {
|
||||||
scene_count,
|
scene_count,
|
||||||
|
|||||||
1
ui/v2/.env
Normal file
1
ui/v2/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
BROWSER=none
|
||||||
23
ui/v2/.gitignore
vendored
Executable file
23
ui/v2/.gitignore
vendored
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
18
ui/v2/.vscode/launch.json
vendored
Normal file
18
ui/v2/.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/.vscode/settings.json
vendored
Normal file
10
ui/v2/.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/README.md
Executable file
47
ui/v2/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/).
|
||||||
17
ui/v2/codegen.yml
Normal file
17
ui/v2/codegen.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
overwrite: true
|
||||||
|
schema: "../../schema/schema.graphql"
|
||||||
|
documents: "../../schema/documents/**/*.graphql"
|
||||||
|
generates:
|
||||||
|
src/core/generated-graphql.tsx:
|
||||||
|
config:
|
||||||
|
noNamespaces: true
|
||||||
|
optionalType: "undefined"
|
||||||
|
noHOC: true
|
||||||
|
noComponents: true
|
||||||
|
withHooks: true
|
||||||
|
plugins:
|
||||||
|
- add: "/* tslint:disable */"
|
||||||
|
- time
|
||||||
|
- "typescript-common"
|
||||||
|
- "typescript-client"
|
||||||
|
- "typescript-react-apollo"
|
||||||
62
ui/v2/package.json
Normal file
62
ui/v2/package.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "stash",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@blueprintjs/core": "3.13.0",
|
||||||
|
"@blueprintjs/select": "3.6.1",
|
||||||
|
"@types/jest": "24.0.5",
|
||||||
|
"@types/lodash": "4.14.121",
|
||||||
|
"@types/node": "11.9.4",
|
||||||
|
"@types/query-string": "6.2.0",
|
||||||
|
"@types/react": "16.8.3",
|
||||||
|
"@types/react-dom": "16.8.1",
|
||||||
|
"@types/react-router-dom": "4.3.1",
|
||||||
|
"apollo-boost": "0.1.28",
|
||||||
|
"axios": "0.18.0",
|
||||||
|
"bulma": "0.7.4",
|
||||||
|
"formik": "1.5.1",
|
||||||
|
"graphql": "14.1.1",
|
||||||
|
"lodash": "4.17.11",
|
||||||
|
"node-sass": "4.11.0",
|
||||||
|
"query-string": "6.2.0",
|
||||||
|
"react": "16.8.3",
|
||||||
|
"react-apollo": "2.4.1",
|
||||||
|
"react-apollo-hooks": "0.4.1",
|
||||||
|
"react-dom": "16.8.3",
|
||||||
|
"react-images": "0.5.19",
|
||||||
|
"react-jw-player": "1.19.0",
|
||||||
|
"react-photo-gallery": "6.3.2",
|
||||||
|
"react-router-dom": "4.3.1",
|
||||||
|
"react-scripts": "2.1.5"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject",
|
||||||
|
"lint": "tslint -c ./tslint.json 'src/**/*.{ts,tsx}'",
|
||||||
|
"lint:fix": "tslint --fix -c ./tslint.json 'src/**/*.{ts,tsx}'",
|
||||||
|
"gqlgen": "gql-gen --config codegen.yml"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "react-app"
|
||||||
|
},
|
||||||
|
"browserslist": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not ie <= 11",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"devDependencies": {
|
||||||
|
"graphql-code-generator": "0.18.0",
|
||||||
|
"graphql-codegen-add": "0.18.0",
|
||||||
|
"graphql-codegen-typescript-client": "0.18.0",
|
||||||
|
"graphql-codegen-typescript-common": "0.18.0",
|
||||||
|
"graphql-codegen-typescript-react-apollo": "0.18.0",
|
||||||
|
"graphql-codegen-time": "0.18.0",
|
||||||
|
"tslint": "5.13.0",
|
||||||
|
"tslint-react": "3.6.0",
|
||||||
|
"typescript": "3.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ui/v2/public/favicon.ico
Executable file
BIN
ui/v2/public/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
41
ui/v2/public/index.html
Executable file
41
ui/v2/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, shrink-to-fit=no"
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>React App</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>
|
||||||
15
ui/v2/public/manifest.json
Executable file
15
ui/v2/public/manifest.json
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
35
ui/v2/src/App.tsx
Executable file
35
ui/v2/src/App.tsx
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Route, Switch } from "react-router-dom";
|
||||||
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
|
import Galleries from "./components/Galleries/Galleries";
|
||||||
|
import { MainNavbar } from "./components/MainNavbar";
|
||||||
|
import { PageNotFound } from "./components/PageNotFound";
|
||||||
|
import Performers from "./components/performers/performers";
|
||||||
|
import Scenes from "./components/scenes/scenes";
|
||||||
|
import { Settings } from "./components/Settings/Settings";
|
||||||
|
import { Stats } from "./components/Stats";
|
||||||
|
import Studios from "./components/Studios/Studios";
|
||||||
|
import Tags from "./components/Tags/Tags";
|
||||||
|
|
||||||
|
export class App extends React.Component {
|
||||||
|
public render() {
|
||||||
|
return (
|
||||||
|
<div className="bp3-dark">
|
||||||
|
<MainNavbar />
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Switch>
|
||||||
|
<Route exact={true} path="/" component={Stats} />
|
||||||
|
<Route path="/scenes" component={Scenes} />
|
||||||
|
{/* <Route path="/scenes/:id" component={Scene} /> */}
|
||||||
|
<Route path="/galleries" component={Galleries} />
|
||||||
|
<Route path="/performers" component={Performers} />
|
||||||
|
<Route path="/tags" component={Tags} />
|
||||||
|
<Route path="/studios" component={Studios} />
|
||||||
|
<Route path="/settings" component={Settings} />
|
||||||
|
<Route component={PageNotFound} />
|
||||||
|
</Switch>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
ui/v2/src/components/ErrorBoundary.tsx
Normal file
34
ui/v2/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<any, any> {
|
||||||
|
constructor(props: any) {
|
||||||
|
super(props);
|
||||||
|
this.state = { error: null, errorInfo: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: any, errorInfo: any) {
|
||||||
|
this.setState({
|
||||||
|
error,
|
||||||
|
errorInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.errorInfo) {
|
||||||
|
// Error path
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Something went wrong.</h2>
|
||||||
|
<details style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{this.state.error && this.state.error.toString()}
|
||||||
|
<br />
|
||||||
|
{this.state.errorInfo.componentStack}
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normally, just render children
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
ui/v2/src/components/Galleries/Galleries.tsx
Normal file
13
ui/v2/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={true} path="/galleries" component={GalleryList} />
|
||||||
|
<Route path="/galleries/:id" component={Gallery} />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Galleries;
|
||||||
32
ui/v2/src/components/Galleries/Gallery.tsx
Normal file
32
ui/v2/src/components/Galleries/Gallery.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
Spinner,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { StashService } from "../../core/StashService";
|
||||||
|
import { IBaseProps } from "../../models";
|
||||||
|
import { GalleryViewer } from "./GalleryViewer";
|
||||||
|
|
||||||
|
interface IProps extends IBaseProps {}
|
||||||
|
|
||||||
|
export const Gallery: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
const [gallery, setGallery] = useState<Partial<GQL.GalleryDataFragment>>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { data, error, loading } = StashService.useFindGallery(props.match.params.id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(loading);
|
||||||
|
if (!data || !data.findGallery || !!error) { return; }
|
||||||
|
setGallery(data.findGallery);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (!data || !data.findGallery || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
|
||||||
|
if (!!error) { return <>{error.message}</>; }
|
||||||
|
return (
|
||||||
|
<div style={{width: "75vw", margin: "0 auto"}}>
|
||||||
|
<GalleryViewer gallery={gallery as any} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
54
ui/v2/src/components/Galleries/GalleryList.tsx
Normal file
54
ui/v2/src/components/Galleries/GalleryList.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { HTMLTable } from "@blueprintjs/core";
|
||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import { QueryHookResult } from "react-apollo-hooks";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { FindGalleriesQuery, FindGalleriesVariables } from "../../core/generated-graphql";
|
||||||
|
import { ListHook } from "../../hooks/ListHook";
|
||||||
|
import { IBaseProps } from "../../models/base-props";
|
||||||
|
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||||
|
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
|
||||||
|
|
||||||
|
interface IProps extends IBaseProps {}
|
||||||
|
|
||||||
|
export const GalleryList: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
const listData = ListHook.useList({
|
||||||
|
filterMode: FilterMode.Galleries,
|
||||||
|
props,
|
||||||
|
renderContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderContent(result: QueryHookResult<FindGalleriesQuery, FindGalleriesVariables>, filter: ListFilterModel) {
|
||||||
|
if (!result.data || !result.data.findGalleries) { return; }
|
||||||
|
if (filter.displayMode === DisplayMode.Grid) {
|
||||||
|
return <h1>TODO</h1>;
|
||||||
|
} else if (filter.displayMode === DisplayMode.List) {
|
||||||
|
return (
|
||||||
|
<HTMLTable style={{margin: "0 auto"}}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Preview</th>
|
||||||
|
<th>Path</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.data.findGalleries.galleries.map((gallery) => (
|
||||||
|
<tr key={gallery.id}>
|
||||||
|
<td>
|
||||||
|
<Link to={`/galleries/${gallery.id}`}>
|
||||||
|
{gallery.files.length > 0 ? <img src={`${gallery.files[0].path}?thumb=true`} /> : undefined}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td><Link to={`/galleries/${gallery.id}`}>{gallery.path}</Link></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</HTMLTable>
|
||||||
|
);
|
||||||
|
} else if (filter.displayMode === DisplayMode.Wall) {
|
||||||
|
return <h1>TODO</h1>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listData.template;
|
||||||
|
};
|
||||||
47
ui/v2/src/components/Galleries/GalleryViewer.tsx
Normal file
47
ui/v2/src/components/Galleries/GalleryViewer.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent, useState } from "react";
|
||||||
|
import Lightbox from "react-images";
|
||||||
|
import Gallery from "react-photo-gallery";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
gallery: GQL.GalleryDataFragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GalleryViewer: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
const [currentImage, setCurrentImage] = useState<number>(0);
|
||||||
|
const [lightboxIsOpen, setLightboxIsOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
function openLightbox(event: any, obj: any) {
|
||||||
|
setCurrentImage(obj.index);
|
||||||
|
setLightboxIsOpen(true);
|
||||||
|
}
|
||||||
|
function closeLightbox() {
|
||||||
|
setCurrentImage(0);
|
||||||
|
setLightboxIsOpen(false);
|
||||||
|
}
|
||||||
|
function gotoPrevious() {
|
||||||
|
setCurrentImage(currentImage - 1);
|
||||||
|
}
|
||||||
|
function gotoNext() {
|
||||||
|
setCurrentImage(currentImage + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const photos = props.gallery.files.map((file) => ({src: file.path || "", caption: file.name}));
|
||||||
|
const thumbs = props.gallery.files.map((file) => ({src: `${file.path}?thumb=true` || "", width: 1, height: 1}));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Gallery photos={thumbs} columns={15} onClick={openLightbox} />
|
||||||
|
<Lightbox
|
||||||
|
images={photos}
|
||||||
|
onClose={closeLightbox}
|
||||||
|
onClickPrev={gotoPrevious}
|
||||||
|
onClickNext={gotoNext}
|
||||||
|
currentImage={currentImage}
|
||||||
|
isOpen={lightboxIsOpen}
|
||||||
|
onClickImage={() => window.open(photos[currentImage].src, "_blank")}
|
||||||
|
width={9999}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
112
ui/v2/src/components/MainNavbar.tsx
Normal file
112
ui/v2/src/components/MainNavbar.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
Navbar,
|
||||||
|
NavbarDivider,
|
||||||
|
NavbarGroup,
|
||||||
|
NavbarHeading,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import { Link, NavLink } from "react-router-dom";
|
||||||
|
|
||||||
|
interface IMainNavbarProps {}
|
||||||
|
|
||||||
|
export const MainNavbar: FunctionComponent<IMainNavbarProps> = (props) => {
|
||||||
|
let newButtonPath: string | undefined;
|
||||||
|
let newButtonElement: JSX.Element | undefined;
|
||||||
|
switch (window.location.pathname) {
|
||||||
|
case "/performers": {
|
||||||
|
newButtonPath = "/performers/new";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "/studios": {
|
||||||
|
newButtonPath = "/studios/new";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!!newButtonPath) {
|
||||||
|
newButtonElement = (
|
||||||
|
<>
|
||||||
|
<NavLink
|
||||||
|
to={newButtonPath}
|
||||||
|
className="bp3-button bp3-intent-primary"
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</NavLink>
|
||||||
|
<NavbarDivider />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Navbar fixedToTop={true}>
|
||||||
|
<div>
|
||||||
|
<NavbarGroup align="left">
|
||||||
|
<NavbarHeading><Link to="/" className="bp3-button bp3-minimal">Stash</Link></NavbarHeading>
|
||||||
|
<NavbarDivider />
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
exact={true}
|
||||||
|
to="/scenes"
|
||||||
|
className="bp3-button bp3-minimal bp3-icon-video"
|
||||||
|
activeClassName="bp3-active"
|
||||||
|
>
|
||||||
|
Scenes
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
exact={true}
|
||||||
|
to="/scenes/markers"
|
||||||
|
className="bp3-button bp3-minimal bp3-icon-map-marker"
|
||||||
|
activeClassName="bp3-active"
|
||||||
|
>
|
||||||
|
Markers
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
exact={true}
|
||||||
|
to="/galleries"
|
||||||
|
className="bp3-button bp3-minimal bp3-icon-media"
|
||||||
|
activeClassName="bp3-active"
|
||||||
|
>
|
||||||
|
Galleries
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
exact={true}
|
||||||
|
to="/performers"
|
||||||
|
className="bp3-button bp3-minimal bp3-icon-person"
|
||||||
|
activeClassName="bp3-active"
|
||||||
|
>
|
||||||
|
Performers
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
exact={true}
|
||||||
|
to="/studios"
|
||||||
|
className="bp3-button bp3-minimal bp3-icon-mobile-video"
|
||||||
|
activeClassName="bp3-active"
|
||||||
|
>
|
||||||
|
Studios
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
exact={true}
|
||||||
|
to="/tags"
|
||||||
|
className="bp3-button bp3-minimal bp3-icon-tag"
|
||||||
|
activeClassName="bp3-active"
|
||||||
|
>
|
||||||
|
Tags
|
||||||
|
</NavLink>
|
||||||
|
</NavbarGroup>
|
||||||
|
<NavbarGroup align="right">
|
||||||
|
{newButtonElement}
|
||||||
|
<NavLink
|
||||||
|
exact={true}
|
||||||
|
to="/settings"
|
||||||
|
className="bp3-button bp3-minimal bp3-icon-cog"
|
||||||
|
activeClassName="bp3-active"
|
||||||
|
/>
|
||||||
|
</NavbarGroup>
|
||||||
|
</div>
|
||||||
|
</Navbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
7
ui/v2/src/components/PageNotFound.tsx
Normal file
7
ui/v2/src/components/PageNotFound.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
|
||||||
|
export const PageNotFound: FunctionComponent = () => {
|
||||||
|
return (
|
||||||
|
<h1>Page not found.</h1>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
ui/v2/src/components/Settings/Settings.tsx
Normal file
48
ui/v2/src/components/Settings/Settings.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import queryString from "query-string";
|
||||||
|
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||||
|
import { IBaseProps } from "../../models";
|
||||||
|
import { SettingsAboutPanel } from "./SettingsAboutPanel";
|
||||||
|
import { SettingsConfigurationPanel } from "./SettingsConfigurationPanel";
|
||||||
|
import { SettingsLogsPanel } from "./SettingsLogsPanel";
|
||||||
|
import { SettingsTasksPanel } from "./SettingsTasksPanel";
|
||||||
|
|
||||||
|
interface IProps extends IBaseProps {}
|
||||||
|
|
||||||
|
type TabId = "configuration" | "tasks" | "logs" | "about";
|
||||||
|
|
||||||
|
export const Settings: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
const [tabId, setTabId] = useState<TabId>(getTabId());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const location = Object.assign({}, props.history.location);
|
||||||
|
location.search = queryString.stringify({tab: tabId}, {encode: false});
|
||||||
|
props.history.replace(location);
|
||||||
|
}, [tabId]);
|
||||||
|
|
||||||
|
function getTabId(): TabId {
|
||||||
|
const queryParams = queryString.parse(props.location.search);
|
||||||
|
if (!queryParams.tab || typeof queryParams.tab !== "string") { return "tasks"; }
|
||||||
|
return queryParams.tab as TabId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card id="details-container">
|
||||||
|
<Tabs
|
||||||
|
renderActiveTabPanelOnly={true}
|
||||||
|
vertical={true}
|
||||||
|
onChange={(newId) => setTabId(newId as TabId)}
|
||||||
|
defaultSelectedTabId={getTabId()}
|
||||||
|
>
|
||||||
|
<Tab id="configuration" title="Configuration" panel={<SettingsConfigurationPanel />} />
|
||||||
|
<Tab id="tasks" title="Tasks" panel={<SettingsTasksPanel />} />
|
||||||
|
<Tab id="logs" title="Logs" panel={<SettingsLogsPanel />} />
|
||||||
|
<Tab id="about" title="About" panel={<SettingsAboutPanel />} />
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
ui/v2/src/components/Settings/SettingsAboutPanel.tsx
Normal file
19
ui/v2/src/components/Settings/SettingsAboutPanel.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
H1,
|
||||||
|
H4,
|
||||||
|
H6,
|
||||||
|
Tag,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { TextUtils } from "../../utils/text";
|
||||||
|
|
||||||
|
interface IProps {}
|
||||||
|
|
||||||
|
export const SettingsAboutPanel: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
About
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
ui/v2/src/components/Settings/SettingsConfigurationPanel.tsx
Normal file
19
ui/v2/src/components/Settings/SettingsConfigurationPanel.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
H1,
|
||||||
|
H4,
|
||||||
|
H6,
|
||||||
|
Tag,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { TextUtils } from "../../utils/text";
|
||||||
|
|
||||||
|
interface IProps {}
|
||||||
|
|
||||||
|
export const SettingsConfigurationPanel: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Configuration
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
ui/v2/src/components/Settings/SettingsLogsPanel.tsx
Normal file
19
ui/v2/src/components/Settings/SettingsLogsPanel.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
H1,
|
||||||
|
H4,
|
||||||
|
H6,
|
||||||
|
Tag,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { TextUtils } from "../../utils/text";
|
||||||
|
|
||||||
|
interface IProps {}
|
||||||
|
|
||||||
|
export const SettingsLogsPanel: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Logs
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
95
ui/v2/src/components/Settings/SettingsTasksPanel.tsx
Normal file
95
ui/v2/src/components/Settings/SettingsTasksPanel.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
FormGroup,
|
||||||
|
H1,
|
||||||
|
H4,
|
||||||
|
H6,
|
||||||
|
InputGroup,
|
||||||
|
Tag,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import React, { FunctionComponent, useState } from "react";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { StashService } from "../../core/StashService";
|
||||||
|
import { TextUtils } from "../../utils/text";
|
||||||
|
|
||||||
|
interface IProps {}
|
||||||
|
|
||||||
|
export const SettingsTasksPanel: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
const [isImportAlertOpen, setIsImportAlertOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
function onImport() {
|
||||||
|
setIsImportAlertOpen(false);
|
||||||
|
StashService.queryMetadataImport();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImportAlert() {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
cancelButtonText="Cancel"
|
||||||
|
confirmButtonText="Import"
|
||||||
|
icon="trash"
|
||||||
|
intent="danger"
|
||||||
|
isOpen={isImportAlertOpen}
|
||||||
|
onCancel={() => setIsImportAlertOpen(false)}
|
||||||
|
onConfirm={() => onImport()}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Are you sure you want to import? This will delete the database and re-import from
|
||||||
|
your exported metadata.
|
||||||
|
</p>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderImportAlert()}
|
||||||
|
|
||||||
|
<H4>Library</H4>
|
||||||
|
<FormGroup
|
||||||
|
helperText="Scan for new content and add it to the database."
|
||||||
|
labelFor="scan"
|
||||||
|
inline={true}
|
||||||
|
>
|
||||||
|
<Button id="scan" text="Scan" onClick={() => StashService.queryMetadataScan()} />
|
||||||
|
</FormGroup>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<H4>Generated Content</H4>
|
||||||
|
<FormGroup
|
||||||
|
helperText="Generate supporting image, sprite, video, vtt and other files."
|
||||||
|
labelFor="generate"
|
||||||
|
inline={true}
|
||||||
|
>
|
||||||
|
<Button id="generate" text="Generate" onClick={() => StashService.queryMetadataGenerate()} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup
|
||||||
|
helperText="TODO"
|
||||||
|
labelFor="clean"
|
||||||
|
inline={true}
|
||||||
|
>
|
||||||
|
<Button id="clean" text="Clean" onClick={() => StashService.queryMetadataClean()} />
|
||||||
|
</FormGroup>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<H4>Metadata</H4>
|
||||||
|
<FormGroup
|
||||||
|
helperText="Export the database content into JSON format"
|
||||||
|
labelFor="export"
|
||||||
|
inline={true}
|
||||||
|
>
|
||||||
|
<Button id="export" text="Export" onClick={() => StashService.queryMetadataExport()} />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup
|
||||||
|
helperText="Import from exported JSON. This is a destructive action."
|
||||||
|
labelFor="import"
|
||||||
|
inline={true}
|
||||||
|
>
|
||||||
|
<Button id="import" text="Import" intent="danger" onClick={() => setIsImportAlertOpen(true)} />
|
||||||
|
</FormGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
97
ui/v2/src/components/Shared/DetailsEditNavbar.tsx
Normal file
97
ui/v2/src/components/Shared/DetailsEditNavbar.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FileInput,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Navbar,
|
||||||
|
NavbarDivider,
|
||||||
|
Popover,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { NavigationUtils } from "../../utils/navigation";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
performer?: Partial<GQL.PerformerDataFragment>;
|
||||||
|
studio?: Partial<GQL.StudioDataFragment>;
|
||||||
|
isNew: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
onToggleEdit: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
|
||||||
|
|
||||||
|
// TODO: only for performers. make generic
|
||||||
|
onDisplayFreeOnesDialog?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DetailsEditNavbar: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
function renderEditButton() {
|
||||||
|
if (props.isNew) { return; }
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
intent="primary"
|
||||||
|
text={props.isEditing ? "Cancel" : "Edit"}
|
||||||
|
onClick={() => props.onToggleEdit()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSaveButton() {
|
||||||
|
if (!props.isEditing) { return; }
|
||||||
|
return <Button intent="success" text="Save" onClick={() => props.onSave()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImageInput() {
|
||||||
|
if (!props.isEditing) { return; }
|
||||||
|
return <FileInput text="Choose image..." onInputChange={props.onImageChange} inputProps={{accept: ".jpg,.jpeg"}} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScraperMenu() {
|
||||||
|
if (!props.performer) { return; }
|
||||||
|
if (!props.isEditing) { return; }
|
||||||
|
const scraperMenu = (
|
||||||
|
<Menu>
|
||||||
|
<MenuItem
|
||||||
|
text="FreeOnes"
|
||||||
|
onClick={() => { if (props.onDisplayFreeOnesDialog) { props.onDisplayFreeOnesDialog(); }}}
|
||||||
|
/>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Popover content={scraperMenu} position="bottom">
|
||||||
|
<Button text="Scrape with..."/>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScenesButton() {
|
||||||
|
if (props.isEditing) { return; }
|
||||||
|
let linkSrc: string = "#";
|
||||||
|
if (!!props.performer) {
|
||||||
|
linkSrc = NavigationUtils.makePerformerScenesUrl(props.performer);
|
||||||
|
} else if (!!props.studio) {
|
||||||
|
linkSrc = NavigationUtils.makeStudioScenesUrl(props.studio);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link className="bp3-button" to={linkSrc}>
|
||||||
|
Scenes
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Navbar>
|
||||||
|
<Navbar.Group>
|
||||||
|
{renderEditButton()}
|
||||||
|
{props.isEditing && !props.isNew ? <NavbarDivider /> : undefined}
|
||||||
|
{renderScraperMenu()}
|
||||||
|
{renderImageInput()}
|
||||||
|
{renderSaveButton()}
|
||||||
|
|
||||||
|
{renderScenesButton()}
|
||||||
|
</Navbar.Group>
|
||||||
|
</Navbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
68
ui/v2/src/components/Stats.tsx
Normal file
68
ui/v2/src/components/Stats.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { H1, Spinner } from "@blueprintjs/core";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import { StashService } from "../core/StashService";
|
||||||
|
|
||||||
|
export const Stats: FunctionComponent = () => {
|
||||||
|
const { data, error, loading } = StashService.useStats();
|
||||||
|
|
||||||
|
function renderStats() {
|
||||||
|
if (!data || !data.stats) { return; }
|
||||||
|
return (
|
||||||
|
<nav id="details-container" className="level">
|
||||||
|
<div className="level-item has-text-centered">
|
||||||
|
<div>
|
||||||
|
<p className="heading">Scenes</p>
|
||||||
|
<p className="title">{data.stats.scene_count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="level-item has-text-centered">
|
||||||
|
<div>
|
||||||
|
<p className="heading">Galleries</p>
|
||||||
|
<p className="title">{data.stats.gallery_count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="level-item has-text-centered">
|
||||||
|
<div>
|
||||||
|
<p className="heading">Performers</p>
|
||||||
|
<p className="title">{data.stats.performer_count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="level-item has-text-centered">
|
||||||
|
<div>
|
||||||
|
<p className="heading">Studios</p>
|
||||||
|
<p className="title">{data.stats.studio_count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="level-item has-text-centered">
|
||||||
|
<div>
|
||||||
|
<p className="heading">Tags</p>
|
||||||
|
<p className="title">{data.stats.tag_count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="details-container">
|
||||||
|
{!data || loading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
||||||
|
{!!error ? <span>error.message</span> : undefined}
|
||||||
|
{renderStats()}
|
||||||
|
|
||||||
|
<h3>Notes</h3>
|
||||||
|
<pre>
|
||||||
|
{`
|
||||||
|
This is still an early version, some things are still a work in progress.
|
||||||
|
|
||||||
|
* Filters for performers and studios only supports one item, even though it's a multi select.
|
||||||
|
* All of the task buttons in settings do work, but provide no feedback in the UI currently.
|
||||||
|
* The tasks tab is the only tab with content in the settings menu.
|
||||||
|
|
||||||
|
TODO:
|
||||||
|
* List view for scenes / performers
|
||||||
|
* Websocket connection to display logs in the UI
|
||||||
|
`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
33
ui/v2/src/components/Studios/StudioCard.tsx
Normal file
33
ui/v2/src/components/Studios/StudioCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Elevation,
|
||||||
|
H4,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
studio: GQL.StudioDataFragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StudioCard: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="grid-item"
|
||||||
|
elevation={Elevation.ONE}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={`/studios/${props.studio.id}`}
|
||||||
|
className="studio previewable image"
|
||||||
|
style={{backgroundImage: `url(${props.studio.image_path})`}}
|
||||||
|
/>
|
||||||
|
<div className="card-section">
|
||||||
|
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
|
||||||
|
{props.studio.name}
|
||||||
|
</H4>
|
||||||
|
<span className="bp3-text-muted block">{props.studio.scene_count} scenes.</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
143
ui/v2/src/components/Studios/StudioDetails/Studio.tsx
Normal file
143
ui/v2/src/components/Studios/StudioDetails/Studio.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Classes,
|
||||||
|
Dialog,
|
||||||
|
EditableText,
|
||||||
|
HTMLSelect,
|
||||||
|
HTMLTable,
|
||||||
|
Spinner,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||||
|
import * as GQL from "../../../core/generated-graphql";
|
||||||
|
import { StashService } from "../../../core/StashService";
|
||||||
|
import { IBaseProps } from "../../../models";
|
||||||
|
import { ErrorUtils } from "../../../utils/errors";
|
||||||
|
import { TableUtils } from "../../../utils/table";
|
||||||
|
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
|
||||||
|
|
||||||
|
interface IProps extends IBaseProps {}
|
||||||
|
|
||||||
|
export const Studio: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
const isNew = props.match.params.id === "new";
|
||||||
|
|
||||||
|
// Editing state
|
||||||
|
const [isEditing, setIsEditing] = useState<boolean>(isNew);
|
||||||
|
|
||||||
|
// Editing studio state
|
||||||
|
const [image, setImage] = useState<string | undefined>(undefined);
|
||||||
|
const [name, setName] = useState<string | undefined>(undefined);
|
||||||
|
const [url, setUrl] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
// Studio state
|
||||||
|
const [studio, setStudio] = useState<Partial<GQL.StudioDataFragment>>({});
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
// Network state
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { data, error, loading } = StashService.useFindStudio(props.match.params.id);
|
||||||
|
const updateStudio = StashService.useStudioUpdate(getStudioInput() as GQL.StudioUpdateInput);
|
||||||
|
const createStudio = StashService.useStudioCreate(getStudioInput() as GQL.StudioCreateInput);
|
||||||
|
|
||||||
|
function updateStudioEditState(state: Partial<GQL.StudioDataFragment>) {
|
||||||
|
setName(state.name);
|
||||||
|
setUrl(state.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(loading);
|
||||||
|
if (!data || !data.findStudio || !!error) { return; }
|
||||||
|
setStudio(data.findStudio);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImagePreview(studio.image_path);
|
||||||
|
setImage(undefined);
|
||||||
|
updateStudioEditState(studio);
|
||||||
|
if (!isNew) {
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
}, [studio]);
|
||||||
|
|
||||||
|
if (!isNew && !isEditing) {
|
||||||
|
if (!data || !data.findStudio || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
|
||||||
|
if (!!error) { return <>error...</>; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStudioInput() {
|
||||||
|
const input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput> = {
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
image,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isNew) {
|
||||||
|
(input as GQL.StudioUpdateInput).id = props.match.params.id;
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (!isNew) {
|
||||||
|
const result = await updateStudio();
|
||||||
|
setStudio(result.data.studioUpdate);
|
||||||
|
} else {
|
||||||
|
const result = await createStudio();
|
||||||
|
setStudio(result.data.studioCreate);
|
||||||
|
props.history.push(`/studios/${result.data.studioCreate.id}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ErrorUtils.handle(e);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||||
|
const file: File = (event.target as any).files[0];
|
||||||
|
const reader: FileReader = new FileReader();
|
||||||
|
|
||||||
|
reader.onloadend = (e) => {
|
||||||
|
setImagePreview(reader.result as string);
|
||||||
|
setImage(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: CSS class
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="columns is-multiline no-spacing">
|
||||||
|
<div className="column is-half details-image-container">
|
||||||
|
<img className="studio" src={imagePreview} />
|
||||||
|
</div>
|
||||||
|
<div className="column is-half details-detail-container">
|
||||||
|
<DetailsEditNavbar
|
||||||
|
studio={studio}
|
||||||
|
isNew={isNew}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onToggleEdit={() => { setIsEditing(!isEditing); updateStudioEditState(studio); }}
|
||||||
|
onSave={onSave}
|
||||||
|
onImageChange={onImageChange}
|
||||||
|
/>
|
||||||
|
<h1 className="bp3-heading">
|
||||||
|
<EditableText
|
||||||
|
disabled={!isEditing}
|
||||||
|
value={name}
|
||||||
|
placeholder="Name"
|
||||||
|
onChange={(value) => setName(value)}
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<HTMLTable style={{width: "100%"}}>
|
||||||
|
<tbody>
|
||||||
|
{TableUtils.renderEditableTextTableRow({title: "URL", value: url, isEditing, onChange: setUrl})}
|
||||||
|
</tbody>
|
||||||
|
</HTMLTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
ui/v2/src/components/Studios/StudioList.tsx
Normal file
36
ui/v2/src/components/Studios/StudioList.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import { QueryHookResult } from "react-apollo-hooks";
|
||||||
|
import { FindStudiosQuery, FindStudiosVariables } from "../../core/generated-graphql";
|
||||||
|
import { ListHook } from "../../hooks/ListHook";
|
||||||
|
import { IBaseProps } from "../../models/base-props";
|
||||||
|
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||||
|
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
|
||||||
|
import { StudioCard } from "./StudioCard";
|
||||||
|
|
||||||
|
interface IProps extends IBaseProps {}
|
||||||
|
|
||||||
|
export const StudioList: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
const listData = ListHook.useList({
|
||||||
|
filterMode: FilterMode.Studios,
|
||||||
|
props,
|
||||||
|
renderContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderContent(result: QueryHookResult<FindStudiosQuery, FindStudiosVariables>, filter: ListFilterModel) {
|
||||||
|
if (!result.data || !result.data.findStudios) { return; }
|
||||||
|
if (filter.displayMode === DisplayMode.Grid) {
|
||||||
|
return (
|
||||||
|
<div className="grid">
|
||||||
|
{result.data.findStudios.studios.map((studio) => (<StudioCard key={studio.id} studio={studio} />))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (filter.displayMode === DisplayMode.List) {
|
||||||
|
return <h1>TODO</h1>;
|
||||||
|
} else if (filter.displayMode === DisplayMode.Wall) {
|
||||||
|
return <h1>TODO</h1>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listData.template;
|
||||||
|
};
|
||||||
13
ui/v2/src/components/Studios/Studios.tsx
Normal file
13
ui/v2/src/components/Studios/Studios.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Route, Switch } from "react-router-dom";
|
||||||
|
import { Studio } from "./StudioDetails/Studio";
|
||||||
|
import { StudioList } from "./StudioList";
|
||||||
|
|
||||||
|
const Studios = () => (
|
||||||
|
<Switch>
|
||||||
|
<Route exact={true} path="/studios" component={StudioList} />
|
||||||
|
<Route path="/studios/:id" component={Studio} />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Studios;
|
||||||
109
ui/v2/src/components/Tags/TagList.tsx
Normal file
109
ui/v2/src/components/Tags/TagList.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { HTMLTable, Spinner, Tag, EditableText, Button, Dialog, Classes, FormGroup, InputGroup } from "@blueprintjs/core";
|
||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent, useState, useEffect } from "react";
|
||||||
|
import { QueryHookResult } from "react-apollo-hooks";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { FindGalleriesQuery, FindGalleriesVariables } from "../../core/generated-graphql";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { StashService } from "../../core/StashService";
|
||||||
|
import { ListHook } from "../../hooks/ListHook";
|
||||||
|
import { IBaseProps } from "../../models/base-props";
|
||||||
|
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||||
|
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
|
||||||
|
import { ErrorUtils } from "../../utils/errors";
|
||||||
|
import { ToastUtils } from "../../utils/toasts";
|
||||||
|
import { NavigationUtils } from "../../utils/navigation";
|
||||||
|
|
||||||
|
interface IProps extends IBaseProps {}
|
||||||
|
|
||||||
|
export const TagList: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
const [tags, setTags] = useState<GQL.AllTagsAllTags[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Editing / New state
|
||||||
|
const [editingTag, setEditingTag] = useState<Partial<GQL.TagDataFragment> | undefined>(undefined);
|
||||||
|
const [name, setName] = useState<string>("");
|
||||||
|
|
||||||
|
const { data, error, loading } = StashService.useAllTags();
|
||||||
|
const updateTag = StashService.useTagUpdate(getTagInput() as GQL.TagUpdateInput);
|
||||||
|
const createTag = StashService.useTagCreate(getTagInput() as GQL.TagCreateInput);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(loading);
|
||||||
|
if (!data || !data.allTags || !!error) { return; }
|
||||||
|
setTags(data.allTags);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!!editingTag) {
|
||||||
|
setName(editingTag.name || "");
|
||||||
|
} else {
|
||||||
|
setName("");
|
||||||
|
}
|
||||||
|
}, [editingTag]);
|
||||||
|
|
||||||
|
function getTagInput() {
|
||||||
|
const tagInput: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { name };
|
||||||
|
if (!!editingTag) { (tagInput as Partial<GQL.TagUpdateInput>).id = editingTag.id; }
|
||||||
|
return tagInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onEdit() {
|
||||||
|
try {
|
||||||
|
if (!!editingTag && !!editingTag.id) {
|
||||||
|
await updateTag();
|
||||||
|
ToastUtils.success("Updated tag");
|
||||||
|
} else {
|
||||||
|
await createTag();
|
||||||
|
ToastUtils.success("Created tag");
|
||||||
|
}
|
||||||
|
setEditingTag(undefined);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorUtils.handle(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || !data.allTags || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
|
||||||
|
if (!!error) { return <>{error.message}</>; }
|
||||||
|
|
||||||
|
const tagElements = tags.map((tag) => {
|
||||||
|
return (
|
||||||
|
<div key={tag.id} className="tag-list-row">
|
||||||
|
<span onClick={() => setEditingTag(tag)}>{tag.name}</span>
|
||||||
|
<div style={{float: "right"}}>
|
||||||
|
<Link className="bp3-button" to={NavigationUtils.makeTagScenesUrl(tag)}>Scenes: {tag.scene_count}</Link>
|
||||||
|
<Link className="bp3-button" to={NavigationUtils.makeTagSceneMarkersUrl(tag)}>
|
||||||
|
Markers: {tag.scene_marker_count}
|
||||||
|
</Link>
|
||||||
|
<span>Total: {(tag.scene_count || 0) + (tag.scene_marker_count || 0)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div id="tag-list-container">
|
||||||
|
<Button intent="primary" style={{marginTop: "20px"}} onClick={() => setEditingTag({})}>New Tag</Button>
|
||||||
|
<Dialog
|
||||||
|
isOpen={!!editingTag}
|
||||||
|
onClose={() => setEditingTag(undefined)}
|
||||||
|
title={!!editingTag && !!editingTag.id ? "Edit Tag" : "New Tag"}
|
||||||
|
>
|
||||||
|
<div className="dialog-content">
|
||||||
|
<FormGroup label="Name">
|
||||||
|
<InputGroup
|
||||||
|
onChange={(newValue: any) => setName(newValue.target.value)}
|
||||||
|
value={name}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</div>
|
||||||
|
<div className={Classes.DIALOG_FOOTER}>
|
||||||
|
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||||
|
<Button onClick={() => onEdit()}>{!!editingTag && !!editingTag.id ? "Update" : "Create"}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{tagElements}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
11
ui/v2/src/components/Tags/Tags.tsx
Normal file
11
ui/v2/src/components/Tags/Tags.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Route, Switch } from "react-router-dom";
|
||||||
|
import { TagList } from "./TagList";
|
||||||
|
|
||||||
|
const Tags = () => (
|
||||||
|
<Switch>
|
||||||
|
<Route exact={true} path="/tags" component={TagList} />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Tags;
|
||||||
98
ui/v2/src/components/Wall/Wall.scss
Normal file
98
ui/v2/src/components/Wall/Wall.scss
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
.wall-overlay {
|
||||||
|
background-color: rgba(0,0,0,.8);
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: transform .5s ease-in-out;
|
||||||
|
}
|
||||||
|
.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity .5s ease-in-out;
|
||||||
|
}
|
||||||
|
.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .5s ease-in-out;
|
||||||
|
}
|
||||||
|
.visible-unanimated {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.hidden-unanimated {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.double-scale {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
transform: scale(2);
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.double-scale img {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-wall-item-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transition: transform .5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-wall-item-container video {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-wall-item-text-container {
|
||||||
|
position: absolute;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #444;
|
||||||
|
padding: 5px;
|
||||||
|
width: 100%;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0.65));
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
& span {
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 10px;
|
||||||
|
margin: 0 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-wall-item-blur {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
left: -5px;
|
||||||
|
right: -5px;
|
||||||
|
bottom: -5px;
|
||||||
|
/*background-color: rgba(255, 255, 255, 0.75);*/
|
||||||
|
/*backdrop-filter: blur(5px);*/
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall.grid-item video, .wall.grid-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall.grid-item {
|
||||||
|
padding: 0 !important;
|
||||||
|
line-height: 0;
|
||||||
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
114
ui/v2/src/components/Wall/WallItem.tsx
Normal file
114
ui/v2/src/components/Wall/WallItem.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent, useRef, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { VideoHoverHook } from "../../hooks/VideoHover";
|
||||||
|
import { TextUtils } from "../../utils/text";
|
||||||
|
|
||||||
|
interface IWallItemProps {
|
||||||
|
scene?: GQL.SlimSceneDataFragment;
|
||||||
|
sceneMarker?: GQL.SceneMarkerDataFragment;
|
||||||
|
origin?: string;
|
||||||
|
onOverlay: (show: boolean) => void;
|
||||||
|
clickHandler?: (item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WallItem: FunctionComponent<IWallItemProps> = (props: IWallItemProps) => {
|
||||||
|
const [videoPath, setVideoPath] = useState<string | undefined>(undefined);
|
||||||
|
const videoHoverHook = VideoHoverHook.useVideoHover({resetOnMouseLeave: true});
|
||||||
|
|
||||||
|
function onMouseEnter() {
|
||||||
|
VideoHoverHook.onMouseEnter(videoHoverHook);
|
||||||
|
if (!videoPath || videoPath === "") {
|
||||||
|
if (!!props.sceneMarker) {
|
||||||
|
setVideoPath(props.sceneMarker.stream || "");
|
||||||
|
} else if (!!props.scene) {
|
||||||
|
setVideoPath(props.scene.paths.preview || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
props.onOverlay(true);
|
||||||
|
}
|
||||||
|
const debouncedOnMouseEnter = useRef(_.debounce(onMouseEnter, 500));
|
||||||
|
|
||||||
|
function onMouseLeave() {
|
||||||
|
VideoHoverHook.onMouseLeave(videoHoverHook);
|
||||||
|
setVideoPath("");
|
||||||
|
debouncedOnMouseEnter.current.cancel();
|
||||||
|
props.onOverlay(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
if (props.clickHandler === undefined) { return; }
|
||||||
|
if (props.scene !== undefined) {
|
||||||
|
props.clickHandler(props.scene);
|
||||||
|
} else if (props.sceneMarker !== undefined) {
|
||||||
|
props.clickHandler(props.sceneMarker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let linkSrc: string = "#";
|
||||||
|
if (props.clickHandler === undefined) {
|
||||||
|
if (props.scene !== undefined) {
|
||||||
|
linkSrc = `/scenes/${props.scene.id}`;
|
||||||
|
} else if (props.sceneMarker !== undefined) {
|
||||||
|
linkSrc = `/scenes/${props.sceneMarker.scene.id}?t=${props.sceneMarker.seconds}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTransitionEnd(event: React.TransitionEvent<HTMLDivElement>) {
|
||||||
|
const target = (event.target as any);
|
||||||
|
if (target.classList.contains("double-scale")) {
|
||||||
|
target.parentElement.style.zIndex = 10;
|
||||||
|
} else {
|
||||||
|
target.parentElement.style.zIndex = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let previewSrc: string = "";
|
||||||
|
let title: string = "";
|
||||||
|
let tags: JSX.Element[] = [];
|
||||||
|
if (!!props.sceneMarker) {
|
||||||
|
previewSrc = props.sceneMarker.preview;
|
||||||
|
title = `${props.sceneMarker!.title} - ${TextUtils.secondsToTimestamp(props.sceneMarker.seconds)}`;
|
||||||
|
tags = props.sceneMarker.tags.map((tag) => (<span key={tag.id}>{tag.name}</span>));
|
||||||
|
tags.unshift(<span key={props.sceneMarker.primary_tag.id}>{props.sceneMarker.primary_tag.name}</span>);
|
||||||
|
} else if (!!props.scene) {
|
||||||
|
previewSrc = props.scene.paths.webp || "";
|
||||||
|
title = props.scene.title || "";
|
||||||
|
// tags = props.scene.tags.map((tag) => (<span key={tag.id}>{tag.name}</span>));
|
||||||
|
}
|
||||||
|
|
||||||
|
const className = ["scene-wall-item-container"];
|
||||||
|
if (videoHoverHook.isHovering.current) { className.push("double-scale"); }
|
||||||
|
const style: React.CSSProperties = {};
|
||||||
|
if (!!props.origin) { style.transformOrigin = props.origin; }
|
||||||
|
return (
|
||||||
|
<div className="wall grid-item">
|
||||||
|
<div
|
||||||
|
className={className.join(" ")}
|
||||||
|
style={style}
|
||||||
|
onTransitionEnd={onTransitionEnd}
|
||||||
|
onMouseEnter={() => debouncedOnMouseEnter.current()}
|
||||||
|
onMouseMove={() => debouncedOnMouseEnter.current()}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
>
|
||||||
|
<Link onClick={() => onClick()} to={linkSrc}>
|
||||||
|
<video
|
||||||
|
src={videoPath}
|
||||||
|
style={videoHoverHook.isHovering.current ? {} : {display: "none"}}
|
||||||
|
autoPlay={true}
|
||||||
|
loop={true}
|
||||||
|
ref={videoHoverHook.videoEl}
|
||||||
|
/>
|
||||||
|
<img src={previewSrc} />
|
||||||
|
<div className="scene-wall-item-text-container">
|
||||||
|
<div style={{lineHeight: 1}}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{tags}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
89
ui/v2/src/components/Wall/WallPanel.tsx
Normal file
89
ui/v2/src/components/Wall/WallPanel.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent, useState } from "react";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import "./Wall.scss";
|
||||||
|
import { WallItem } from "./WallItem";
|
||||||
|
|
||||||
|
interface IWallPanelProps {
|
||||||
|
scenes?: GQL.SlimSceneDataFragment[];
|
||||||
|
sceneMarkers?: GQL.SceneMarkerDataFragment[];
|
||||||
|
clickHandler?: (item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WallPanel: FunctionComponent<IWallPanelProps> = (props: IWallPanelProps) => {
|
||||||
|
const [showOverlay, setShowOverlay] = useState<boolean>(false);
|
||||||
|
|
||||||
|
function onOverlay(show: boolean) {
|
||||||
|
setShowOverlay(show);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrigin(index: number, rowSize: number, total: number): string {
|
||||||
|
const isAtStart = index % rowSize === 0;
|
||||||
|
const isAtEnd = index % rowSize === rowSize - 1;
|
||||||
|
const endRemaining = total % rowSize;
|
||||||
|
|
||||||
|
// First row
|
||||||
|
if (total === 1) { return "top"; }
|
||||||
|
if (index === 0) { return "top left"; }
|
||||||
|
if (index === rowSize - 1 || (total < rowSize && index === total - 1)) { return "top right"; }
|
||||||
|
if (index < rowSize) { return "top"; }
|
||||||
|
|
||||||
|
// Bottom row
|
||||||
|
if (isAtEnd && index === total - 1) { return "bottom right"; }
|
||||||
|
if (isAtStart && index === total - rowSize) { return "bottom left"; }
|
||||||
|
if (endRemaining !== 0 && index >= total - endRemaining) { return "bottom"; }
|
||||||
|
if (endRemaining === 0 && index >= total - rowSize) { return "bottom"; }
|
||||||
|
|
||||||
|
// Everything else
|
||||||
|
if (isAtStart) { return "center left"; }
|
||||||
|
if (isAtEnd) { return "center right"; }
|
||||||
|
return "center";
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderScenes() {
|
||||||
|
if (props.scenes === undefined) { return; }
|
||||||
|
return props.scenes.map((scene, index) => {
|
||||||
|
const origin = getOrigin(index, 5, props.scenes!.length);
|
||||||
|
return (
|
||||||
|
<WallItem
|
||||||
|
key={scene.id}
|
||||||
|
scene={scene}
|
||||||
|
onOverlay={onOverlay}
|
||||||
|
clickHandler={props.clickHandler}
|
||||||
|
origin={origin}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderSceneMarkers() {
|
||||||
|
if (props.sceneMarkers === undefined) { return; }
|
||||||
|
return props.sceneMarkers.map((marker, index) => {
|
||||||
|
const origin = getOrigin(index, 5, props.sceneMarkers!.length);
|
||||||
|
return (
|
||||||
|
<WallItem
|
||||||
|
key={marker.id}
|
||||||
|
sceneMarker={marker}
|
||||||
|
onOverlay={onOverlay}
|
||||||
|
clickHandler={props.clickHandler}
|
||||||
|
origin={origin}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const overlayClassName = showOverlay ? "visible" : "hidden";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`wall-overlay ${overlayClassName}`} />
|
||||||
|
<div className="wall grid">
|
||||||
|
{maybeRenderScenes()}
|
||||||
|
{maybeRenderSceneMarkers()}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return render();
|
||||||
|
};
|
||||||
140
ui/v2/src/components/list/AddFilter.tsx
Normal file
140
ui/v2/src/components/list/AddFilter.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Classes,
|
||||||
|
Dialog,
|
||||||
|
FormGroup,
|
||||||
|
HTMLSelect,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent, useEffect, useRef, useState } from "react";
|
||||||
|
import { isArray } from "util";
|
||||||
|
import { Criterion, CriterionType } from "../../models/list-filter/criteria/criterion";
|
||||||
|
import { NoneCriterion } from "../../models/list-filter/criteria/none";
|
||||||
|
import { PerformersCriterion } from "../../models/list-filter/criteria/performers";
|
||||||
|
import { StudiosCriterion } from "../../models/list-filter/criteria/studios";
|
||||||
|
import { TagsCriterion } from "../../models/list-filter/criteria/tags";
|
||||||
|
import { makeCriteria } from "../../models/list-filter/criteria/utils";
|
||||||
|
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||||
|
import { FilterMultiSelect } from "../select/FilterMultiSelect";
|
||||||
|
|
||||||
|
interface IAddFilterProps {
|
||||||
|
onAddCriterion: (criterion: Criterion) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
filter: ListFilterModel;
|
||||||
|
editingCriterion?: Criterion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddFilter: FunctionComponent<IAddFilterProps> = (props: IAddFilterProps) => {
|
||||||
|
const singleValueSelect = useRef<HTMLSelect>(null);
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [criterion, setCriterion] = useState<Criterion<any, any>>(new NoneCriterion());
|
||||||
|
|
||||||
|
// 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 onChangedSingleSelect(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
|
const newCriterion = _.cloneDeep(criterion);
|
||||||
|
newCriterion.value = event.target.value;
|
||||||
|
setCriterion(newCriterion);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddFilter() {
|
||||||
|
if (!isArray(criterion.value) && !!singleValueSelect.current) {
|
||||||
|
const value = singleValueSelect.current.props.defaultValue;
|
||||||
|
if (value === undefined || value === "" || typeof value === "number") { criterion.value = criterion.options[0]; }
|
||||||
|
}
|
||||||
|
props.onAddCriterion(criterion);
|
||||||
|
onToggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggle() {
|
||||||
|
if (isOpen) {
|
||||||
|
props.onCancel();
|
||||||
|
}
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
setCriterion(makeCriteria());
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeRenderFilterPopoverContents = () => {
|
||||||
|
if (criterion.type === "none") { return; }
|
||||||
|
|
||||||
|
function renderSelect() {
|
||||||
|
if (isArray(criterion.value)) {
|
||||||
|
let type: "performers" | "studios" | "tags" | "" = "";
|
||||||
|
if (criterion instanceof PerformersCriterion) {
|
||||||
|
type = "performers";
|
||||||
|
} else if (criterion instanceof StudiosCriterion) {
|
||||||
|
type = "studios";
|
||||||
|
} else if (criterion instanceof TagsCriterion) {
|
||||||
|
type = "tags";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "") {
|
||||||
|
return (<>todo</>);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<FilterMultiSelect
|
||||||
|
type={type}
|
||||||
|
onUpdate={(items) => criterion.value = items.map((i) => ({id: i.id, label: i.name!}))}
|
||||||
|
openOnKeyDown={true}
|
||||||
|
initialIds={criterion.value.map((labeled) => labeled.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<HTMLSelect
|
||||||
|
ref={singleValueSelect}
|
||||||
|
options={criterion.options}
|
||||||
|
onChange={onChangedSingleSelect}
|
||||||
|
defaultValue={criterion.value}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <FormGroup>{renderSelect()}</FormGroup>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function maybeRenderFilterSelect() {
|
||||||
|
if (!!props.editingCriterion) { return; }
|
||||||
|
return (
|
||||||
|
<FormGroup label="Filter">
|
||||||
|
<HTMLSelect
|
||||||
|
style={{flexBasis: "min-content"}}
|
||||||
|
options={props.filter.criterionOptions}
|
||||||
|
onChange={onChangedCriteriaType}
|
||||||
|
defaultValue={criterion.type}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = !props.editingCriterion ? "Add Filter" : "Update Filter";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => onToggle()} active={isOpen} large={true}>Filter</Button>
|
||||||
|
<Dialog isOpen={isOpen} onClose={() => onToggle()} title={title}>
|
||||||
|
<div className="dialog-content">
|
||||||
|
{maybeRenderFilterSelect()}
|
||||||
|
{maybeRenderFilterPopoverContents()}
|
||||||
|
</div>
|
||||||
|
<div className={Classes.DIALOG_FOOTER}>
|
||||||
|
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||||
|
<Button onClick={onAddFilter} disabled={criterion.type === "none"}>{title}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
188
ui/v2/src/components/list/ListFilter.tsx
Normal file
188
ui/v2/src/components/list/ListFilter.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
AnchorButton,
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
ControlGroup,
|
||||||
|
HTMLSelect,
|
||||||
|
InputGroup,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Popover,
|
||||||
|
Tag,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import React, { FunctionComponent, SyntheticEvent, useEffect, useRef, useState } from "react";
|
||||||
|
import { Criterion } from "../../models/list-filter/criteria/criterion";
|
||||||
|
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||||
|
import { DisplayMode } from "../../models/list-filter/types";
|
||||||
|
import { AddFilter } from "./AddFilter";
|
||||||
|
|
||||||
|
interface IListFilterProps {
|
||||||
|
onChangePageSize: (pageSize: number) => void;
|
||||||
|
onChangeQuery: (query: string) => void;
|
||||||
|
onChangeSortDirection: (sortDirection: "asc" | "desc") => void;
|
||||||
|
onChangeSortBy: (sortBy: string) => void;
|
||||||
|
onChangeDisplayMode: (displayMode: DisplayMode) => void;
|
||||||
|
onAddCriterion: (criterion: Criterion) => void;
|
||||||
|
onRemoveCriterion: (criterion: Criterion) => void;
|
||||||
|
filter: ListFilterModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120"];
|
||||||
|
|
||||||
|
export const ListFilter: FunctionComponent<IListFilterProps> = (props: IListFilterProps) => {
|
||||||
|
let searchCallback: any;
|
||||||
|
|
||||||
|
const [editingCriterion, setEditingCriterion] = useState<Criterion | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
searchCallback = debounce((event) => {
|
||||||
|
props.onChangeQuery(event.target.value);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
function onChangePageSize(event: SyntheticEvent<HTMLSelectElement>) {
|
||||||
|
const val = event!.currentTarget!.value;
|
||||||
|
props.onChangePageSize(parseInt(val, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeQuery(event: SyntheticEvent<HTMLInputElement>) {
|
||||||
|
event.persist();
|
||||||
|
searchCallback(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeSortDirection(_: any) {
|
||||||
|
if (props.filter.sortDirection === "asc") {
|
||||||
|
props.onChangeSortDirection("desc");
|
||||||
|
} else {
|
||||||
|
props.onChangeSortDirection("asc");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeSortBy(event: React.MouseEvent<any>) {
|
||||||
|
props.onChangeSortBy(event.currentTarget.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeDisplayMode(displayMode: DisplayMode) {
|
||||||
|
props.onChangeDisplayMode(displayMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddCriterion(criterion: Criterion) {
|
||||||
|
props.onAddCriterion(criterion);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancelAddCriterion() {
|
||||||
|
setEditingCriterion(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
let removedCriterionId = "";
|
||||||
|
function onRemoveCriterionTag(criterion?: Criterion) {
|
||||||
|
if (!criterion) { return; }
|
||||||
|
setEditingCriterion(undefined);
|
||||||
|
removedCriterionId = criterion.getId();
|
||||||
|
props.onRemoveCriterion(criterion);
|
||||||
|
}
|
||||||
|
function onClickCriterionTag(criterion?: Criterion) {
|
||||||
|
if (!criterion || removedCriterionId !== "") { return; }
|
||||||
|
setEditingCriterion(criterion);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSortByOptions() {
|
||||||
|
return props.filter.sortByOptions.map((option) => (
|
||||||
|
<MenuItem onClick={onChangeSortBy} text={option} key={option} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDisplayModeOptions() {
|
||||||
|
function getIcon(option: DisplayMode) {
|
||||||
|
switch (option) {
|
||||||
|
case DisplayMode.Grid: return "grid-view";
|
||||||
|
case DisplayMode.List: return "list";
|
||||||
|
case DisplayMode.Wall: return "symbol-square";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getLabel(option: DisplayMode) {
|
||||||
|
switch (option) {
|
||||||
|
case DisplayMode.Grid: return "Grid";
|
||||||
|
case DisplayMode.List: return "List";
|
||||||
|
case DisplayMode.Wall: return "Wall";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return props.filter.displayModeOptions.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option}
|
||||||
|
active={props.filter.displayMode === option}
|
||||||
|
onClick={() => onChangeDisplayMode(option)}
|
||||||
|
icon={getIcon(option)}
|
||||||
|
text={getLabel(option)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFilterTags() {
|
||||||
|
return props.filter.criteria.map((criterion) => (
|
||||||
|
<Tag
|
||||||
|
key={criterion.type}
|
||||||
|
className="tag-item"
|
||||||
|
itemID={criterion.getId()}
|
||||||
|
interactive={true}
|
||||||
|
onRemove={() => onRemoveCriterionTag(criterion)}
|
||||||
|
onClick={() => onClickCriterionTag(criterion)}
|
||||||
|
>
|
||||||
|
{criterion.getLabel()}
|
||||||
|
</Tag>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="filter-container">
|
||||||
|
<InputGroup
|
||||||
|
large={true}
|
||||||
|
placeholder="Search..."
|
||||||
|
defaultValue={props.filter.searchTerm}
|
||||||
|
onChange={onChangeQuery}
|
||||||
|
className="filter-item"
|
||||||
|
/>
|
||||||
|
<HTMLSelect
|
||||||
|
large={true}
|
||||||
|
style={{flexBasis: "min-content"}}
|
||||||
|
options={PAGE_SIZE_OPTIONS}
|
||||||
|
onChange={onChangePageSize}
|
||||||
|
value={props.filter.itemsPerPage}
|
||||||
|
className="filter-item"
|
||||||
|
/>
|
||||||
|
<ControlGroup className="filter-item">
|
||||||
|
<AnchorButton
|
||||||
|
rightIcon={props.filter.sortDirection === "asc" ? "caret-up" : "caret-down"}
|
||||||
|
onClick={onChangeSortDirection}
|
||||||
|
>
|
||||||
|
{props.filter.sortDirection === "asc" ? "Ascending" : "Descending"}
|
||||||
|
</AnchorButton>
|
||||||
|
<Popover position="bottom">
|
||||||
|
<Button large={true}>{props.filter.sortBy}</Button>
|
||||||
|
<Menu>{renderSortByOptions()}</Menu>
|
||||||
|
</Popover>
|
||||||
|
</ControlGroup>
|
||||||
|
|
||||||
|
<AddFilter
|
||||||
|
filter={props.filter}
|
||||||
|
onAddCriterion={onAddCriterion}
|
||||||
|
onCancel={onCancelAddCriterion}
|
||||||
|
editingCriterion={editingCriterion}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ButtonGroup className="filter-item">
|
||||||
|
{renderDisplayModeOptions()}
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
<div style={{display: "flex", justifyContent: "center", margin: "10px auto"}}>
|
||||||
|
{renderFilterTags()}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return render();
|
||||||
|
};
|
||||||
119
ui/v2/src/components/list/Pagination.tsx
Normal file
119
ui/v2/src/components/list/Pagination.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Button, ButtonGroup } from "@blueprintjs/core";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface IPaginationProps {
|
||||||
|
itemsPerPage: number;
|
||||||
|
currentPage: number;
|
||||||
|
totalItems: number;
|
||||||
|
onChangePage: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPaginationState {
|
||||||
|
pages: number[];
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Pagination extends React.Component<IPaginationProps, IPaginationState> {
|
||||||
|
constructor(props: IPaginationProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
pages: [],
|
||||||
|
totalPages: Number.MAX_SAFE_INTEGER,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentWillMount() {
|
||||||
|
this.setPage(this.props.currentPage, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidUpdate(prevProps: IPaginationProps) {
|
||||||
|
if (this.props.totalItems !== prevProps.totalItems || this.props.itemsPerPage !== prevProps.itemsPerPage) {
|
||||||
|
this.setPage(this.props.currentPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (!this.state || !this.state.pages || this.state.pages.length <= 1) { return null; }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonGroup large={true} className="filter-container">
|
||||||
|
<Button
|
||||||
|
text="First"
|
||||||
|
disabled={this.props.currentPage === 1}
|
||||||
|
onClick={() => this.setPage(1)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
text="Previous"
|
||||||
|
disabled={this.props.currentPage === 1}
|
||||||
|
onClick={() => this.setPage(this.props.currentPage - 1)}
|
||||||
|
/>
|
||||||
|
{this.renderPageButtons()}
|
||||||
|
<Button
|
||||||
|
text="Next"
|
||||||
|
disabled={this.props.currentPage === this.state.totalPages}
|
||||||
|
onClick={() => this.setPage(this.props.currentPage + 1)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
text="Last"
|
||||||
|
disabled={this.props.currentPage === this.state.totalPages}
|
||||||
|
onClick={() => this.setPage(this.state.totalPages)}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderPageButtons() {
|
||||||
|
return this.state.pages.map((page: number, index: number) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
text={page}
|
||||||
|
active={this.props.currentPage === page}
|
||||||
|
onClick={() => this.setPage(page)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setPage(page?: number, propagate: boolean = true) {
|
||||||
|
if (page === undefined) { return; }
|
||||||
|
|
||||||
|
const pagerState = this.getPagerState(this.props.totalItems, page, this.props.itemsPerPage);
|
||||||
|
|
||||||
|
if (page < 1) { page = 1; }
|
||||||
|
if (page > pagerState.totalPages) { page = pagerState.totalPages; }
|
||||||
|
|
||||||
|
this.setState(pagerState);
|
||||||
|
if (propagate) { this.props.onChangePage(page); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPagerState(totalItems: number, currentPage: number, pageSize: number) {
|
||||||
|
const totalPages = Math.ceil(totalItems / pageSize);
|
||||||
|
|
||||||
|
let startPage: number;
|
||||||
|
let endPage: number;
|
||||||
|
if (totalPages <= 10) {
|
||||||
|
// less than 10 total pages so show all
|
||||||
|
startPage = 1;
|
||||||
|
endPage = totalPages;
|
||||||
|
} else {
|
||||||
|
// more than 10 total pages so calculate start and end pages
|
||||||
|
if (currentPage <= 6) {
|
||||||
|
startPage = 1;
|
||||||
|
endPage = 10;
|
||||||
|
} else if (currentPage + 4 >= totalPages) {
|
||||||
|
startPage = totalPages - 9;
|
||||||
|
endPage = totalPages;
|
||||||
|
} else {
|
||||||
|
startPage = currentPage - 5;
|
||||||
|
endPage = currentPage + 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// create an array of pages numbers
|
||||||
|
const pages = [...Array((endPage + 1) - startPage).keys()].map((i) => startPage + i);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pages,
|
||||||
|
totalPages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
50
ui/v2/src/components/performers/PerformerCard.tsx
Normal file
50
ui/v2/src/components/performers/PerformerCard.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Elevation,
|
||||||
|
H4,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { TextUtils } from "../../utils/text";
|
||||||
|
|
||||||
|
interface IPerformerCardProps {
|
||||||
|
performer: GQL.PerformerDataFragment;
|
||||||
|
ageFromDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PerformerCard: FunctionComponent<IPerformerCardProps> = (props: IPerformerCardProps) => {
|
||||||
|
const age = TextUtils.age(props.performer.birthdate, props.ageFromDate);
|
||||||
|
const ageString = `${age} years old${!!props.ageFromDate ? " in this scene." : "."}`;
|
||||||
|
|
||||||
|
function maybeRenderFavoriteBanner() {
|
||||||
|
if (props.performer.favorite === false) { return; }
|
||||||
|
return (
|
||||||
|
<div className={`rating-banner rating-5`}>
|
||||||
|
FAVORITE
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="grid-item"
|
||||||
|
elevation={Elevation.ONE}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={`/performers/${props.performer.id}`}
|
||||||
|
className="performer previewable image"
|
||||||
|
style={{backgroundImage: `url(${props.performer.image_path})`}}
|
||||||
|
>
|
||||||
|
{maybeRenderFavoriteBanner()}
|
||||||
|
</Link>
|
||||||
|
<div className="card-section">
|
||||||
|
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
|
||||||
|
{props.performer.name}
|
||||||
|
</H4>
|
||||||
|
{age !== 0 ? <span className="bp3-text-muted block">{ageString}</span> : undefined}
|
||||||
|
<span className="bp3-text-muted block">Stars in {props.performer.scene_count} scenes.</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
285
ui/v2/src/components/performers/PerformerDetails/Performer.tsx
Normal file
285
ui/v2/src/components/performers/PerformerDetails/Performer.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Classes,
|
||||||
|
Dialog,
|
||||||
|
EditableText,
|
||||||
|
HTMLTable,
|
||||||
|
Spinner,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||||
|
import * as GQL from "../../../core/generated-graphql";
|
||||||
|
import { StashService } from "../../../core/StashService";
|
||||||
|
import { IBaseProps } from "../../../models";
|
||||||
|
import { ErrorUtils } from "../../../utils/errors";
|
||||||
|
import { TableUtils } from "../../../utils/table";
|
||||||
|
import { FreeOnesPerformerSuggest } from "../../select/FreeOnesPerformerSuggest";
|
||||||
|
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar";
|
||||||
|
|
||||||
|
interface IPerformerProps extends IBaseProps {}
|
||||||
|
|
||||||
|
export const Performer: FunctionComponent<IPerformerProps> = (props: IPerformerProps) => {
|
||||||
|
const isNew = props.match.params.id === "new";
|
||||||
|
|
||||||
|
// Editing state
|
||||||
|
const [isEditing, setIsEditing] = useState<boolean>(isNew);
|
||||||
|
const [isDisplayingScraperDialog, setIsDisplayingScraperDialog] = useState<"freeones" | undefined>(undefined);
|
||||||
|
const [scrapePerformerName, setScrapePerformerName] = useState<string>("");
|
||||||
|
|
||||||
|
// Editing performer state
|
||||||
|
const [image, setImage] = useState<string | undefined>(undefined);
|
||||||
|
const [name, setName] = useState<string | undefined>(undefined);
|
||||||
|
const [aliases, setAliases] = useState<string | undefined>(undefined);
|
||||||
|
const [favorite, setFavorite] = useState<boolean | undefined>(undefined);
|
||||||
|
const [birthdate, setBirthdate] = useState<string | undefined>(undefined);
|
||||||
|
const [ethnicity, setEthnicity] = useState<string | undefined>(undefined);
|
||||||
|
const [country, setCountry] = useState<string | undefined>(undefined);
|
||||||
|
const [eyeColor, setEyeColor] = useState<string | undefined>(undefined);
|
||||||
|
const [height, setHeight] = useState<string | undefined>(undefined);
|
||||||
|
const [measurements, setMeasurements] = useState<string | undefined>(undefined);
|
||||||
|
const [fakeTits, setFakeTits] = useState<string | undefined>(undefined);
|
||||||
|
const [careerLength, setCareerLength] = useState<string | undefined>(undefined);
|
||||||
|
const [tattoos, setTattoos] = useState<string | undefined>(undefined);
|
||||||
|
const [piercings, setPiercings] = useState<string | undefined>(undefined);
|
||||||
|
const [url, setUrl] = useState<string | undefined>(undefined);
|
||||||
|
const [twitter, setTwitter] = useState<string | undefined>(undefined);
|
||||||
|
const [instagram, setInstagram] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
// Performer state
|
||||||
|
const [performer, setPerformer] = useState<Partial<GQL.PerformerDataFragment>>({});
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
// Network state
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { data, error, loading } = StashService.useFindPerformer(props.match.params.id);
|
||||||
|
const updatePerformer = StashService.usePerformerUpdate(getPerformerInput() as GQL.PerformerUpdateInput);
|
||||||
|
const createPerformer = StashService.usePerformerCreate(getPerformerInput() as GQL.PerformerCreateInput);
|
||||||
|
|
||||||
|
function updatePerformerEditState(state: Partial<GQL.PerformerDataFragment | GQL.ScrapeFreeonesScrapeFreeones>) {
|
||||||
|
if ((state as GQL.PerformerDataFragment).favorite !== undefined) {
|
||||||
|
setFavorite((state as GQL.PerformerDataFragment).favorite);
|
||||||
|
}
|
||||||
|
setName(state.name);
|
||||||
|
setAliases(state.aliases);
|
||||||
|
setBirthdate(state.birthdate);
|
||||||
|
setEthnicity(state.ethnicity);
|
||||||
|
setCountry(state.country);
|
||||||
|
setEyeColor(state.eye_color);
|
||||||
|
setHeight(state.height);
|
||||||
|
setMeasurements(state.measurements);
|
||||||
|
setFakeTits(state.fake_tits);
|
||||||
|
setCareerLength(state.career_length);
|
||||||
|
setTattoos(state.tattoos);
|
||||||
|
setPiercings(state.piercings);
|
||||||
|
setUrl(state.url);
|
||||||
|
setTwitter(state.twitter);
|
||||||
|
setInstagram(state.instagram);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(loading);
|
||||||
|
if (!data || !data.findPerformer || !!error) { return; }
|
||||||
|
setPerformer(data.findPerformer);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImagePreview(performer.image_path);
|
||||||
|
setImage(undefined);
|
||||||
|
updatePerformerEditState(performer);
|
||||||
|
if (!isNew) {
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
}, [performer]);
|
||||||
|
|
||||||
|
if (!isNew && !isEditing) {
|
||||||
|
if (!data || !data.findPerformer || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
|
||||||
|
if (!!error) { return <>error...</>; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPerformerInput() {
|
||||||
|
const performerInput: Partial<GQL.PerformerCreateInput | GQL.PerformerUpdateInput> = {
|
||||||
|
name,
|
||||||
|
aliases,
|
||||||
|
favorite,
|
||||||
|
birthdate,
|
||||||
|
ethnicity,
|
||||||
|
country,
|
||||||
|
eye_color: eyeColor,
|
||||||
|
height,
|
||||||
|
measurements,
|
||||||
|
fake_tits: fakeTits,
|
||||||
|
career_length: careerLength,
|
||||||
|
tattoos,
|
||||||
|
piercings,
|
||||||
|
url,
|
||||||
|
twitter,
|
||||||
|
instagram,
|
||||||
|
image,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isNew) {
|
||||||
|
(performerInput as GQL.PerformerUpdateInput).id = props.match.params.id;
|
||||||
|
}
|
||||||
|
return performerInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (!isNew) {
|
||||||
|
const result = await updatePerformer();
|
||||||
|
setPerformer(result.data.performerUpdate);
|
||||||
|
} else {
|
||||||
|
const result = await createPerformer();
|
||||||
|
setPerformer(result.data.performerCreate);
|
||||||
|
props.history.push(`/performers/${result.data.performerCreate.id}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ErrorUtils.handle(e);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||||
|
const file: File = (event.target as any).files[0];
|
||||||
|
const reader: FileReader = new FileReader();
|
||||||
|
|
||||||
|
reader.onloadend = (e) => {
|
||||||
|
setImagePreview(reader.result as string);
|
||||||
|
setImage(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDisplayFreeOnesDialog() {
|
||||||
|
setIsDisplayingScraperDialog("freeones");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onScrapeFreeOnes() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (!scrapePerformerName) { return; }
|
||||||
|
const result = await StashService.queryScrapeFreeones(scrapePerformerName);
|
||||||
|
if (!result.data || !result.data.scrapeFreeones) { return; }
|
||||||
|
updatePerformerEditState(result.data.scrapeFreeones);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorUtils.handle(e);
|
||||||
|
} finally {
|
||||||
|
setIsDisplayingScraperDialog(undefined);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEthnicity() {
|
||||||
|
return TableUtils.renderHtmlSelect({
|
||||||
|
title: "Ethnicity",
|
||||||
|
value: ethnicity,
|
||||||
|
isEditing,
|
||||||
|
onChange: (value: string) => setEthnicity(value),
|
||||||
|
selectOptions: ["white", "black", "asian", "hispanic"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScraperDialog() {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={!!isDisplayingScraperDialog}
|
||||||
|
onClose={() => setIsDisplayingScraperDialog(undefined)}
|
||||||
|
title="Scrape"
|
||||||
|
>
|
||||||
|
<div className="dialog-content">
|
||||||
|
<FreeOnesPerformerSuggest
|
||||||
|
placeholder="Performer name"
|
||||||
|
style={{width: "100%"}}
|
||||||
|
onQueryChange={(query) => setScrapePerformerName(query)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={Classes.DIALOG_FOOTER}>
|
||||||
|
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||||
|
<Button onClick={() => onScrapeFreeOnes()}>Scrape</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderScraperDialog()}
|
||||||
|
<div className="columns is-multiline no-spacing">
|
||||||
|
<div className="column is-half details-image-container">
|
||||||
|
<img className="performer" src={imagePreview} />
|
||||||
|
</div>
|
||||||
|
<div className="column is-half details-detail-container">
|
||||||
|
<DetailsEditNavbar
|
||||||
|
performer={performer}
|
||||||
|
isNew={isNew}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onToggleEdit={() => { setIsEditing(!isEditing); updatePerformerEditState(performer); }}
|
||||||
|
onSave={onSave}
|
||||||
|
onImageChange={onImageChange}
|
||||||
|
onDisplayFreeOnesDialog={onDisplayFreeOnesDialog}
|
||||||
|
/>
|
||||||
|
<h1 className="bp3-heading">
|
||||||
|
<EditableText
|
||||||
|
disabled={!isEditing}
|
||||||
|
value={name}
|
||||||
|
placeholder="Name"
|
||||||
|
onChange={(value) => setName(value)}
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
<h6 className="bp3-heading">
|
||||||
|
<span style={{fontWeight: 300}}>Aliases: </span>
|
||||||
|
<EditableText
|
||||||
|
disabled={!isEditing}
|
||||||
|
value={aliases}
|
||||||
|
placeholder="Aliases"
|
||||||
|
onChange={(value) => setAliases(value)}
|
||||||
|
/>
|
||||||
|
</h6>
|
||||||
|
<div>
|
||||||
|
<span style={{fontWeight: 300}}>Favorite:</span>
|
||||||
|
<Button
|
||||||
|
icon="heart"
|
||||||
|
disabled={!isEditing}
|
||||||
|
className={favorite ? "favorite" : undefined}
|
||||||
|
onClick={() => setFavorite(!favorite)}
|
||||||
|
minimal={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HTMLTable style={{width: "100%"}}>
|
||||||
|
<tbody>
|
||||||
|
{TableUtils.renderEditableTextTableRow(
|
||||||
|
{title: "Birthdate (YYYY-MM-DD)", value: birthdate, isEditing, onChange: setBirthdate})}
|
||||||
|
{renderEthnicity()}
|
||||||
|
{TableUtils.renderEditableTextTableRow(
|
||||||
|
{title: "Eye Color", value: eyeColor, isEditing, onChange: setEyeColor})}
|
||||||
|
{TableUtils.renderEditableTextTableRow(
|
||||||
|
{title: "Country", value: country, isEditing, onChange: setCountry})}
|
||||||
|
{TableUtils.renderEditableTextTableRow(
|
||||||
|
{title: "Height (CM)", value: height, isEditing, onChange: setHeight})}
|
||||||
|
{TableUtils.renderEditableTextTableRow(
|
||||||
|
{title: "Measurements", value: measurements, isEditing, onChange: setMeasurements})}
|
||||||
|
{TableUtils.renderEditableTextTableRow(
|
||||||
|
{title: "Fake Tits", value: fakeTits, isEditing, onChange: setFakeTits})}
|
||||||
|
{TableUtils.renderEditableTextTableRow(
|
||||||
|
{title: "Career Length", value: careerLength, isEditing, onChange: setCareerLength})}
|
||||||
|
{TableUtils.renderEditableTextTableRow(
|
||||||
|
{title: "Tattoos", value: tattoos, isEditing, onChange: setTattoos})}
|
||||||
|
{TableUtils.renderEditableTextTableRow(
|
||||||
|
{title: "Piercings", value: piercings, isEditing, onChange: setPiercings})}
|
||||||
|
{TableUtils.renderEditableTextTableRow(
|
||||||
|
{title: "URL", value: url, isEditing, onChange: setUrl})}
|
||||||
|
{TableUtils.renderEditableTextTableRow(
|
||||||
|
{title: "Twitter", value: twitter, isEditing, onChange: setTwitter})}
|
||||||
|
{TableUtils.renderEditableTextTableRow(
|
||||||
|
{title: "Instagram", value: instagram, isEditing, onChange: setInstagram})}
|
||||||
|
</tbody>
|
||||||
|
</HTMLTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
ui/v2/src/components/performers/PerformerList.tsx
Normal file
37
ui/v2/src/components/performers/PerformerList.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import { QueryHookResult } from "react-apollo-hooks";
|
||||||
|
import { FindPerformersQuery, FindPerformersVariables } from "../../core/generated-graphql";
|
||||||
|
import { ListHook } from "../../hooks/ListHook";
|
||||||
|
import { IBaseProps } from "../../models/base-props";
|
||||||
|
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||||
|
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
|
||||||
|
import { PerformerCard } from "./PerformerCard";
|
||||||
|
|
||||||
|
interface IPerformerListProps extends IBaseProps {}
|
||||||
|
|
||||||
|
export const PerformerList: FunctionComponent<IPerformerListProps> = (props: IPerformerListProps) => {
|
||||||
|
const listData = ListHook.useList({
|
||||||
|
filterMode: FilterMode.Performers,
|
||||||
|
props,
|
||||||
|
renderContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderContent(
|
||||||
|
result: QueryHookResult<FindPerformersQuery, FindPerformersVariables>, filter: ListFilterModel) {
|
||||||
|
if (!result.data || !result.data.findPerformers) { return; }
|
||||||
|
if (filter.displayMode === DisplayMode.Grid) {
|
||||||
|
return (
|
||||||
|
<div className="grid">
|
||||||
|
{result.data.findPerformers.performers.map((p) => (<PerformerCard key={p.id} performer={p} />))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (filter.displayMode === DisplayMode.List) {
|
||||||
|
return <h1>TODO</h1>;
|
||||||
|
} else if (filter.displayMode === DisplayMode.Wall) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listData.template;
|
||||||
|
};
|
||||||
13
ui/v2/src/components/performers/performers.tsx
Normal file
13
ui/v2/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={true} path="/performers" component={PerformerList} />
|
||||||
|
<Route path="/performers/:id" component={Performer} />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Performers;
|
||||||
149
ui/v2/src/components/scenes/SceneCard.tsx
Normal file
149
ui/v2/src/components/scenes/SceneCard.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Elevation,
|
||||||
|
H4,
|
||||||
|
Popover,
|
||||||
|
Tag,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import React, { FunctionComponent, RefObject, useEffect, useRef, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { VideoHoverHook } from "../../hooks/VideoHover";
|
||||||
|
import { ColorUtils } from "../../utils/color";
|
||||||
|
import { TextUtils } from "../../utils/text";
|
||||||
|
import { SceneHelpers } from "./helpers";
|
||||||
|
|
||||||
|
interface ISceneCardProps {
|
||||||
|
scene: GQL.SlimSceneDataFragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardProps) => {
|
||||||
|
const [previewPath, setPreviewPath] = useState<string | undefined>(undefined);
|
||||||
|
const videoHoverHook = VideoHoverHook.useVideoHover();
|
||||||
|
|
||||||
|
function maybeRenderRatingBanner() {
|
||||||
|
if (!props.scene.rating) { return; }
|
||||||
|
return (
|
||||||
|
<div className={`rating-banner ${ColorUtils.classForRating(props.scene.rating)}`}>
|
||||||
|
RATING: {props.scene.rating}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderTagPopoverButton() {
|
||||||
|
if (props.scene.tags.length <= 0) { return; }
|
||||||
|
|
||||||
|
const tags = props.scene.tags.map((tag) => (
|
||||||
|
<Tag key={tag.id} className="tag-item">{tag.name}</Tag>
|
||||||
|
));
|
||||||
|
return (
|
||||||
|
<Popover interactionKind={"hover"} position="bottom">
|
||||||
|
<Button
|
||||||
|
icon="tag"
|
||||||
|
text={props.scene.tags.length}
|
||||||
|
/>
|
||||||
|
<>{tags}</>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderPerformerPopoverButton() {
|
||||||
|
if (props.scene.performers.length <= 0) { return; }
|
||||||
|
|
||||||
|
const performers = props.scene.performers.map((performer) => (
|
||||||
|
<Tag key={performer.id} className="tag-item">{performer.name}</Tag>
|
||||||
|
));
|
||||||
|
return (
|
||||||
|
<Popover interactionKind={"hover"} position="bottom">
|
||||||
|
<Button
|
||||||
|
icon="person"
|
||||||
|
text={props.scene.performers.length}
|
||||||
|
/>
|
||||||
|
<>{performers}</>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderSceneMarkerPopoverButton() {
|
||||||
|
if (props.scene.scene_markers.length <= 0) { return; }
|
||||||
|
|
||||||
|
const sceneMarkers = props.scene.scene_markers.map((marker) => (
|
||||||
|
<Tag key={marker.id} className="tag-item">{marker.title} - {TextUtils.secondsToTimestamp(marker.seconds)}</Tag>
|
||||||
|
));
|
||||||
|
return (
|
||||||
|
<Popover interactionKind={"hover"} position="bottom">
|
||||||
|
<Button
|
||||||
|
icon="map-marker"
|
||||||
|
text={props.scene.scene_markers.length}
|
||||||
|
/>
|
||||||
|
<>{sceneMarkers}</>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderPopoverButtonGroup() {
|
||||||
|
if (props.scene.tags.length > 0 ||
|
||||||
|
props.scene.performers.length > 0 ||
|
||||||
|
props.scene.scene_markers.length > 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<ButtonGroup minimal={true} className="card-section centered">
|
||||||
|
{maybeRenderTagPopoverButton()}
|
||||||
|
{maybeRenderPerformerPopoverButton()}
|
||||||
|
{maybeRenderSceneMarkerPopoverButton()}
|
||||||
|
</ButtonGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseEnter() {
|
||||||
|
if (!previewPath || previewPath === "") {
|
||||||
|
setPreviewPath(props.scene.paths.preview || "");
|
||||||
|
}
|
||||||
|
VideoHoverHook.onMouseEnter(videoHoverHook);
|
||||||
|
}
|
||||||
|
function onMouseLeave() {
|
||||||
|
VideoHoverHook.onMouseLeave(videoHoverHook);
|
||||||
|
setPreviewPath("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="grid-item"
|
||||||
|
elevation={Elevation.ONE}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
>
|
||||||
|
<Link to={`/scenes/${props.scene.id}`} className="image previewable">
|
||||||
|
{maybeRenderRatingBanner()}
|
||||||
|
<video className="preview" loop={true} poster={props.scene.paths.screenshot || ""} ref={videoHoverHook.videoEl}>
|
||||||
|
{!!previewPath ? <source src={previewPath} /> : ""}
|
||||||
|
</video>
|
||||||
|
</Link>
|
||||||
|
<div className="card-section">
|
||||||
|
<H4 style={{textOverflow: "ellipsis", overflow: "hidden"}}>
|
||||||
|
{!!props.scene.title ? props.scene.title : TextUtils.fileNameFromPath(props.scene.path)}
|
||||||
|
</H4>
|
||||||
|
<span className="bp3-text-small bp3-text-muted">{props.scene.date}</span>
|
||||||
|
<p>{TextUtils.truncate(props.scene.details, 100, "... (continued)")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{maybeRenderPopoverButtonGroup()}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<span className="card-section centered">
|
||||||
|
{props.scene.file.size !== undefined ? TextUtils.fileSize(parseInt(props.scene.file.size, 10)) : ""}
|
||||||
|
|
|
||||||
|
{props.scene.file.duration !== undefined ? TextUtils.secondsToTimestamp(props.scene.file.duration) : ""}
|
||||||
|
|
|
||||||
|
{props.scene.file.width} x {props.scene.file.height}
|
||||||
|
</span>
|
||||||
|
{SceneHelpers.maybeRenderStudio(props.scene, 50, true)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
91
ui/v2/src/components/scenes/SceneDetails/Scene.tsx
Normal file
91
ui/v2/src/components/scenes/SceneDetails/Scene.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Spinner,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import queryString from "query-string";
|
||||||
|
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||||
|
import * as GQL from "../../../core/generated-graphql";
|
||||||
|
import { StashService } from "../../../core/StashService";
|
||||||
|
import { IBaseProps } from "../../../models";
|
||||||
|
import { GalleryViewer } from "../../Galleries/GalleryViewer";
|
||||||
|
import { ScenePlayer } from "../ScenePlayer/ScenePlayer";
|
||||||
|
import { SceneDetailPanel } from "./SceneDetailPanel";
|
||||||
|
import { SceneEditPanel } from "./SceneEditPanel";
|
||||||
|
import { SceneFileInfoPanel } from "./SceneFileInfoPanel";
|
||||||
|
import { SceneMarkersPanel } from "./SceneMarkersPanel";
|
||||||
|
import { ScenePerformerPanel } from "./ScenePerformerPanel";
|
||||||
|
|
||||||
|
interface ISceneProps extends IBaseProps {}
|
||||||
|
|
||||||
|
export const Scene: FunctionComponent<ISceneProps> = (props: ISceneProps) => {
|
||||||
|
const [timestamp, setTimestamp] = useState<number>(0);
|
||||||
|
const [scene, setScene] = useState<Partial<GQL.SceneDataFragment>>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { data, error, loading } = StashService.useFindScene(props.match.params.id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(loading);
|
||||||
|
if (!data || !data.findScene || !!error) { return; }
|
||||||
|
setScene(StashService.nullToUndefined(data.findScene));
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const queryParams = queryString.parse(props.location.search);
|
||||||
|
if (!!queryParams.t && typeof queryParams.t === "string" && timestamp === 0) {
|
||||||
|
const newTimestamp = parseInt(queryParams.t, 10);
|
||||||
|
setTimestamp(newTimestamp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
|
||||||
|
setTimestamp(marker.seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || !data.findScene || isLoading || Object.keys(scene).length === 0) {
|
||||||
|
return <Spinner size={Spinner.SIZE_LARGE} />;
|
||||||
|
}
|
||||||
|
const modifiedScene =
|
||||||
|
Object.assign({scene_marker_tags: data.sceneMarkerTags}, scene) as GQL.SceneDataFragment; // TODO Hack from angular
|
||||||
|
if (!!error) { return <>error...</>; }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScenePlayer scene={modifiedScene} timestamp={timestamp} />
|
||||||
|
<Card id="details-container">
|
||||||
|
<Tabs
|
||||||
|
renderActiveTabPanelOnly={true}
|
||||||
|
large={true}
|
||||||
|
>
|
||||||
|
<Tab id="scene-details-panel" title="Details" panel={<SceneDetailPanel scene={modifiedScene} />} />
|
||||||
|
<Tab
|
||||||
|
id="scene-markers-panel"
|
||||||
|
title="Markers"
|
||||||
|
panel={<SceneMarkersPanel scene={modifiedScene} onClickMarker={onClickMarker} />}
|
||||||
|
/>
|
||||||
|
{modifiedScene.performers.length > 0 ?
|
||||||
|
<Tab
|
||||||
|
id="scene-performer-panel"
|
||||||
|
title="Performers"
|
||||||
|
panel={<ScenePerformerPanel scene={modifiedScene} />}
|
||||||
|
/> : undefined
|
||||||
|
}
|
||||||
|
{!!modifiedScene.gallery ?
|
||||||
|
<Tab
|
||||||
|
id="scene-gallery-panel"
|
||||||
|
title="Gallery"
|
||||||
|
panel={<GalleryViewer gallery={modifiedScene.gallery} />}
|
||||||
|
/> : undefined
|
||||||
|
}
|
||||||
|
<Tab id="scene-file-info-panel" title="File Info" panel={<SceneFileInfoPanel scene={modifiedScene} />} />
|
||||||
|
<Tab
|
||||||
|
id="scene-edit-panel"
|
||||||
|
title="Edit"
|
||||||
|
panel={<SceneEditPanel scene={modifiedScene} onUpdate={(newScene) => setScene(newScene)} />}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
H1,
|
||||||
|
H4,
|
||||||
|
H6,
|
||||||
|
Tag,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import * as GQL from "../../../core/generated-graphql";
|
||||||
|
import { TextUtils } from "../../../utils/text";
|
||||||
|
import { SceneHelpers } from "../helpers";
|
||||||
|
|
||||||
|
interface ISceneDetailProps {
|
||||||
|
scene: GQL.SceneDataFragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SceneDetailPanel: FunctionComponent<ISceneDetailProps> = (props: ISceneDetailProps) => {
|
||||||
|
function renderDetails() {
|
||||||
|
if (!props.scene.details || props.scene.details === "") { return; }
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<H6>Details</H6>
|
||||||
|
<p className="pre">{props.scene.details}</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTags() {
|
||||||
|
if (props.scene.tags.length === 0) { return; }
|
||||||
|
const tags = props.scene.tags.map((tag) => (
|
||||||
|
<Tag key={tag.id} className="tag-item">{tag.name}</Tag>
|
||||||
|
));
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<H6>Tags</H6>
|
||||||
|
{tags}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{SceneHelpers.maybeRenderStudio(props.scene, 70, false)}
|
||||||
|
<H1 className="bp3-heading">
|
||||||
|
{!!props.scene.title ? props.scene.title : TextUtils.fileNameFromPath(props.scene.path)}
|
||||||
|
</H1>
|
||||||
|
{!!props.scene.date ? <H4>{props.scene.date}</H4> : ""}
|
||||||
|
{renderDetails()}
|
||||||
|
{renderTags()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
176
ui/v2/src/components/scenes/SceneDetails/SceneEditPanel.tsx
Normal file
176
ui/v2/src/components/scenes/SceneDetails/SceneEditPanel.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormGroup,
|
||||||
|
HTMLSelect,
|
||||||
|
InputGroup,
|
||||||
|
Spinner,
|
||||||
|
TextArea,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||||
|
import * as GQL from "../../../core/generated-graphql";
|
||||||
|
import { StashService } from "../../../core/StashService";
|
||||||
|
import { ErrorUtils } from "../../../utils/errors";
|
||||||
|
import { ToastUtils } from "../../../utils/toasts";
|
||||||
|
import { FilterMultiSelect } from "../../select/FilterMultiSelect";
|
||||||
|
import { FilterSelect } from "../../select/FilterSelect";
|
||||||
|
import { ValidGalleriesSelect } from "../../select/ValidGalleriesSelect";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
scene: GQL.SceneDataFragment;
|
||||||
|
onUpdate: (scene: GQL.SceneDataFragment) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
// Editing scene state
|
||||||
|
const [title, setTitle] = useState<string | undefined>(undefined);
|
||||||
|
const [details, setDetails] = useState<string | undefined>(undefined);
|
||||||
|
const [url, setUrl] = useState<string | undefined>(undefined);
|
||||||
|
const [date, setDate] = useState<string | undefined>(undefined);
|
||||||
|
const [rating, setRating] = useState<number | undefined>(undefined);
|
||||||
|
const [galleryId, setGalleryId] = useState<string | undefined>(undefined);
|
||||||
|
const [studioId, setStudioId] = useState<string | undefined>(undefined);
|
||||||
|
const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined);
|
||||||
|
const [tagIds, setTagIds] = useState<string[] | undefined>(undefined);
|
||||||
|
|
||||||
|
// Network state
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const updateScene = StashService.useSceneUpdate(getSceneInput());
|
||||||
|
|
||||||
|
function updateSceneEditState(state: Partial<GQL.SceneDataFragment>) {
|
||||||
|
const perfIds = !!state.performers ? state.performers.map((performer) => performer.id) : undefined;
|
||||||
|
const tIds = !!state.tags ? state.tags.map((tag) => tag.id) : undefined;
|
||||||
|
|
||||||
|
setTitle(state.title);
|
||||||
|
setDetails(state.details);
|
||||||
|
setUrl(state.url);
|
||||||
|
setDate(state.date);
|
||||||
|
setRating(state.rating);
|
||||||
|
setGalleryId(state.gallery ? state.gallery.id : undefined);
|
||||||
|
setStudioId(state.studio ? state.studio.id : undefined);
|
||||||
|
setPerformerIds(perfIds);
|
||||||
|
setTagIds(tIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateSceneEditState(props.scene);
|
||||||
|
}, [props.scene]);
|
||||||
|
|
||||||
|
// if (!isNew && !isEditing) {
|
||||||
|
// if (!data || !data.findPerformer || isLoading) { return <Spinner size={Spinner.SIZE_LARGE} />; }
|
||||||
|
// if (!!error) { return <>error...</>; }
|
||||||
|
// }
|
||||||
|
|
||||||
|
function getSceneInput(): GQL.SceneUpdateInput {
|
||||||
|
return {
|
||||||
|
id: props.scene.id,
|
||||||
|
title,
|
||||||
|
details,
|
||||||
|
url,
|
||||||
|
date,
|
||||||
|
rating,
|
||||||
|
gallery_id: galleryId,
|
||||||
|
studio_id: studioId,
|
||||||
|
performer_ids: performerIds,
|
||||||
|
tag_ids: tagIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await updateScene();
|
||||||
|
props.onUpdate(result.data.sceneUpdate);
|
||||||
|
ToastUtils.success("Updated scene");
|
||||||
|
} catch (e) {
|
||||||
|
ErrorUtils.handle(e);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMultiSelect(type: "performers" | "tags", initialIds: string[] | undefined) {
|
||||||
|
return (
|
||||||
|
<FilterMultiSelect
|
||||||
|
type={type}
|
||||||
|
onUpdate={(items) => {
|
||||||
|
const ids = items.map((i) => i.id);
|
||||||
|
switch (type) {
|
||||||
|
case "performers": setPerformerIds(ids); break;
|
||||||
|
case "tags": setTagIds(ids); break;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
initialIds={initialIds}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isLoading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
||||||
|
<div className="form-container " style={{width: "50%"}}>
|
||||||
|
<FormGroup label="Title">
|
||||||
|
<InputGroup
|
||||||
|
onChange={(newValue: any) => setTitle(newValue.target.value)}
|
||||||
|
value={title}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Details">
|
||||||
|
<TextArea
|
||||||
|
fill={true}
|
||||||
|
onChange={(newValue) => setDetails(newValue.target.value)}
|
||||||
|
value={details}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="URL">
|
||||||
|
<InputGroup
|
||||||
|
onChange={(newValue: any) => setUrl(newValue.target.value)}
|
||||||
|
value={url}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Date" helperText="YYYY-MM-DD">
|
||||||
|
<InputGroup
|
||||||
|
onChange={(newValue: any) => setDate(newValue.target.value)}
|
||||||
|
value={date}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Rating">
|
||||||
|
<HTMLSelect
|
||||||
|
options={["", 1, 2, 3, 4, 5]}
|
||||||
|
onChange={(event) => setRating(parseInt(event.target.value, 10))}
|
||||||
|
value={rating}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Gallery">
|
||||||
|
<ValidGalleriesSelect
|
||||||
|
sceneId={props.scene.id}
|
||||||
|
initialId={galleryId}
|
||||||
|
onSelectItem={(item) => setGalleryId(item.id)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Studio">
|
||||||
|
<FilterSelect
|
||||||
|
type="studios"
|
||||||
|
onSelectItem={(item) => setStudioId(item.id)}
|
||||||
|
initialId={studioId}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Performers">
|
||||||
|
{renderMultiSelect("performers", performerIds)}
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Tags">
|
||||||
|
{renderMultiSelect("tags", tagIds)}
|
||||||
|
</FormGroup>
|
||||||
|
</div>
|
||||||
|
<Button text="Save" intent="primary" onClick={() => onSave()}/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
129
ui/v2/src/components/scenes/SceneDetails/SceneFileInfoPanel.tsx
Normal file
129
ui/v2/src/components/scenes/SceneDetails/SceneFileInfoPanel.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import {
|
||||||
|
HTMLTable,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import * as GQL from "../../../core/generated-graphql";
|
||||||
|
import { TextUtils } from "../../../utils/text";
|
||||||
|
|
||||||
|
interface ISceneFileInfoPanelProps {
|
||||||
|
scene: GQL.SceneDataFragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SceneFileInfoPanel: FunctionComponent<ISceneFileInfoPanelProps> = (props: ISceneFileInfoPanelProps) => {
|
||||||
|
function renderChecksum() {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>Checksum</td>
|
||||||
|
<td>{props.scene.checksum}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPath() {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>Path</td>
|
||||||
|
<td>{props.scene.path}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileSize() {
|
||||||
|
if (props.scene.file.size === undefined) { return; }
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>File Size</td>
|
||||||
|
<td>{TextUtils.fileSize(parseInt(props.scene.file.size, 10))}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDuration() {
|
||||||
|
if (props.scene.file.duration === undefined) { return; }
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>Duration</td>
|
||||||
|
<td>{TextUtils.secondsToTimestamp(props.scene.file.duration)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDimensions() {
|
||||||
|
if (props.scene.file.duration === undefined) { return; }
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>Dimensions</td>
|
||||||
|
<td>{props.scene.file.width} x {props.scene.file.height}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFrameRate() {
|
||||||
|
if (props.scene.file.framerate === undefined) { return; }
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>Frame Rate</td>
|
||||||
|
<td>{props.scene.file.framerate} frames per second</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBitRate() {
|
||||||
|
if (props.scene.file.bitrate === undefined) { return; }
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>Bit Rate</td>
|
||||||
|
<td>{TextUtils.bitRate(props.scene.file.bitrate)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVideoCodec() {
|
||||||
|
if (props.scene.file.video_codec === undefined) { return; }
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>Video Codec</td>
|
||||||
|
<td>{props.scene.file.video_codec}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAudioCodec() {
|
||||||
|
if (props.scene.file.audio_codec === undefined) { return; }
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>Audio Codec</td>
|
||||||
|
<td>{props.scene.file.audio_codec}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUrl() {
|
||||||
|
if (!props.scene.url || props.scene.url === "") { return; }
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>Downloaded From</td>
|
||||||
|
<td>{props.scene.url}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HTMLTable>
|
||||||
|
<tbody>
|
||||||
|
{renderChecksum()}
|
||||||
|
{renderPath()}
|
||||||
|
{renderFileSize()}
|
||||||
|
{renderDuration()}
|
||||||
|
{renderDimensions()}
|
||||||
|
{renderFrameRate()}
|
||||||
|
{renderBitRate()}
|
||||||
|
{renderVideoCodec()}
|
||||||
|
{renderAudioCodec()}
|
||||||
|
{renderUrl()}
|
||||||
|
</tbody>
|
||||||
|
</HTMLTable>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
272
ui/v2/src/components/scenes/SceneDetails/SceneMarkersPanel.tsx
Normal file
272
ui/v2/src/components/scenes/SceneDetails/SceneMarkersPanel.tsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Collapse,
|
||||||
|
Divider,
|
||||||
|
FormGroup,
|
||||||
|
H3,
|
||||||
|
NumericInput,
|
||||||
|
Tag,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import { Field, FieldProps, Form, Formik, FormikActions, FormikProps } from "formik";
|
||||||
|
import React, { CSSProperties, FunctionComponent, useState } from "react";
|
||||||
|
import * as GQL from "../../../core/generated-graphql";
|
||||||
|
import { StashService } from "../../../core/StashService";
|
||||||
|
import { TextUtils } from "../../../utils/text";
|
||||||
|
import { FilterMultiSelect } from "../../select/FilterMultiSelect";
|
||||||
|
import { FilterSelect } from "../../select/FilterSelect";
|
||||||
|
import { MarkerTitleSuggest } from "../../select/MarkerTitleSuggest";
|
||||||
|
import { WallPanel } from "../../Wall/WallPanel";
|
||||||
|
import { SceneHelpers } from "../helpers";
|
||||||
|
|
||||||
|
interface ISceneMarkersPanelProps {
|
||||||
|
scene: GQL.SceneDataFragment;
|
||||||
|
onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFormFields {
|
||||||
|
title: string;
|
||||||
|
seconds: string;
|
||||||
|
primaryTagId: string;
|
||||||
|
tagIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SceneMarkersPanel: FunctionComponent<ISceneMarkersPanelProps> = (props: ISceneMarkersPanelProps) => {
|
||||||
|
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
|
||||||
|
const [editingMarker, setEditingMarker] = useState<GQL.SceneMarkerDataFragment | null>(null);
|
||||||
|
|
||||||
|
const sceneMarkerCreate = StashService.useSceneMarkerCreate();
|
||||||
|
const sceneMarkerUpdate = StashService.useSceneMarkerUpdate();
|
||||||
|
const sceneMarkerDestroy = StashService.useSceneMarkerDestroy();
|
||||||
|
|
||||||
|
const jwplayer = SceneHelpers.getJWPlayer();
|
||||||
|
|
||||||
|
function onOpenEditor(marker: GQL.SceneMarkerDataFragment | null = null) {
|
||||||
|
setIsEditorOpen(true);
|
||||||
|
setEditingMarker(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
|
||||||
|
props.onClickMarker(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTags() {
|
||||||
|
function renderMarkers(primaryTag: GQL.FindSceneSceneMarkerTags) {
|
||||||
|
const markers = primaryTag.scene_markers.map((marker) => {
|
||||||
|
const markerTags = marker.tags.map((tag) => (
|
||||||
|
<Tag key={tag.id} className="tag-item">{tag.name}</Tag>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={marker.id}>
|
||||||
|
<Divider />
|
||||||
|
<div>
|
||||||
|
<a onClick={() => onClickMarker(marker)}>{marker.title}</a>
|
||||||
|
{!isEditorOpen ? <a style={{float: "right"}} onClick={() => onOpenEditor(marker)}>Edit</a> : undefined}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{TextUtils.secondsToTimestamp(marker.seconds)}
|
||||||
|
</div>
|
||||||
|
<div className="card-section centered">
|
||||||
|
{markerTags}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style: CSSProperties = {
|
||||||
|
height: "300px",
|
||||||
|
overflowY: "auto",
|
||||||
|
overflowX: "hidden",
|
||||||
|
display: "inline-block",
|
||||||
|
margin: "5px",
|
||||||
|
width: "300px",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
};
|
||||||
|
const tags = (props.scene as any).scene_marker_tags.map((primaryTag: GQL.FindSceneSceneMarkerTags) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={primaryTag.tag.id} style={{padding: "1px"}}>
|
||||||
|
<Card style={style}>
|
||||||
|
<div className="content" style={{whiteSpace: "normal"}}>
|
||||||
|
<H3>{primaryTag.tag.name}</H3>
|
||||||
|
{renderMarkers(primaryTag)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderForm() {
|
||||||
|
function onSubmit(values: IFormFields, _: FormikActions<IFormFields>) {
|
||||||
|
const isEditing = !!editingMarker;
|
||||||
|
const variables: GQL.SceneMarkerCreateVariables | GQL.SceneMarkerUpdateVariables = {
|
||||||
|
title: values.title,
|
||||||
|
seconds: parseFloat(values.seconds),
|
||||||
|
scene_id: props.scene.id,
|
||||||
|
primary_tag_id: values.primaryTagId,
|
||||||
|
tag_ids: values.tagIds,
|
||||||
|
};
|
||||||
|
if (!isEditing) {
|
||||||
|
sceneMarkerCreate({ variables }).then((response) => {
|
||||||
|
console.log(response);
|
||||||
|
}).catch((err) => console.error(err));
|
||||||
|
} else {
|
||||||
|
const updateVariables = variables as GQL.SceneMarkerUpdateVariables;
|
||||||
|
updateVariables.id = editingMarker!.id;
|
||||||
|
sceneMarkerUpdate({ variables: updateVariables }).then((response) => {
|
||||||
|
console.log(response);
|
||||||
|
}).catch((err) => console.error(err));
|
||||||
|
}
|
||||||
|
setIsEditorOpen(false);
|
||||||
|
setEditingMarker(null);
|
||||||
|
}
|
||||||
|
function onDelete() {
|
||||||
|
if (!editingMarker) { return; }
|
||||||
|
sceneMarkerDestroy({variables: {id: editingMarker.id}}).then((response) => {
|
||||||
|
console.log(response);
|
||||||
|
}).catch((err) => console.error(err));
|
||||||
|
setIsEditorOpen(false);
|
||||||
|
setEditingMarker(null);
|
||||||
|
}
|
||||||
|
function renderTitleField(fieldProps: FieldProps<IFormFields>) {
|
||||||
|
return (
|
||||||
|
<MarkerTitleSuggest
|
||||||
|
initialMarkerString={!!editingMarker ? editingMarker.title : undefined}
|
||||||
|
placeholder="Title"
|
||||||
|
name={fieldProps.field.name}
|
||||||
|
onBlur={fieldProps.field.onBlur}
|
||||||
|
value={fieldProps.field.value}
|
||||||
|
onQueryChange={(query) => fieldProps.form.setFieldValue("title", query)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function renderSecondsField(fieldProps: FieldProps<IFormFields>) {
|
||||||
|
return (
|
||||||
|
<NumericInput
|
||||||
|
placeholder="Seconds"
|
||||||
|
fill={true}
|
||||||
|
allowNumericCharactersOnly={true}
|
||||||
|
name={fieldProps.field.name}
|
||||||
|
onValueChange={(_, s) => fieldProps.form.setFieldValue("seconds", s)}
|
||||||
|
onBlur={fieldProps.field.onBlur}
|
||||||
|
value={fieldProps.field.value}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function renderPrimaryTagField(fieldProps: FieldProps<IFormFields>) {
|
||||||
|
return (
|
||||||
|
<FilterSelect
|
||||||
|
type="tags"
|
||||||
|
onSelectItem={(tag) => fieldProps.form.setFieldValue("primaryTagId", tag.id)}
|
||||||
|
initialId={!!editingMarker ? editingMarker.primary_tag.id : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function renderTagsField(fieldProps: FieldProps<IFormFields>) {
|
||||||
|
return (
|
||||||
|
<FilterMultiSelect
|
||||||
|
type="tags"
|
||||||
|
onUpdate={(tags) => fieldProps.form.setFieldValue("tagIds", tags.map((tag) => tag.id))}
|
||||||
|
initialIds={!!editingMarker ? editingMarker.tags.map((tag) => tag.id) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function renderFormFields(formikProps: FormikProps<IFormFields>) {
|
||||||
|
let deleteButton: JSX.Element | undefined;
|
||||||
|
if (!!editingMarker) {
|
||||||
|
deleteButton = (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
intent="danger"
|
||||||
|
style={{float: "right", marginRight: "10px"}}
|
||||||
|
onClick={() => onDelete()}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Form style={{marginTop: "10px"}}>
|
||||||
|
<div className="columns is-multiline is-gapless">
|
||||||
|
<FormGroup label="Scene Marker Title" labelFor="title" className="column is-full">
|
||||||
|
<Field name="title" render={renderTitleField} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup label="Seconds" labelFor="seconds" className="column is-half">
|
||||||
|
<Field name="seconds" render={renderSecondsField} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup label="Primary Tag" labelFor="primaryTagId" className="column is-half">
|
||||||
|
<Field name="primaryTagId" render={renderPrimaryTagField} />
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup label="Tags" labelFor="tagIds" className="column is-full">
|
||||||
|
<Field name="tagIds" render={renderTagsField} />
|
||||||
|
</FormGroup>
|
||||||
|
</div>
|
||||||
|
<div className="buttons-container">
|
||||||
|
<Button intent="primary" type="submit">Submit</Button>
|
||||||
|
<Button type="button" onClick={() => setIsEditorOpen(false)}>Cancel</Button>
|
||||||
|
{deleteButton}
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let initialValues: any;
|
||||||
|
if (!!editingMarker) {
|
||||||
|
initialValues = {
|
||||||
|
title: editingMarker.title,
|
||||||
|
seconds: editingMarker.seconds,
|
||||||
|
primaryTagId: editingMarker.primary_tag.id,
|
||||||
|
tagIds: editingMarker.tags.map((tag) => tag.id),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
initialValues = {title: "", seconds: Math.round(jwplayer.getPosition()), primaryTagId: "", tagIds: []};
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Collapse isOpen={isEditorOpen}>
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
render={renderFormFields}
|
||||||
|
/>
|
||||||
|
</Collapse>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const newMarkerForm = (
|
||||||
|
<div style={{margin: "5px"}}>
|
||||||
|
<Button onClick={() => onOpenEditor()}>Create</Button>
|
||||||
|
{renderForm()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (props.scene.scene_markers.length === 0) {
|
||||||
|
return newMarkerForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerStyle: CSSProperties = {
|
||||||
|
overflowY: "hidden",
|
||||||
|
overflowX: "scroll",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{newMarkerForm}
|
||||||
|
<div style={containerStyle}>
|
||||||
|
{renderTags()}
|
||||||
|
</div>
|
||||||
|
<WallPanel
|
||||||
|
sceneMarkers={props.scene.scene_markers}
|
||||||
|
clickHandler={(marker) => { window.scrollTo(0, 0); onClickMarker(marker as any); }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return render();
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import * as GQL from "../../../core/generated-graphql";
|
||||||
|
import { PerformerCard } from "../../performers/PerformerCard";
|
||||||
|
|
||||||
|
interface IScenePerformerPanelProps {
|
||||||
|
scene: GQL.SceneDataFragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScenePerformerPanel: FunctionComponent<IScenePerformerPanelProps> = (props: IScenePerformerPanelProps) => {
|
||||||
|
const cards = props.scene.performers.map((performer) => (
|
||||||
|
<PerformerCard key={performer.id} performer={performer} ageFromDate={props.scene.date} />
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid">
|
||||||
|
{cards}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
ui/v2/src/components/scenes/SceneList.tsx
Normal file
37
ui/v2/src/components/scenes/SceneList.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import { QueryHookResult } from "react-apollo-hooks";
|
||||||
|
import { FindScenesQuery, FindScenesVariables } from "../../core/generated-graphql";
|
||||||
|
import { ListHook } from "../../hooks/ListHook";
|
||||||
|
import { IBaseProps } from "../../models/base-props";
|
||||||
|
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||||
|
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
|
||||||
|
import { WallPanel } from "../Wall/WallPanel";
|
||||||
|
import { SceneCard } from "./SceneCard";
|
||||||
|
|
||||||
|
interface ISceneListProps extends IBaseProps {}
|
||||||
|
|
||||||
|
export const SceneList: FunctionComponent<ISceneListProps> = (props: ISceneListProps) => {
|
||||||
|
const listData = ListHook.useList({
|
||||||
|
filterMode: FilterMode.Scenes,
|
||||||
|
props,
|
||||||
|
renderContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderContent(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel) {
|
||||||
|
if (!result.data || !result.data.findScenes) { return; }
|
||||||
|
if (filter.displayMode === DisplayMode.Grid) {
|
||||||
|
return (
|
||||||
|
<div className="grid">
|
||||||
|
{result.data.findScenes.scenes.map((scene) => (<SceneCard key={scene.id} scene={scene} />))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (filter.displayMode === DisplayMode.List) {
|
||||||
|
return <h1>TODO</h1>;
|
||||||
|
} else if (filter.displayMode === DisplayMode.Wall) {
|
||||||
|
return <WallPanel scenes={result.data.findScenes.scenes} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listData.template;
|
||||||
|
};
|
||||||
31
ui/v2/src/components/scenes/SceneMarkerList.tsx
Normal file
31
ui/v2/src/components/scenes/SceneMarkerList.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import _ from "lodash";
|
||||||
|
import React, { FunctionComponent } from "react";
|
||||||
|
import { QueryHookResult } from "react-apollo-hooks";
|
||||||
|
import { FindSceneMarkersQuery, FindSceneMarkersVariables } from "../../core/generated-graphql";
|
||||||
|
import { ListHook } from "../../hooks/ListHook";
|
||||||
|
import { IBaseProps } from "../../models/base-props";
|
||||||
|
import { ListFilterModel } from "../../models/list-filter/filter";
|
||||||
|
import { DisplayMode, FilterMode } from "../../models/list-filter/types";
|
||||||
|
import { WallPanel } from "../Wall/WallPanel";
|
||||||
|
|
||||||
|
interface IProps extends IBaseProps {}
|
||||||
|
|
||||||
|
export const SceneMarkerList: FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
const listData = ListHook.useList({
|
||||||
|
filterMode: FilterMode.SceneMarkers,
|
||||||
|
props,
|
||||||
|
renderContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderContent(
|
||||||
|
result: QueryHookResult<FindSceneMarkersQuery, FindSceneMarkersVariables>,
|
||||||
|
filter: ListFilterModel,
|
||||||
|
) {
|
||||||
|
if (!result.data || !result.data.findSceneMarkers) { return; }
|
||||||
|
if (filter.displayMode === DisplayMode.Wall) {
|
||||||
|
return <WallPanel sceneMarkers={result.data.findSceneMarkers.scene_markers} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listData.template;
|
||||||
|
};
|
||||||
152
ui/v2/src/components/scenes/ScenePlayer/ScenePlayer.tsx
Normal file
152
ui/v2/src/components/scenes/ScenePlayer/ScenePlayer.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { Hotkey, Hotkeys, HotkeysTarget } from "@blueprintjs/core";
|
||||||
|
import React from "react";
|
||||||
|
import ReactJWPlayer from "react-jw-player";
|
||||||
|
import * as GQL from "../../../core/generated-graphql";
|
||||||
|
import { SceneHelpers } from "../helpers";
|
||||||
|
import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
|
||||||
|
|
||||||
|
interface IScenePlayerProps {
|
||||||
|
scene: GQL.SceneDataFragment;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
interface IScenePlayerState {
|
||||||
|
scrubberPosition: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@HotkeysTarget
|
||||||
|
export class ScenePlayer extends React.Component<IScenePlayerProps, IScenePlayerState> {
|
||||||
|
private player: any;
|
||||||
|
private lastTime = 0;
|
||||||
|
|
||||||
|
constructor(props: IScenePlayerProps) {
|
||||||
|
super(props);
|
||||||
|
this.onReady = this.onReady.bind(this);
|
||||||
|
this.onSeeked = this.onSeeked.bind(this);
|
||||||
|
this.onTime = this.onTime.bind(this);
|
||||||
|
|
||||||
|
this.onScrubberSeek = this.onScrubberSeek.bind(this);
|
||||||
|
this.onScrubberScrolled = this.onScrubberScrolled.bind(this);
|
||||||
|
|
||||||
|
this.state = {scrubberPosition: 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidUpdate(prevProps: IScenePlayerProps) {
|
||||||
|
if (prevProps.timestamp !== this.props.timestamp) {
|
||||||
|
this.player.seek(this.props.timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
const config = this.makeConfig(this.props.scene);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div id="jwplayer-container">
|
||||||
|
<ReactJWPlayer
|
||||||
|
playerId={SceneHelpers.getJWPlayerId()}
|
||||||
|
playerScript="https://content.jwplatform.com/libraries/QRX6Y71b.js"
|
||||||
|
customProps={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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderHotkeys() {
|
||||||
|
const onIncrease = () => {
|
||||||
|
const currentPlaybackRate = !!this.player ? this.player.getPlaybackRate() : 1;
|
||||||
|
this.player.setPlaybackRate(currentPlaybackRate + 0.5);
|
||||||
|
};
|
||||||
|
const onDecrease = () => {
|
||||||
|
const currentPlaybackRate = !!this.player ? this.player.getPlaybackRate() : 1;
|
||||||
|
this.player.setPlaybackRate(currentPlaybackRate - 0.5);
|
||||||
|
};
|
||||||
|
const onReset = () => { this.player.setPlaybackRate(1); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Hotkeys>
|
||||||
|
<Hotkey
|
||||||
|
global={true}
|
||||||
|
combo="num2"
|
||||||
|
label="Increase playback speed"
|
||||||
|
preventDefault={true}
|
||||||
|
onKeyDown={onIncrease}
|
||||||
|
/>
|
||||||
|
<Hotkey
|
||||||
|
global={true}
|
||||||
|
combo="num1"
|
||||||
|
label="Decrease playback speed"
|
||||||
|
preventDefault={true}
|
||||||
|
onKeyDown={onDecrease}
|
||||||
|
/>
|
||||||
|
<Hotkey
|
||||||
|
global={true}
|
||||||
|
combo="num0"
|
||||||
|
label="Reset playback speed"
|
||||||
|
preventDefault={true}
|
||||||
|
onKeyDown={onReset}
|
||||||
|
/>
|
||||||
|
</Hotkeys>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeConfig(scene: GQL.SceneDataFragment) {
|
||||||
|
if (!scene.paths.stream) { return {}; }
|
||||||
|
return {
|
||||||
|
file: scene.paths.stream,
|
||||||
|
image: scene.paths.screenshot,
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
file: scene.paths.vtt,
|
||||||
|
kind: "thumbnails",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: scene.paths.chapters_vtt,
|
||||||
|
kind: "chapters",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
primary: "html5",
|
||||||
|
autostart: false,
|
||||||
|
playbackRateControls: true,
|
||||||
|
playbackRates: [0.75, 1, 1.5, 2, 3, 4],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private onReady() {
|
||||||
|
this.player = SceneHelpers.getJWPlayer();
|
||||||
|
if (this.props.timestamp > 0) {
|
||||||
|
this.player.seek(this.props.timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSeeked() {
|
||||||
|
const position = this.player.getPosition();
|
||||||
|
this.setState({scrubberPosition: position});
|
||||||
|
this.player.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onTime(data: any) {
|
||||||
|
const position = this.player.getPosition();
|
||||||
|
const difference = Math.abs(position - this.lastTime);
|
||||||
|
if (difference > 1) {
|
||||||
|
this.lastTime = position;
|
||||||
|
this.setState({scrubberPosition: position});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onScrubberSeek(seconds: number) {
|
||||||
|
this.player.seek(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onScrubberScrolled() {
|
||||||
|
this.player.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
128
ui/v2/src/components/scenes/ScenePlayer/ScenePlayerScrubber.scss
Normal file
128
ui/v2/src/components/scenes/ScenePlayer/ScenePlayerScrubber.scss
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
.scrubber-wrapper {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scrubber-back {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scrubber-forward {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrubber-button {
|
||||||
|
width: 1.5%;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 120px;
|
||||||
|
padding: 0;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #555;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #FFF;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrubber-content {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
height: 120px;
|
||||||
|
width: 96%;
|
||||||
|
margin: 0 0.5%;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrubber-content.dragging {
|
||||||
|
cursor: -webkit-grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrubber-tags-background {
|
||||||
|
background-color: #555;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scrubber-position-indicator {
|
||||||
|
background-color: #CCC;
|
||||||
|
width: 100%;
|
||||||
|
left: -100%;
|
||||||
|
height: 20px;
|
||||||
|
z-index: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scrubber-current-position {
|
||||||
|
background-color: #FFF;
|
||||||
|
width: 2px;
|
||||||
|
height: 30px;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 100;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrubber-viewport {
|
||||||
|
position: static;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrubber-slider {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
left: 0;
|
||||||
|
transition: 333ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrubber-tags {
|
||||||
|
height: 20px;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrubber-tag {
|
||||||
|
position: absolute;
|
||||||
|
background-color: #000;
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.scrubber-tag:hover {
|
||||||
|
z-index: 1;
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
.scrubber-tag:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -5px;
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -5px;
|
||||||
|
border-top: solid 5px #000;
|
||||||
|
border-left: solid 5px transparent;
|
||||||
|
border-right: solid 5px transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrubber-item {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
margin-right: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 1px 1px black;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrubber-item span {
|
||||||
|
display: inline-block;
|
||||||
|
align-self: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
316
ui/v2/src/components/scenes/ScenePlayer/ScenePlayerScrubber.tsx
Normal file
316
ui/v2/src/components/scenes/ScenePlayer/ScenePlayerScrubber.tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import React, { CSSProperties, FunctionComponent, RefObject, useEffect, useRef, useState } from "react";
|
||||||
|
import * as GQL from "../../../core/generated-graphql";
|
||||||
|
import { TextUtils } from "../../../utils/text";
|
||||||
|
import "./ScenePlayerScrubber.scss";
|
||||||
|
|
||||||
|
interface IScenePlayerScrubberProps {
|
||||||
|
scene: GQL.SceneDataFragment;
|
||||||
|
position: number;
|
||||||
|
onSeek: (seconds: number) => void;
|
||||||
|
onScrolled: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISceneSpriteItem {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScenePlayerScrubber: FunctionComponent<IScenePlayerScrubberProps> = (props: IScenePlayerScrubberProps) => {
|
||||||
|
const contentEl = useRef<HTMLDivElement>(null);
|
||||||
|
const positionIndicatorEl = useRef<HTMLDivElement>(null);
|
||||||
|
const scrubberSliderEl = useRef<HTMLDivElement>(null);
|
||||||
|
const mouseDown = useRef(false);
|
||||||
|
const lastMouseEvent = useRef<any>(null);
|
||||||
|
const startMouseEvent = useRef<any>(null);
|
||||||
|
const velocity = useRef(0);
|
||||||
|
|
||||||
|
const _position = useRef(0);
|
||||||
|
function getPostion() { return _position.current; }
|
||||||
|
function setPosition(newPostion: number, shouldEmit: boolean = true) {
|
||||||
|
if (!scrubberSliderEl.current || !positionIndicatorEl.current) { return; }
|
||||||
|
if (shouldEmit) { props.onScrolled(); }
|
||||||
|
|
||||||
|
const midpointOffset = scrubberSliderEl.current.clientWidth / 2;
|
||||||
|
|
||||||
|
const bounds = getBounds() * -1;
|
||||||
|
if (newPostion > midpointOffset) {
|
||||||
|
_position.current = midpointOffset;
|
||||||
|
} else if (newPostion < bounds - midpointOffset) {
|
||||||
|
_position.current = bounds - midpointOffset;
|
||||||
|
} else {
|
||||||
|
_position.current = newPostion;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrubberSliderEl.current.style.transform = `translateX(${_position.current}px)`;
|
||||||
|
|
||||||
|
const indicatorPosition = (
|
||||||
|
(newPostion - midpointOffset) / (bounds - (midpointOffset * 2)) * scrubberSliderEl.current.clientWidth
|
||||||
|
);
|
||||||
|
positionIndicatorEl.current.style.transform = `translateX(${indicatorPosition}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [spriteItems, setSpriteItems] = useState<ISceneSpriteItem[]>([]);
|
||||||
|
const [delayedRender, setDelayedRender] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrubberSliderEl.current) { return; }
|
||||||
|
scrubberSliderEl.current.style.transform = `translateX(${scrubberSliderEl.current.clientWidth / 2}px)`;
|
||||||
|
}, [scrubberSliderEl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSpriteInfo();
|
||||||
|
}, [props.scene]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrubberSliderEl.current) { return; }
|
||||||
|
const duration = Number(props.scene.file.duration);
|
||||||
|
const percentage = props.position / duration;
|
||||||
|
const position = (
|
||||||
|
(scrubberSliderEl.current.scrollWidth * percentage) - (scrubberSliderEl.current.clientWidth / 2)
|
||||||
|
) * -1;
|
||||||
|
setPosition(position, false);
|
||||||
|
}, [props.position]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("mouseup", onMouseUp, false);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contentEl.current) { return; }
|
||||||
|
contentEl.current.addEventListener("mousedown", onMouseDown, false);
|
||||||
|
return () => {
|
||||||
|
if (!contentEl.current) { return; }
|
||||||
|
contentEl.current.removeEventListener("mousedown", onMouseDown);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contentEl.current) { return; }
|
||||||
|
contentEl.current.addEventListener("mousemove", onMouseMove, false);
|
||||||
|
return () => {
|
||||||
|
if (!contentEl.current) { return; }
|
||||||
|
contentEl.current.removeEventListener("mousemove", onMouseMove);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function onMouseUp(this: Window, event: MouseEvent) {
|
||||||
|
if (!startMouseEvent.current || !scrubberSliderEl.current) { return; }
|
||||||
|
mouseDown.current = false;
|
||||||
|
const delta = Math.abs(event.clientX - startMouseEvent.current.clientX);
|
||||||
|
if (delta < 1 && event.target instanceof HTMLDivElement) {
|
||||||
|
const target: HTMLDivElement = event.target;
|
||||||
|
let seekSeconds: number | undefined;
|
||||||
|
|
||||||
|
const spriteIdString = target.getAttribute("data-sprite-item-id");
|
||||||
|
if (spriteIdString != null) {
|
||||||
|
const spritePercentage = event.offsetX / target.clientWidth;
|
||||||
|
const offset = target.offsetLeft + (target.clientWidth * spritePercentage);
|
||||||
|
const percentage = offset / scrubberSliderEl.current.scrollWidth;
|
||||||
|
seekSeconds = percentage * (props.scene.file.duration || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markerIdString = target.getAttribute("data-marker-id");
|
||||||
|
if (markerIdString != null) {
|
||||||
|
const marker = props.scene.scene_markers[Number(markerIdString)];
|
||||||
|
seekSeconds = marker.seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!!seekSeconds) { props.onSeek(seekSeconds); }
|
||||||
|
} else if (Math.abs(velocity.current) > 25) {
|
||||||
|
const newPosition = getPostion() + (velocity.current * 10);
|
||||||
|
setPosition(newPosition);
|
||||||
|
velocity.current = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseDown(this: HTMLDivElement, event: MouseEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
mouseDown.current = true;
|
||||||
|
lastMouseEvent.current = event;
|
||||||
|
startMouseEvent.current = event;
|
||||||
|
velocity.current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(this: HTMLDivElement, event: MouseEvent) {
|
||||||
|
if (!mouseDown.current) { return; }
|
||||||
|
|
||||||
|
// negative dragging right (past), positive left (future)
|
||||||
|
const delta = event.clientX - lastMouseEvent.current.clientX;
|
||||||
|
|
||||||
|
const movement = event.movementX;
|
||||||
|
velocity.current = movement;
|
||||||
|
|
||||||
|
const newPostion = getPostion() + delta;
|
||||||
|
setPosition(newPostion);
|
||||||
|
lastMouseEvent.current = event;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBounds(): number {
|
||||||
|
if (!scrubberSliderEl.current || !positionIndicatorEl.current) { return 0; }
|
||||||
|
return scrubberSliderEl.current.scrollWidth - scrubberSliderEl.current.clientWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (!scrubberSliderEl.current) { return; }
|
||||||
|
const newPosition = getPostion() + scrubberSliderEl.current.clientWidth;
|
||||||
|
setPosition(newPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goForward() {
|
||||||
|
if (!scrubberSliderEl.current) { return; }
|
||||||
|
const newPosition = getPostion() - scrubberSliderEl.current.clientWidth;
|
||||||
|
setPosition(newPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSpriteInfo() {
|
||||||
|
if (!props.scene || !props.scene.paths.vtt) { return; }
|
||||||
|
|
||||||
|
const response = await axios.get<string>(props.scene.paths.vtt, {responseType: "text"});
|
||||||
|
if (response.status !== 200) {
|
||||||
|
console.log(response.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: This is gnarly
|
||||||
|
const lines = response.data.split("\n");
|
||||||
|
if (lines.shift() !== "WEBVTT") { return; }
|
||||||
|
if (lines.shift() !== "") { return; }
|
||||||
|
let item: ISceneSpriteItem = {start: 0, end: 0, x: 0, y: 0, w: 0, h: 0};
|
||||||
|
const newSpriteItems: ISceneSpriteItem[] = [];
|
||||||
|
while (lines.length) {
|
||||||
|
const line = lines.shift();
|
||||||
|
if (line === undefined) { continue; }
|
||||||
|
|
||||||
|
if (line.includes("#") && line.includes("=") && line.includes(",")) {
|
||||||
|
const size = line.split("#")[1].split("=")[1].split(",");
|
||||||
|
item.x = Number(size[0]);
|
||||||
|
item.y = Number(size[1]);
|
||||||
|
item.w = Number(size[2]);
|
||||||
|
item.h = Number(size[3]);
|
||||||
|
|
||||||
|
newSpriteItems.push(item);
|
||||||
|
item = {start: 0, end: 0, x: 0, y: 0, w: 0, h: 0};
|
||||||
|
} else if (line.includes(" --> ")) {
|
||||||
|
const times = line.split(" --> ");
|
||||||
|
|
||||||
|
const start = times[0].split(":");
|
||||||
|
item.start = (+start[0]) * 60 * 60 + (+start[1]) * 60 + (+start[2]);
|
||||||
|
|
||||||
|
const end = times[1].split(":");
|
||||||
|
item.end = (+end[0]) * 60 * 60 + (+end[1]) * 60 + (+end[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpriteItems(newSpriteItems);
|
||||||
|
// TODO: Very hacky. Need to wait for the scroll width to update from the image loading.
|
||||||
|
setTimeout(() => {
|
||||||
|
setDelayedRender(true);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTags() {
|
||||||
|
function getTagStyle(i: number): CSSProperties {
|
||||||
|
if (!scrubberSliderEl.current ||
|
||||||
|
spriteItems.length === 0 ||
|
||||||
|
getBounds() === 0) { return {}; }
|
||||||
|
|
||||||
|
const tags = window.document.getElementsByClassName("scrubber-tag");
|
||||||
|
if (tags.length === 0) { return {}; }
|
||||||
|
|
||||||
|
let tag: any;
|
||||||
|
for (let index = 0; index < tags.length; index++) {
|
||||||
|
tag = tags.item(index) as any;
|
||||||
|
const id = tag.getAttribute("data-marker-id");
|
||||||
|
if (id === i.toString()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const marker = props.scene.scene_markers[i];
|
||||||
|
const duration = Number(props.scene.file.duration);
|
||||||
|
const percentage = marker.seconds / duration;
|
||||||
|
|
||||||
|
const left = (scrubberSliderEl.current.scrollWidth * percentage) - (tag.clientWidth / 2);
|
||||||
|
return {
|
||||||
|
left: `${left}px`,
|
||||||
|
height: 20,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.scene.scene_markers.map((marker, index) => {
|
||||||
|
const dataAttrs = {
|
||||||
|
"data-marker-id": index,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="scrubber-tag"
|
||||||
|
style={getTagStyle(index)}
|
||||||
|
{...dataAttrs}
|
||||||
|
>
|
||||||
|
{marker.title}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSprites() {
|
||||||
|
function getStyleForSprite(index: number): CSSProperties {
|
||||||
|
if (!props.scene.paths.vtt) { return {}; }
|
||||||
|
const sprite = spriteItems[index];
|
||||||
|
const left = sprite.w * index;
|
||||||
|
const path = props.scene.paths.vtt.replace("_thumbs.vtt", "_sprite.jpg"); // TODO: Gnarly
|
||||||
|
return {
|
||||||
|
width: `${sprite.w}px`,
|
||||||
|
height: `${sprite.h}px`,
|
||||||
|
margin: "0px auto",
|
||||||
|
backgroundPosition: -sprite.x + "px " + -sprite.y + "px",
|
||||||
|
backgroundImage: `url(${path})`,
|
||||||
|
left: `${left}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return spriteItems.map((spriteItem, index) => {
|
||||||
|
const dataAttrs = {
|
||||||
|
"data-sprite-item-id": index,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="scrubber-item"
|
||||||
|
style={getStyleForSprite(index)}
|
||||||
|
{...dataAttrs}
|
||||||
|
>
|
||||||
|
<span>{TextUtils.secondsToTimestamp(spriteItem.start)} - {TextUtils.secondsToTimestamp(spriteItem.end)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scrubber-wrapper">
|
||||||
|
<a className="scrubber-button" id="scrubber-back" onClick={() => goBack()}><</a>
|
||||||
|
<div ref={contentEl} className="scrubber-content">
|
||||||
|
<div className="scrubber-tags-background" />
|
||||||
|
<div ref={positionIndicatorEl} id="scrubber-position-indicator" />
|
||||||
|
<div id="scrubber-current-position" />
|
||||||
|
<div className="scrubber-viewport">
|
||||||
|
<div ref={scrubberSliderEl} className="scrubber-slider">
|
||||||
|
<div className="scrubber-tags">
|
||||||
|
{renderTags()}
|
||||||
|
</div>
|
||||||
|
{renderSprites()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a className="scrubber-button" id="scrubber-forward" onClick={() => goForward()}>></a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
40
ui/v2/src/components/scenes/helpers.tsx
Normal file
40
ui/v2/src/components/scenes/helpers.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
Divider,
|
||||||
|
} from "@blueprintjs/core";
|
||||||
|
import React, { } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
|
||||||
|
export class SceneHelpers {
|
||||||
|
public static maybeRenderStudio(
|
||||||
|
scene: GQL.SceneDataFragment | GQL.SlimSceneDataFragment,
|
||||||
|
height: number,
|
||||||
|
showDivider: boolean,
|
||||||
|
) {
|
||||||
|
if (!scene.studio) { return; }
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
backgroundImage: `url('${scene.studio.image_path}')`,
|
||||||
|
width: "100%",
|
||||||
|
height: `${height}px`,
|
||||||
|
lineHeight: 5,
|
||||||
|
backgroundSize: "contain",
|
||||||
|
display: "inline-block",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showDivider ? <Divider /> : undefined}
|
||||||
|
<Link
|
||||||
|
to={`/studios/${scene.studio.id}`}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getJWPlayerId(): string { return "main-jwplayer"; }
|
||||||
|
public static getJWPlayer(): any {
|
||||||
|
return (window as any).jwplayer("main-jwplayer");
|
||||||
|
}
|
||||||
|
}
|
||||||
15
ui/v2/src/components/scenes/scenes.tsx
Normal file
15
ui/v2/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={true} path="/scenes" component={SceneList} />
|
||||||
|
<Route exact={true} path="/scenes/markers" component={SceneMarkerList} />
|
||||||
|
<Route path="/scenes/:id" component={Scene} />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Scenes;
|
||||||
108
ui/v2/src/components/select/FilterMultiSelect.tsx
Normal file
108
ui/v2/src/components/select/FilterMultiSelect.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { MenuItem } from "@blueprintjs/core";
|
||||||
|
import { IMultiSelectProps, ItemPredicate, ItemRenderer, MultiSelect } from "@blueprintjs/select";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { StashService } from "../../core/StashService";
|
||||||
|
import { HTMLInputProps } from "../../models";
|
||||||
|
|
||||||
|
const InternalPerformerMultiSelect = MultiSelect.ofType<GQL.AllPerformersForFilterAllPerformers>();
|
||||||
|
const InternalTagMultiSelect = MultiSelect.ofType<GQL.AllTagsForFilterAllTags>();
|
||||||
|
const InternalStudioMultiSelect = MultiSelect.ofType<GQL.AllStudiosForFilterAllStudios>();
|
||||||
|
|
||||||
|
type ValidTypes =
|
||||||
|
GQL.AllPerformersForFilterAllPerformers |
|
||||||
|
GQL.AllTagsForFilterAllTags |
|
||||||
|
GQL.AllStudiosForFilterAllStudios;
|
||||||
|
|
||||||
|
interface IProps extends HTMLInputProps, Partial<IMultiSelectProps<ValidTypes>> {
|
||||||
|
type: "performers" | "studios" | "tags";
|
||||||
|
initialIds?: string[];
|
||||||
|
onUpdate: (items: ValidTypes[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilterMultiSelect: React.FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
let items: ValidTypes[];
|
||||||
|
let InternalMultiSelect: new (props: IMultiSelectProps<any>) => MultiSelect<any>;
|
||||||
|
switch (props.type) {
|
||||||
|
case "performers": {
|
||||||
|
const { data } = StashService.useAllPerformersForFilter();
|
||||||
|
items = !!data && !!data.allPerformers ? data.allPerformers : [];
|
||||||
|
InternalMultiSelect = InternalPerformerMultiSelect;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "studios": {
|
||||||
|
const { data } = StashService.useAllStudiosForFilter();
|
||||||
|
items = !!data && !!data.allStudios ? data.allStudios : [];
|
||||||
|
InternalMultiSelect = InternalStudioMultiSelect;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "tags": {
|
||||||
|
const { data } = StashService.useAllTagsForFilter();
|
||||||
|
items = !!data && !!data.allTags ? data.allTags : [];
|
||||||
|
InternalMultiSelect = InternalTagMultiSelect;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
console.error("Unhandled case in FilterMultiSelect");
|
||||||
|
return <>Unhandled case in FilterMultiSelect</>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [selectedItems, setSelectedItems] = React.useState<ValidTypes[]>([]);
|
||||||
|
const [isInitialized, setIsInitialized] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
if (!!props.initialIds && selectedItems.length === 0 && !isInitialized) {
|
||||||
|
const initialItems = items.filter((item) => props.initialIds!.includes(item.id));
|
||||||
|
if (initialItems.length > 0) {
|
||||||
|
setSelectedItems(initialItems);
|
||||||
|
setIsInitialized(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItem: ItemRenderer<ValidTypes> = (item, itemProps) => {
|
||||||
|
if (!itemProps.modifiers.matchesPredicate) { return null; }
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
active={itemProps.modifiers.active}
|
||||||
|
disabled={itemProps.modifiers.disabled}
|
||||||
|
key={item.id}
|
||||||
|
onClick={itemProps.handleClick}
|
||||||
|
text={item.name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filter: ItemPredicate<ValidTypes> = (query, item) => {
|
||||||
|
if (selectedItems.includes(item)) { return false; }
|
||||||
|
return item.name!.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
function onItemSelect(item: ValidTypes) {
|
||||||
|
selectedItems.push(item);
|
||||||
|
setSelectedItems(selectedItems);
|
||||||
|
props.onUpdate(selectedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onItemRemove(value: string, index: number) {
|
||||||
|
const newSelectedItems = selectedItems.filter((_, i) => i !== index);
|
||||||
|
setSelectedItems(newSelectedItems);
|
||||||
|
props.onUpdate(newSelectedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InternalMultiSelect
|
||||||
|
items={items}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
itemRenderer={renderItem}
|
||||||
|
itemPredicate={filter}
|
||||||
|
tagRenderer={(tag) => tag.name}
|
||||||
|
tagInputProps={{ onRemove: onItemRemove }}
|
||||||
|
onItemSelect={onItemSelect}
|
||||||
|
resetOnSelect={true}
|
||||||
|
activeItem={null}
|
||||||
|
popoverProps={{position: "bottom"}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
100
ui/v2/src/components/select/FilterSelect.tsx
Normal file
100
ui/v2/src/components/select/FilterSelect.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { Button, MenuItem } from "@blueprintjs/core";
|
||||||
|
import { ISelectProps, ItemPredicate, ItemRenderer, Select } from "@blueprintjs/select";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { StashService } from "../../core/StashService";
|
||||||
|
import { HTMLInputProps } from "../../models";
|
||||||
|
|
||||||
|
const InternalPerformerSelect = Select.ofType<GQL.AllPerformersForFilterAllPerformers>();
|
||||||
|
const InternalTagSelect = Select.ofType<GQL.AllTagsForFilterAllTags>();
|
||||||
|
const InternalStudioSelect = Select.ofType<GQL.AllStudiosForFilterAllStudios>();
|
||||||
|
|
||||||
|
type ValidTypes =
|
||||||
|
GQL.AllPerformersForFilterAllPerformers |
|
||||||
|
GQL.AllTagsForFilterAllTags |
|
||||||
|
GQL.AllStudiosForFilterAllStudios;
|
||||||
|
|
||||||
|
interface IProps extends HTMLInputProps {
|
||||||
|
type: "performers" | "studios" | "tags";
|
||||||
|
initialId?: string;
|
||||||
|
onSelectItem: (item: ValidTypes) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilterSelect: React.FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
let items: ValidTypes[];
|
||||||
|
let InternalSelect: new (props: ISelectProps<any>) => Select<any>;
|
||||||
|
switch (props.type) {
|
||||||
|
case "performers": {
|
||||||
|
const { data } = StashService.useAllPerformersForFilter();
|
||||||
|
items = !!data && !!data.allPerformers ? data.allPerformers : [];
|
||||||
|
InternalSelect = InternalPerformerSelect;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "studios": {
|
||||||
|
const { data } = StashService.useAllStudiosForFilter();
|
||||||
|
items = !!data && !!data.allStudios ? data.allStudios : [];
|
||||||
|
InternalSelect = InternalStudioSelect;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "tags": {
|
||||||
|
const { data } = StashService.useAllTagsForFilter();
|
||||||
|
items = !!data && !!data.allTags ? data.allTags : [];
|
||||||
|
InternalSelect = InternalTagSelect;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
console.error("Unhandled case in FilterSelect");
|
||||||
|
return <>Unhandled case in FilterSelect</>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [selectedItem, setSelectedItem] = React.useState<ValidTypes | null>(null);
|
||||||
|
const [isInitialized, setIsInitialized] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
if (!!props.initialId && !selectedItem && !isInitialized) {
|
||||||
|
const initialItem = items.find((item) => props.initialId === item.id);
|
||||||
|
if (!!initialItem) {
|
||||||
|
setSelectedItem(initialItem);
|
||||||
|
setIsInitialized(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItem: ItemRenderer<ValidTypes> = (item, itemProps) => {
|
||||||
|
if (!itemProps.modifiers.matchesPredicate) { return null; }
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
active={itemProps.modifiers.active}
|
||||||
|
disabled={itemProps.modifiers.disabled}
|
||||||
|
key={item.id}
|
||||||
|
onClick={itemProps.handleClick}
|
||||||
|
text={item.name}
|
||||||
|
shouldDismissPopover={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filter: ItemPredicate<ValidTypes> = (query, item) => {
|
||||||
|
return item.name!.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
function onItemSelect(item: ValidTypes) {
|
||||||
|
props.onSelectItem(item);
|
||||||
|
setSelectedItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonText = selectedItem ? selectedItem.name : "(No selection)";
|
||||||
|
return (
|
||||||
|
<InternalSelect
|
||||||
|
items={items}
|
||||||
|
itemRenderer={renderItem}
|
||||||
|
itemPredicate={filter}
|
||||||
|
noResults={<MenuItem disabled={true} text="No results." />}
|
||||||
|
onItemSelect={onItemSelect}
|
||||||
|
popoverProps={{position: "bottom"}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Button fill={true} text={buttonText} />
|
||||||
|
</InternalSelect>
|
||||||
|
);
|
||||||
|
};
|
||||||
47
ui/v2/src/components/select/FreeOnesPerformerSuggest.tsx
Normal file
47
ui/v2/src/components/select/FreeOnesPerformerSuggest.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { MenuItem } from "@blueprintjs/core";
|
||||||
|
import { ItemPredicate, ItemRenderer, Suggest } from "@blueprintjs/select";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { StashService } from "../../core/StashService";
|
||||||
|
import { HTMLInputProps } from "../../models";
|
||||||
|
|
||||||
|
const InternalSuggest = Suggest.ofType<string>();
|
||||||
|
|
||||||
|
interface IProps extends HTMLInputProps {
|
||||||
|
onQueryChange: (query: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FreeOnesPerformerSuggest: React.FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
const [query, setQuery] = React.useState<string>("");
|
||||||
|
const { data } = StashService.useScrapeFreeonesPerformers(query);
|
||||||
|
const performerNames = !!data && !!data.scrapeFreeonesPerformerList ? data.scrapeFreeonesPerformerList : [];
|
||||||
|
|
||||||
|
const renderInputValue = (performerName: string) => performerName;
|
||||||
|
|
||||||
|
const renderItem: ItemRenderer<string> = (performerName, itemProps) => {
|
||||||
|
if (!itemProps.modifiers.matchesPredicate) { return null; }
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
active={itemProps.modifiers.active}
|
||||||
|
disabled={itemProps.modifiers.disabled}
|
||||||
|
key={performerName}
|
||||||
|
onClick={itemProps.handleClick}
|
||||||
|
text={performerName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InternalSuggest
|
||||||
|
inputValueRenderer={renderInputValue}
|
||||||
|
items={performerNames}
|
||||||
|
itemRenderer={renderItem}
|
||||||
|
onItemSelect={(item) => { props.onQueryChange(item); setQuery(item); }}
|
||||||
|
onQueryChange={(newQuery) => { props.onQueryChange(newQuery); setQuery(newQuery); }}
|
||||||
|
activeItem={null}
|
||||||
|
selectedItem={query}
|
||||||
|
popoverProps={{position: "bottom"}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
61
ui/v2/src/components/select/MarkerTitleSuggest.tsx
Normal file
61
ui/v2/src/components/select/MarkerTitleSuggest.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { MenuItem } from "@blueprintjs/core";
|
||||||
|
import { ItemPredicate, ItemRenderer, Suggest } from "@blueprintjs/select";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { StashService } from "../../core/StashService";
|
||||||
|
import { HTMLInputProps } from "../../models";
|
||||||
|
|
||||||
|
const InternalSuggest = Suggest.ofType<GQL.MarkerStringsMarkerStrings>();
|
||||||
|
|
||||||
|
interface IProps extends HTMLInputProps {
|
||||||
|
initialMarkerString?: string;
|
||||||
|
onQueryChange: (query: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarkerTitleSuggest: React.FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
const { data } = StashService.useMarkerStrings();
|
||||||
|
const markerStrings = !!data && !!data.markerStrings ? data.markerStrings : [];
|
||||||
|
const [selectedItem, setSelectedItem] = React.useState<GQL.MarkerStringsMarkerStrings | null>(null);
|
||||||
|
|
||||||
|
if (!!props.initialMarkerString && !selectedItem) {
|
||||||
|
const initialItem = markerStrings.find((item) => {
|
||||||
|
return props.initialMarkerString!.toLowerCase() === item!.title.toLowerCase();
|
||||||
|
});
|
||||||
|
if (!!initialItem) { setSelectedItem(initialItem); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderInputValue = (markerString: GQL.MarkerStringsMarkerStrings) => markerString.title;
|
||||||
|
|
||||||
|
const renderItem: ItemRenderer<GQL.MarkerStringsMarkerStrings> = (markerString, itemProps) => {
|
||||||
|
if (!itemProps.modifiers.matchesPredicate) { return null; }
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
active={itemProps.modifiers.active}
|
||||||
|
disabled={itemProps.modifiers.disabled}
|
||||||
|
label={markerString.count.toString()}
|
||||||
|
key={markerString.id}
|
||||||
|
onClick={itemProps.handleClick}
|
||||||
|
text={markerString.title}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filter: ItemPredicate<GQL.MarkerStringsMarkerStrings> = (query, item) => {
|
||||||
|
return item.title.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InternalSuggest
|
||||||
|
inputValueRenderer={renderInputValue}
|
||||||
|
items={markerStrings as any}
|
||||||
|
itemRenderer={renderItem}
|
||||||
|
itemPredicate={filter}
|
||||||
|
onItemSelect={(item) => { props.onQueryChange(item.title); setSelectedItem(item); }}
|
||||||
|
onQueryChange={(query) => { props.onQueryChange(query); setSelectedItem(null); }}
|
||||||
|
activeItem={null}
|
||||||
|
selectedItem={selectedItem}
|
||||||
|
popoverProps={{position: "bottom"}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
71
ui/v2/src/components/select/ValidGalleriesSelect.tsx
Normal file
71
ui/v2/src/components/select/ValidGalleriesSelect.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { Button, MenuItem } from "@blueprintjs/core";
|
||||||
|
import { ItemPredicate, ItemRenderer, Select } from "@blueprintjs/select";
|
||||||
|
import * as GQL from "../../core/generated-graphql";
|
||||||
|
import { StashService } from "../../core/StashService";
|
||||||
|
import { HTMLInputProps } from "../../models";
|
||||||
|
|
||||||
|
const InternalSelect = Select.ofType<GQL.ValidGalleriesForSceneValidGalleriesForScene>();
|
||||||
|
|
||||||
|
interface IProps extends HTMLInputProps {
|
||||||
|
initialId?: string;
|
||||||
|
sceneId: string;
|
||||||
|
onSelectItem: (item: GQL.ValidGalleriesForSceneValidGalleriesForScene) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ValidGalleriesSelect: React.FunctionComponent<IProps> = (props: IProps) => {
|
||||||
|
const { data } = StashService.useValidGalleriesForScene(props.sceneId);
|
||||||
|
const items = !!data && !!data.validGalleriesForScene ? data.validGalleriesForScene : [];
|
||||||
|
// Add a none option to clear the gallery
|
||||||
|
if (!items.find((item) => item.id === "0")) { items.unshift({id: "0", path: "None"}); }
|
||||||
|
|
||||||
|
const [selectedItem, setSelectedItem] = React.useState<GQL.ValidGalleriesForSceneValidGalleriesForScene | null>(null);
|
||||||
|
const [isInitialized, setIsInitialized] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
if (!!props.initialId && !selectedItem && !isInitialized) {
|
||||||
|
const initialItem = items.find((item) => props.initialId === item.id);
|
||||||
|
if (!!initialItem) {
|
||||||
|
setSelectedItem(initialItem);
|
||||||
|
setIsInitialized(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItem: ItemRenderer<GQL.ValidGalleriesForSceneValidGalleriesForScene> = (item, itemProps) => {
|
||||||
|
if (!itemProps.modifiers.matchesPredicate) { return null; }
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
active={itemProps.modifiers.active}
|
||||||
|
disabled={itemProps.modifiers.disabled}
|
||||||
|
key={item.id}
|
||||||
|
onClick={itemProps.handleClick}
|
||||||
|
text={item.path}
|
||||||
|
shouldDismissPopover={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filter: ItemPredicate<GQL.ValidGalleriesForSceneValidGalleriesForScene> = (query, item) => {
|
||||||
|
return item.path!.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
function onItemSelect(item: GQL.ValidGalleriesForSceneValidGalleriesForScene) {
|
||||||
|
props.onSelectItem(item);
|
||||||
|
setSelectedItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonText = selectedItem ? selectedItem.path : "(No selection)";
|
||||||
|
return (
|
||||||
|
<InternalSelect
|
||||||
|
items={items}
|
||||||
|
itemRenderer={renderItem}
|
||||||
|
itemPredicate={filter}
|
||||||
|
noResults={<MenuItem disabled={true} text="No results." />}
|
||||||
|
onItemSelect={onItemSelect}
|
||||||
|
popoverProps={{position: "bottom"}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Button fill={true} text={buttonText} />
|
||||||
|
</InternalSelect>
|
||||||
|
);
|
||||||
|
};
|
||||||
204
ui/v2/src/core/StashService.ts
Normal file
204
ui/v2/src/core/StashService.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import ApolloClient from "apollo-boost";
|
||||||
|
import _ from "lodash";
|
||||||
|
import { ListFilterModel } from "../models/list-filter/filter";
|
||||||
|
import * as GQL from "./generated-graphql";
|
||||||
|
|
||||||
|
export class StashService {
|
||||||
|
public static client: ApolloClient<any>;
|
||||||
|
|
||||||
|
public static initialize() {
|
||||||
|
const platformUrl = new URL(window.location.origin);
|
||||||
|
platformUrl.port = platformUrl.protocol === "https:" ? "9999" : "9998";
|
||||||
|
const url = platformUrl.toString().slice(0, -1);
|
||||||
|
|
||||||
|
StashService.client = new ApolloClient({
|
||||||
|
uri: `${url}/graphql`,
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).StashService = StashService;
|
||||||
|
return StashService.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static useFindGalleries(filter: ListFilterModel) {
|
||||||
|
return GQL.useFindGalleries({
|
||||||
|
variables: {
|
||||||
|
filter: filter.makeFindFilter(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static useFindScenes(filter: ListFilterModel) {
|
||||||
|
let sceneFilter = {};
|
||||||
|
// if (!!filter && filter.criteriaFilterOpen) {
|
||||||
|
sceneFilter = filter.makeSceneFilter();
|
||||||
|
// }
|
||||||
|
// if (filter.customCriteria) {
|
||||||
|
// filter.customCriteria.forEach(criteria => {
|
||||||
|
// scene_filter[criteria.key] = criteria.value;
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
return GQL.useFindScenes({
|
||||||
|
variables: {
|
||||||
|
filter: filter.makeFindFilter(),
|
||||||
|
scene_filter: sceneFilter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static useFindSceneMarkers(filter: ListFilterModel) {
|
||||||
|
let sceneMarkerFilter = {};
|
||||||
|
// if (!!filter && filter.criteriaFilterOpen) {
|
||||||
|
sceneMarkerFilter = filter.makeSceneMarkerFilter();
|
||||||
|
// }
|
||||||
|
// if (filter.customCriteria) {
|
||||||
|
// filter.customCriteria.forEach(criteria => {
|
||||||
|
// scene_filter[criteria.key] = criteria.value;
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
return GQL.useFindSceneMarkers({
|
||||||
|
variables: {
|
||||||
|
filter: filter.makeFindFilter(),
|
||||||
|
scene_marker_filter: sceneMarkerFilter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static useFindStudios(filter: ListFilterModel) {
|
||||||
|
return GQL.useFindStudios({
|
||||||
|
variables: {
|
||||||
|
filter: filter.makeFindFilter(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static useFindPerformers(filter: ListFilterModel) {
|
||||||
|
let performerFilter = {};
|
||||||
|
// if (!!filter && filter.criteriaFilterOpen) {
|
||||||
|
performerFilter = filter.makePerformerFilter();
|
||||||
|
// }
|
||||||
|
// if (filter.customCriteria) {
|
||||||
|
// filter.customCriteria.forEach(criteria => {
|
||||||
|
// scene_filter[criteria.key] = criteria.value;
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
return GQL.useFindPerformers({
|
||||||
|
variables: {
|
||||||
|
filter: filter.makeFindFilter(),
|
||||||
|
performer_filter: performerFilter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static useFindGallery(id: string) { return GQL.useFindGallery({variables: {id}}); }
|
||||||
|
public static useFindScene(id: string) { return GQL.useFindScene({variables: {id}}); }
|
||||||
|
public static useFindPerformer(id: string) {
|
||||||
|
const skip = id === "new" ? true : false;
|
||||||
|
return GQL.useFindPerformer({variables: {id}, skip});
|
||||||
|
}
|
||||||
|
public static useFindStudio(id: string) {
|
||||||
|
const skip = id === "new" ? true : false;
|
||||||
|
return GQL.useFindStudio({variables: {id}, skip});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static useSceneMarkerCreate() { return GQL.useSceneMarkerCreate({ refetchQueries: ["FindScene"] }); }
|
||||||
|
public static useSceneMarkerUpdate() { return GQL.useSceneMarkerUpdate({ refetchQueries: ["FindScene"] }); }
|
||||||
|
public static useSceneMarkerDestroy() { return GQL.useSceneMarkerDestroy({ refetchQueries: ["FindScene"] }); }
|
||||||
|
|
||||||
|
public static useScrapeFreeonesPerformers(q: string) { return GQL.useScrapeFreeonesPerformers({ variables: { q } }); }
|
||||||
|
public static useMarkerStrings() { return GQL.useMarkerStrings(); }
|
||||||
|
public static useAllTags() { return GQL.useAllTags(); }
|
||||||
|
public static useAllTagsForFilter() { return GQL.useAllTagsForFilter(); }
|
||||||
|
public static useAllPerformersForFilter() { return GQL.useAllPerformersForFilter(); }
|
||||||
|
public static useAllStudiosForFilter() { return GQL.useAllStudiosForFilter(); }
|
||||||
|
public static useValidGalleriesForScene(sceneId: string) {
|
||||||
|
return GQL.useValidGalleriesForScene({variables: {scene_id: sceneId}});
|
||||||
|
}
|
||||||
|
public static useStats() { return GQL.useStats(); }
|
||||||
|
|
||||||
|
public static usePerformerCreate(input: GQL.PerformerCreateInput) {
|
||||||
|
return GQL.usePerformerCreate({ variables: input });
|
||||||
|
}
|
||||||
|
public static usePerformerUpdate(input: GQL.PerformerUpdateInput) {
|
||||||
|
return GQL.usePerformerUpdate({ variables: input });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static useSceneUpdate(input: GQL.SceneUpdateInput) {
|
||||||
|
return GQL.useSceneUpdate({ variables: input });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static useStudioCreate(input: GQL.StudioCreateInput) {
|
||||||
|
return GQL.useStudioCreate({ variables: input });
|
||||||
|
}
|
||||||
|
public static useStudioUpdate(input: GQL.StudioUpdateInput) {
|
||||||
|
return GQL.useStudioUpdate({ variables: input });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static useTagCreate(input: GQL.TagCreateInput) {
|
||||||
|
return GQL.useTagCreate({ variables: input, refetchQueries: ["AllTags"] });
|
||||||
|
}
|
||||||
|
public static useTagUpdate(input: GQL.TagUpdateInput) {
|
||||||
|
return GQL.useTagUpdate({ variables: input, refetchQueries: ["AllTags"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static queryScrapeFreeones(performerName: string) {
|
||||||
|
return StashService.client.query<GQL.ScrapeFreeonesQuery>({
|
||||||
|
query: GQL.ScrapeFreeonesDocument,
|
||||||
|
variables: {
|
||||||
|
performer_name: performerName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static queryMetadataScan() {
|
||||||
|
return StashService.client.query<GQL.MetadataScanQuery>({
|
||||||
|
query: GQL.MetadataScanDocument,
|
||||||
|
fetchPolicy: "network-only",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static queryMetadataGenerate() {
|
||||||
|
return StashService.client.query<GQL.MetadataGenerateQuery>({
|
||||||
|
query: GQL.MetadataGenerateDocument,
|
||||||
|
fetchPolicy: "network-only",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static queryMetadataClean() {
|
||||||
|
return StashService.client.query<GQL.MetadataCleanQuery>({
|
||||||
|
query: GQL.MetadataCleanDocument,
|
||||||
|
fetchPolicy: "network-only",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static queryMetadataExport() {
|
||||||
|
return StashService.client.query<GQL.MetadataExportQuery>({
|
||||||
|
query: GQL.MetadataExportDocument,
|
||||||
|
fetchPolicy: "network-only",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static queryMetadataImport() {
|
||||||
|
return StashService.client.query<GQL.MetadataImportQuery>({
|
||||||
|
query: GQL.MetadataImportDocument,
|
||||||
|
fetchPolicy: "network-only",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static nullToUndefined(value: any): any {
|
||||||
|
if (_.isPlainObject(value)) {
|
||||||
|
return _.mapValues(value, StashService.nullToUndefined);
|
||||||
|
}
|
||||||
|
if (_.isArray(value)) {
|
||||||
|
return value.map(StashService.nullToUndefined);
|
||||||
|
}
|
||||||
|
if (value === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
}
|
||||||
2239
ui/v2/src/core/generated-graphql.tsx
Normal file
2239
ui/v2/src/core/generated-graphql.tsx
Normal file
File diff suppressed because it is too large
Load Diff
171
ui/v2/src/hooks/ListHook.tsx
Normal file
171
ui/v2/src/hooks/ListHook.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { Spinner } from "@blueprintjs/core";
|
||||||
|
import _ from "lodash";
|
||||||
|
import queryString from "query-string";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { QueryHookResult } from "react-apollo-hooks";
|
||||||
|
import { ListFilter } from "../components/list/ListFilter";
|
||||||
|
import { Pagination } from "../components/list/Pagination";
|
||||||
|
import { StashService } from "../core/StashService";
|
||||||
|
import { IBaseProps } from "../models";
|
||||||
|
import { Criterion } from "../models/list-filter/criteria/criterion";
|
||||||
|
import { ListFilterModel } from "../models/list-filter/filter";
|
||||||
|
import { DisplayMode, FilterMode } from "../models/list-filter/types";
|
||||||
|
|
||||||
|
export interface IListHookData {
|
||||||
|
filter: ListFilterModel;
|
||||||
|
template: JSX.Element;
|
||||||
|
options: IListHookOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IListHookOptions {
|
||||||
|
filterMode: FilterMode;
|
||||||
|
props: IBaseProps;
|
||||||
|
renderContent: (result: QueryHookResult<any, any>, filter: ListFilterModel) => JSX.Element | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListHook {
|
||||||
|
public static useList(options: IListHookOptions): IListHookData {
|
||||||
|
const [filter, setFilter] = useState<ListFilterModel>(new ListFilterModel(options.filterMode));
|
||||||
|
|
||||||
|
// Update the filter when the query parameters change
|
||||||
|
useEffect(() => {
|
||||||
|
const queryParams = queryString.parse(options.props.location.search);
|
||||||
|
filter.configureFromQueryParameters(queryParams);
|
||||||
|
setFilter(filter);
|
||||||
|
}, [options.props.location.search]);
|
||||||
|
|
||||||
|
let result: QueryHookResult<any, any>;
|
||||||
|
let totalCount: number;
|
||||||
|
|
||||||
|
switch (options.filterMode) {
|
||||||
|
case FilterMode.Scenes: {
|
||||||
|
result = StashService.useFindScenes(filter);
|
||||||
|
totalCount = !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case FilterMode.SceneMarkers: {
|
||||||
|
result = StashService.useFindSceneMarkers(filter);
|
||||||
|
totalCount = !!result.data && !!result.data.findSceneMarkers ? result.data.findSceneMarkers.count : 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case FilterMode.Galleries: {
|
||||||
|
result = StashService.useFindGalleries(filter);
|
||||||
|
totalCount = !!result.data && !!result.data.findGalleries ? result.data.findGalleries.count : 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case FilterMode.Studios: {
|
||||||
|
result = StashService.useFindStudios(filter);
|
||||||
|
totalCount = !!result.data && !!result.data.findStudios ? result.data.findStudios.count : 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case FilterMode.Performers: {
|
||||||
|
result = StashService.useFindPerformers(filter);
|
||||||
|
totalCount = !!result.data && !!result.data.findPerformers ? result.data.findPerformers.count : 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
console.error("REMOVE DEFAULT IN LIST HOOK");
|
||||||
|
result = StashService.useFindScenes(filter);
|
||||||
|
totalCount = !!result.data && !!result.data.findScenes ? result.data.findScenes.count : 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the query parameters when the data changes
|
||||||
|
useEffect(() => {
|
||||||
|
const location = Object.assign({}, options.props.history.location);
|
||||||
|
location.search = filter.makeQueryParameters();
|
||||||
|
options.props.history.replace(location);
|
||||||
|
}, [result.data, filter.displayMode]);
|
||||||
|
|
||||||
|
// Update the total count
|
||||||
|
useEffect(() => {
|
||||||
|
const newFilter = _.cloneDeep(filter);
|
||||||
|
newFilter.totalCount = totalCount;
|
||||||
|
setFilter(newFilter);
|
||||||
|
}, [totalCount]);
|
||||||
|
|
||||||
|
function onChangePageSize(pageSize: number) {
|
||||||
|
const newFilter = _.cloneDeep(filter);
|
||||||
|
newFilter.itemsPerPage = pageSize;
|
||||||
|
newFilter.currentPage = 1;
|
||||||
|
setFilter(newFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeQuery(query: string) {
|
||||||
|
const newFilter = _.cloneDeep(filter);
|
||||||
|
newFilter.searchTerm = query;
|
||||||
|
newFilter.currentPage = 1;
|
||||||
|
setFilter(newFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeSortDirection(sortDirection: "asc" | "desc") {
|
||||||
|
const newFilter = _.cloneDeep(filter);
|
||||||
|
newFilter.sortDirection = sortDirection;
|
||||||
|
setFilter(newFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeSortBy(sortBy: string) {
|
||||||
|
const newFilter = _.cloneDeep(filter);
|
||||||
|
newFilter.sortBy = sortBy;
|
||||||
|
setFilter(newFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeDisplayMode(displayMode: DisplayMode) {
|
||||||
|
const newFilter = _.cloneDeep(filter);
|
||||||
|
newFilter.displayMode = displayMode;
|
||||||
|
setFilter(newFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddCriterion(criterion: Criterion) {
|
||||||
|
const newFilter = _.cloneDeep(filter);
|
||||||
|
const existingIndex = newFilter.criteria.findIndex((c) => c.getId() === criterion.getId());
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
newFilter.criteria.push(criterion);
|
||||||
|
} else {
|
||||||
|
newFilter.criteria[existingIndex] = criterion;
|
||||||
|
}
|
||||||
|
newFilter.currentPage = 1;
|
||||||
|
setFilter(newFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemoveCriterion(removedCriterion: Criterion) {
|
||||||
|
const newFilter = _.cloneDeep(filter);
|
||||||
|
newFilter.criteria = newFilter.criteria.filter((criterion) => criterion.getId() !== removedCriterion.getId());
|
||||||
|
newFilter.currentPage = 1;
|
||||||
|
setFilter(newFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangePage(page: number) {
|
||||||
|
const newFilter = _.cloneDeep(filter);
|
||||||
|
newFilter.currentPage = page;
|
||||||
|
setFilter(newFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = (
|
||||||
|
<div>
|
||||||
|
<ListFilter
|
||||||
|
onChangePageSize={onChangePageSize}
|
||||||
|
onChangeQuery={onChangeQuery}
|
||||||
|
onChangeSortDirection={onChangeSortDirection}
|
||||||
|
onChangeSortBy={onChangeSortBy}
|
||||||
|
onChangeDisplayMode={onChangeDisplayMode}
|
||||||
|
onAddCriterion={onAddCriterion}
|
||||||
|
onRemoveCriterion={onRemoveCriterion}
|
||||||
|
filter={filter}
|
||||||
|
/>
|
||||||
|
{result.loading ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
|
||||||
|
{result.error ? <h1>{result.error.message}</h1> : undefined}
|
||||||
|
{options.renderContent(result, filter)}
|
||||||
|
<Pagination
|
||||||
|
itemsPerPage={filter.itemsPerPage}
|
||||||
|
currentPage={filter.currentPage}
|
||||||
|
totalItems={totalCount}
|
||||||
|
onChangePage={onChangePage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { filter, template, options };
|
||||||
|
}
|
||||||
|
}
|
||||||
69
ui/v2/src/hooks/VideoHover.ts
Normal file
69
ui/v2/src/hooks/VideoHover.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export interface IVideoHoverHookData {
|
||||||
|
videoEl: React.RefObject<HTMLVideoElement>;
|
||||||
|
isPlaying: React.MutableRefObject<boolean>;
|
||||||
|
isHovering: React.MutableRefObject<boolean>;
|
||||||
|
options: IVideoHoverHookOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IVideoHoverHookOptions {
|
||||||
|
resetOnMouseLeave: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VideoHoverHook {
|
||||||
|
public static useVideoHover(options?: IVideoHoverHookOptions): IVideoHoverHookData {
|
||||||
|
if (options === undefined) {
|
||||||
|
options = {
|
||||||
|
resetOnMouseLeave: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoEl = useRef<HTMLVideoElement>(null);
|
||||||
|
const isPlaying = useRef<boolean>(false);
|
||||||
|
const isHovering = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const videoTag = videoEl.current;
|
||||||
|
if (!videoTag) { return; }
|
||||||
|
videoTag.volume = 0.05;
|
||||||
|
videoTag.onplaying = () => {
|
||||||
|
if (isHovering.current === true) {
|
||||||
|
isPlaying.current = true;
|
||||||
|
} else {
|
||||||
|
videoTag.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
videoTag.onpause = () => isPlaying.current = false;
|
||||||
|
}, [videoEl]);
|
||||||
|
|
||||||
|
return {videoEl, isPlaying, isHovering, options};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static onMouseEnter(data: IVideoHoverHookData) {
|
||||||
|
data.isHovering.current = true;
|
||||||
|
|
||||||
|
const videoTag = data.videoEl.current;
|
||||||
|
if (!videoTag) { return; }
|
||||||
|
if (videoTag.paused && !data.isPlaying.current) {
|
||||||
|
videoTag.play().catch((error) => {
|
||||||
|
console.log(error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static onMouseLeave(data: IVideoHoverHookData) {
|
||||||
|
data.isHovering.current = false;
|
||||||
|
|
||||||
|
const videoTag = data.videoEl.current;
|
||||||
|
if (!videoTag) { return; }
|
||||||
|
if (!videoTag.paused && data.isPlaying) {
|
||||||
|
videoTag.pause();
|
||||||
|
if (data.options.resetOnMouseLeave) {
|
||||||
|
videoTag.removeAttribute("src");
|
||||||
|
videoTag.load();
|
||||||
|
data.isPlaying.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
179
ui/v2/src/index.scss
Executable file
179
ui/v2/src/index.scss
Executable file
@@ -0,0 +1,179 @@
|
|||||||
|
@import "~normalize.css";
|
||||||
|
|
||||||
|
@import "~@blueprintjs/core/lib/css/blueprint.css";
|
||||||
|
@import "~@blueprintjs/icons/lib/css/blueprint-icons.css";
|
||||||
|
@import "~@blueprintjs/core/lib/scss/variables";
|
||||||
|
|
||||||
|
@import "~bulma/sass/utilities/all";
|
||||||
|
@import "~bulma/sass/grid/all";
|
||||||
|
@import "~bulma/sass/layout/all";
|
||||||
|
@import "~bulma/sass/components/level";
|
||||||
|
|
||||||
|
@import "styles/form/grid";
|
||||||
|
|
||||||
|
@import "styles/shared/details";
|
||||||
|
|
||||||
|
@import "styles/blueprint-overrides";
|
||||||
|
@import "styles/scrollbars";
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: $pt-navbar-height 0 0 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
height: 100vh;
|
||||||
|
background: $dark-gray2;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
justify-content: center;
|
||||||
|
margin: $pt-grid-size $pt-grid-size 0 0;
|
||||||
|
padding: 0 calc(10%);
|
||||||
|
|
||||||
|
&.wall {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-item {
|
||||||
|
// flex: auto;
|
||||||
|
width: calc(25% - 1.5em);
|
||||||
|
margin: 0px 0 $pt-grid-size $pt-grid-size;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.wall {
|
||||||
|
width: calc(20%);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewable {
|
||||||
|
display: block;
|
||||||
|
line-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
width: calc(100% + 40px);
|
||||||
|
margin: -20px 0 0 -20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.preview {
|
||||||
|
// height: 225px; // slows down the page
|
||||||
|
width: 100%;
|
||||||
|
// width: calc(100% + 40px);
|
||||||
|
// margin: -20px 0 0 -20px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-item {
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-item {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 10px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-section {
|
||||||
|
padding: 10px 0 0 0;
|
||||||
|
&.centered {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-flow: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-5 { background: #FF2F39; }
|
||||||
|
.rating-4 { background: $red1; }
|
||||||
|
.rating-3 { background: $orange1; }
|
||||||
|
.rating-2 { background: $sepia1; }
|
||||||
|
.rating-1 { background: $dark-gray5; }
|
||||||
|
|
||||||
|
.rating-banner {
|
||||||
|
transform: rotate(-36deg);
|
||||||
|
display: block;
|
||||||
|
padding: 6px 45px;
|
||||||
|
font-weight: 400;
|
||||||
|
top: 14px;
|
||||||
|
position: absolute;
|
||||||
|
left: -46px;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-size-adjust: none;
|
||||||
|
font-size: .85714em;
|
||||||
|
line-height: 1.6em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#jwplayer-container {
|
||||||
|
margin: 10px auto;
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#details-container {
|
||||||
|
margin: 10px auto;
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pre {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.block {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performer.image {
|
||||||
|
height: 50vh;
|
||||||
|
background-size: cover !important;
|
||||||
|
background-position: center !important;
|
||||||
|
background-repeat: no-repeat !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio.image {
|
||||||
|
height: 100px;
|
||||||
|
background-size: contain !important;
|
||||||
|
background-position: center !important;
|
||||||
|
background-repeat: no-repeat !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-spacing {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-photo-gallery--gallery {
|
||||||
|
& img {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#tag-list-container {
|
||||||
|
width: 50vw;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
& .tag-list-row {
|
||||||
|
margin: 10px;
|
||||||
|
|
||||||
|
& .bp3-button {
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
ui/v2/src/index.tsx
Executable file
21
ui/v2/src/index.tsx
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { ApolloProvider } from "react-apollo-hooks";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { App } from "./App";
|
||||||
|
import { StashService } from "./core/StashService";
|
||||||
|
import "./index.scss";
|
||||||
|
import * as serviceWorker from "./serviceWorker";
|
||||||
|
|
||||||
|
ReactDOM.render((
|
||||||
|
<BrowserRouter>
|
||||||
|
<ApolloProvider client={StashService.initialize()!}>
|
||||||
|
<App />
|
||||||
|
</ApolloProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
), document.getElementById("root"));
|
||||||
|
|
||||||
|
// If you want your app to work offline and load faster, you can change
|
||||||
|
// unregister() to register() below. Note this comes with some pitfalls.
|
||||||
|
// Learn more about service workers: http://bit.ly/CRA-PWA
|
||||||
|
serviceWorker.unregister();
|
||||||
3
ui/v2/src/models/base-props.ts
Normal file
3
ui/v2/src/models/base-props.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { RouteComponentProps } from "react-router";
|
||||||
|
|
||||||
|
export interface IBaseProps<M = any> extends RouteComponentProps<M> {}
|
||||||
2
ui/v2/src/models/index.ts
Normal file
2
ui/v2/src/models/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./base-props";
|
||||||
|
export * from "./types";
|
||||||
92
ui/v2/src/models/list-filter/criteria/criterion.ts
Normal file
92
ui/v2/src/models/list-filter/criteria/criterion.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { isArray } from "util";
|
||||||
|
import { ILabeledId } from "../types";
|
||||||
|
|
||||||
|
export type CriterionType =
|
||||||
|
"none" |
|
||||||
|
"rating" |
|
||||||
|
"resolution" |
|
||||||
|
"favorite" |
|
||||||
|
"hasMarkers" |
|
||||||
|
"isMissing" |
|
||||||
|
"tags" |
|
||||||
|
"sceneTags" |
|
||||||
|
"performers" |
|
||||||
|
"studios";
|
||||||
|
|
||||||
|
export enum CriterionModifier {
|
||||||
|
Equals,
|
||||||
|
NotEquals,
|
||||||
|
GreaterThan,
|
||||||
|
LessThan,
|
||||||
|
Inclusive,
|
||||||
|
Exclusive,
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class Criterion<Option = any, Value = any> {
|
||||||
|
public static getLabel(type: CriterionType = "none"): string {
|
||||||
|
switch (type) {
|
||||||
|
case "none": return "None";
|
||||||
|
case "rating": return "Rating";
|
||||||
|
case "resolution": return "Resolution";
|
||||||
|
case "favorite": return "Favorite";
|
||||||
|
case "hasMarkers": return "Has Markers";
|
||||||
|
case "isMissing": return "Is Missing";
|
||||||
|
case "tags": return "Tags";
|
||||||
|
case "sceneTags": return "Scene Tags";
|
||||||
|
case "performers": return "Performers";
|
||||||
|
case "studios": return "Studios";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract type: CriterionType;
|
||||||
|
public abstract parameterName: string;
|
||||||
|
public abstract modifier: CriterionModifier;
|
||||||
|
public abstract options: Option[];
|
||||||
|
public abstract value: Value;
|
||||||
|
|
||||||
|
public getLabel(): string {
|
||||||
|
let modifierString: string;
|
||||||
|
switch (this.modifier) {
|
||||||
|
case CriterionModifier.Equals: modifierString = "is"; break;
|
||||||
|
case CriterionModifier.NotEquals: modifierString = "is not"; break;
|
||||||
|
case CriterionModifier.GreaterThan: modifierString = "is greater than"; break;
|
||||||
|
case CriterionModifier.LessThan: modifierString = "is less than"; break;
|
||||||
|
case CriterionModifier.Inclusive: modifierString = "includes"; break;
|
||||||
|
case CriterionModifier.Exclusive: modifierString = "exculdes"; break;
|
||||||
|
default: modifierString = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let valueString: string;
|
||||||
|
if (isArray(this.value) && this.value.length > 0) {
|
||||||
|
let items = this.value;
|
||||||
|
if ((this.value as ILabeledId[])[0].label) {
|
||||||
|
items = this.value.map((item) => item.label) as any;
|
||||||
|
}
|
||||||
|
valueString = items.join(", ");
|
||||||
|
} else if (typeof this.value === "string") {
|
||||||
|
valueString = this.value;
|
||||||
|
} else {
|
||||||
|
valueString = this.value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Criterion.getLabel(this.type)} ${modifierString} ${valueString}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getId(): string {
|
||||||
|
return `${this.parameterName}-${this.modifier.toString()}`; // TODO add values?
|
||||||
|
}
|
||||||
|
|
||||||
|
public set(modifier: CriterionModifier, value: Value) {
|
||||||
|
this.modifier = modifier;
|
||||||
|
if (isArray(this.value)) {
|
||||||
|
this.value.push(value);
|
||||||
|
} else {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICriterionOption {
|
||||||
|
label: string;
|
||||||
|
value: CriterionType;
|
||||||
|
}
|
||||||
19
ui/v2/src/models/list-filter/criteria/favorite.ts
Normal file
19
ui/v2/src/models/list-filter/criteria/favorite.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
Criterion,
|
||||||
|
CriterionModifier,
|
||||||
|
CriterionType,
|
||||||
|
ICriterionOption,
|
||||||
|
} from "./criterion";
|
||||||
|
|
||||||
|
export class FavoriteCriterion extends Criterion<string, string> {
|
||||||
|
public type: CriterionType = "favorite";
|
||||||
|
public parameterName: string = "filter_favorites";
|
||||||
|
public modifier = CriterionModifier.Equals;
|
||||||
|
public options: string[] = [true.toString(), false.toString()];
|
||||||
|
public value: string = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FavoriteCriterionOption implements ICriterionOption {
|
||||||
|
public label: string = Criterion.getLabel("favorite");
|
||||||
|
public value: CriterionType = "favorite";
|
||||||
|
}
|
||||||
19
ui/v2/src/models/list-filter/criteria/has-markers.ts
Normal file
19
ui/v2/src/models/list-filter/criteria/has-markers.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
Criterion,
|
||||||
|
CriterionModifier,
|
||||||
|
CriterionType,
|
||||||
|
ICriterionOption,
|
||||||
|
} from "./criterion";
|
||||||
|
|
||||||
|
export class HasMarkersCriterion extends Criterion<string, string> {
|
||||||
|
public type: CriterionType = "hasMarkers";
|
||||||
|
public parameterName: string = "has_markers";
|
||||||
|
public modifier = CriterionModifier.Equals;
|
||||||
|
public options: string[] = [true.toString(), false.toString()];
|
||||||
|
public value: string = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HasMarkersCriterionOption implements ICriterionOption {
|
||||||
|
public label: string = Criterion.getLabel("hasMarkers");
|
||||||
|
public value: CriterionType = "hasMarkers";
|
||||||
|
}
|
||||||
19
ui/v2/src/models/list-filter/criteria/is-missing.ts
Normal file
19
ui/v2/src/models/list-filter/criteria/is-missing.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
Criterion,
|
||||||
|
CriterionModifier,
|
||||||
|
CriterionType,
|
||||||
|
ICriterionOption,
|
||||||
|
} from "./criterion";
|
||||||
|
|
||||||
|
export class IsMissingCriterion extends Criterion<string, string> {
|
||||||
|
public type: CriterionType = "isMissing";
|
||||||
|
public parameterName: string = "is_missing";
|
||||||
|
public modifier = CriterionModifier.Equals;
|
||||||
|
public options: string[] = ["title", "url", "date", "gallery", "studio", "performers"];
|
||||||
|
public value: string = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IsMissingCriterionOption implements ICriterionOption {
|
||||||
|
public label: string = Criterion.getLabel("isMissing");
|
||||||
|
public value: CriterionType = "isMissing";
|
||||||
|
}
|
||||||
19
ui/v2/src/models/list-filter/criteria/none.ts
Normal file
19
ui/v2/src/models/list-filter/criteria/none.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
Criterion,
|
||||||
|
CriterionModifier,
|
||||||
|
CriterionType,
|
||||||
|
ICriterionOption,
|
||||||
|
} from "./criterion";
|
||||||
|
|
||||||
|
export class NoneCriterion extends Criterion<any, any> {
|
||||||
|
public type: CriterionType = "none";
|
||||||
|
public parameterName: string = "";
|
||||||
|
public modifier = CriterionModifier.Equals;
|
||||||
|
public options: any;
|
||||||
|
public value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NoneCriterionOption implements ICriterionOption {
|
||||||
|
public label: string = Criterion.getLabel("none");
|
||||||
|
public value: CriterionType = "none";
|
||||||
|
}
|
||||||
26
ui/v2/src/models/list-filter/criteria/performers.ts
Normal file
26
ui/v2/src/models/list-filter/criteria/performers.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { ILabeledId } from "../types";
|
||||||
|
import {
|
||||||
|
Criterion,
|
||||||
|
CriterionModifier,
|
||||||
|
CriterionType,
|
||||||
|
ICriterionOption,
|
||||||
|
} from "./criterion";
|
||||||
|
|
||||||
|
interface IOptionType {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
image_path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerformersCriterion extends Criterion<IOptionType, ILabeledId[]> {
|
||||||
|
public type: CriterionType = "performers";
|
||||||
|
public parameterName: string = "performers";
|
||||||
|
public modifier = CriterionModifier.Equals;
|
||||||
|
public options: IOptionType[] = [];
|
||||||
|
public value: ILabeledId[] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerformersCriterionOption implements ICriterionOption {
|
||||||
|
public label: string = Criterion.getLabel("performers");
|
||||||
|
public value: CriterionType = "performers";
|
||||||
|
}
|
||||||
19
ui/v2/src/models/list-filter/criteria/rating.ts
Normal file
19
ui/v2/src/models/list-filter/criteria/rating.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
Criterion,
|
||||||
|
CriterionModifier,
|
||||||
|
CriterionType,
|
||||||
|
ICriterionOption,
|
||||||
|
} from "./criterion";
|
||||||
|
|
||||||
|
export class RatingCriterion extends Criterion<number, number> { // TODO <number, number[]>
|
||||||
|
public type: CriterionType = "rating";
|
||||||
|
public parameterName: string = "rating";
|
||||||
|
public modifier = CriterionModifier.Equals;
|
||||||
|
public options: number[] = [1, 2, 3, 4, 5];
|
||||||
|
public value: number = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RatingCriterionOption implements ICriterionOption {
|
||||||
|
public label: string = Criterion.getLabel("rating");
|
||||||
|
public value: CriterionType = "rating";
|
||||||
|
}
|
||||||
19
ui/v2/src/models/list-filter/criteria/resolution.ts
Normal file
19
ui/v2/src/models/list-filter/criteria/resolution.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
Criterion,
|
||||||
|
CriterionModifier,
|
||||||
|
CriterionType,
|
||||||
|
ICriterionOption,
|
||||||
|
} from "./criterion";
|
||||||
|
|
||||||
|
export class ResolutionCriterion extends Criterion<string, string> { // TODO <string, string[]>
|
||||||
|
public type: CriterionType = "resolution";
|
||||||
|
public parameterName: string = "resolution";
|
||||||
|
public modifier = CriterionModifier.Equals;
|
||||||
|
public options: string[] = ["240p", "480p", "720p", "1080p", "4k"];
|
||||||
|
public value: string = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResolutionCriterionOption implements ICriterionOption {
|
||||||
|
public label: string = Criterion.getLabel("resolution");
|
||||||
|
public value: CriterionType = "resolution";
|
||||||
|
}
|
||||||
26
ui/v2/src/models/list-filter/criteria/studios.ts
Normal file
26
ui/v2/src/models/list-filter/criteria/studios.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { ILabeledId } from "../types";
|
||||||
|
import {
|
||||||
|
Criterion,
|
||||||
|
CriterionModifier,
|
||||||
|
CriterionType,
|
||||||
|
ICriterionOption,
|
||||||
|
} from "./criterion";
|
||||||
|
|
||||||
|
interface IOptionType {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
image_path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StudiosCriterion extends Criterion<IOptionType, ILabeledId[]> {
|
||||||
|
public type: CriterionType = "studios";
|
||||||
|
public parameterName: string = "studios";
|
||||||
|
public modifier = CriterionModifier.Equals;
|
||||||
|
public options: IOptionType[] = [];
|
||||||
|
public value: ILabeledId[] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StudiosCriterionOption implements ICriterionOption {
|
||||||
|
public label: string = Criterion.getLabel("studios");
|
||||||
|
public value: CriterionType = "studios";
|
||||||
|
}
|
||||||
35
ui/v2/src/models/list-filter/criteria/tags.ts
Normal file
35
ui/v2/src/models/list-filter/criteria/tags.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import * as GQL from "../../../core/generated-graphql";
|
||||||
|
import { ILabeledId } from "../types";
|
||||||
|
import {
|
||||||
|
Criterion,
|
||||||
|
CriterionModifier,
|
||||||
|
CriterionType,
|
||||||
|
ICriterionOption,
|
||||||
|
} from "./criterion";
|
||||||
|
|
||||||
|
export class TagsCriterion extends Criterion<GQL.AllTagsForFilterAllTags, ILabeledId[]> {
|
||||||
|
public type: CriterionType;
|
||||||
|
public parameterName: string;
|
||||||
|
public modifier = CriterionModifier.Equals;
|
||||||
|
public options: GQL.AllTagsForFilterAllTags[] = [];
|
||||||
|
public value: ILabeledId[] = [];
|
||||||
|
|
||||||
|
constructor(type: "tags" | "sceneTags") {
|
||||||
|
super();
|
||||||
|
this.type = type;
|
||||||
|
this.parameterName = type;
|
||||||
|
if (type === "sceneTags") {
|
||||||
|
this.parameterName = "scene_tags";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TagsCriterionOption implements ICriterionOption {
|
||||||
|
public label: string = Criterion.getLabel("tags");
|
||||||
|
public value: CriterionType = "tags";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SceneTagsCriterionOption implements ICriterionOption {
|
||||||
|
public label: string = Criterion.getLabel("sceneTags");
|
||||||
|
public value: CriterionType = "sceneTags";
|
||||||
|
}
|
||||||
33
ui/v2/src/models/list-filter/criteria/utils.ts
Normal file
33
ui/v2/src/models/list-filter/criteria/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { QueryHookResult } from "react-apollo-hooks";
|
||||||
|
import {
|
||||||
|
AllPerformersForFilterQuery,
|
||||||
|
AllPerformersForFilterVariables,
|
||||||
|
AllTagsForFilterQuery,
|
||||||
|
AllTagsForFilterVariables,
|
||||||
|
} from "../../../core/generated-graphql";
|
||||||
|
import { StashService } from "../../../core/StashService";
|
||||||
|
import { Criterion, CriterionType } from "./criterion";
|
||||||
|
import { FavoriteCriterion } from "./favorite";
|
||||||
|
import { HasMarkersCriterion } from "./has-markers";
|
||||||
|
import { IsMissingCriterion } from "./is-missing";
|
||||||
|
import { NoneCriterion } from "./none";
|
||||||
|
import { PerformersCriterion } from "./performers";
|
||||||
|
import { RatingCriterion } from "./rating";
|
||||||
|
import { ResolutionCriterion } from "./resolution";
|
||||||
|
import { StudiosCriterion } from "./studios";
|
||||||
|
import { TagsCriterion } from "./tags";
|
||||||
|
|
||||||
|
export function makeCriteria(type: CriterionType = "none") {
|
||||||
|
switch (type) {
|
||||||
|
case "none": return new NoneCriterion();
|
||||||
|
case "rating": return new RatingCriterion();
|
||||||
|
case "resolution": return new ResolutionCriterion();
|
||||||
|
case "favorite": return new FavoriteCriterion();
|
||||||
|
case "hasMarkers": return new HasMarkersCriterion();
|
||||||
|
case "isMissing": return new IsMissingCriterion();
|
||||||
|
case "tags": return new TagsCriterion("tags");
|
||||||
|
case "sceneTags": return new TagsCriterion("sceneTags");
|
||||||
|
case "performers": return new PerformersCriterion();
|
||||||
|
case "studios": return new StudiosCriterion();
|
||||||
|
}
|
||||||
|
}
|
||||||
263
ui/v2/src/models/list-filter/filter.ts
Normal file
263
ui/v2/src/models/list-filter/filter.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import queryString from "query-string";
|
||||||
|
import {
|
||||||
|
FindFilterType,
|
||||||
|
PerformerFilterType,
|
||||||
|
ResolutionEnum,
|
||||||
|
SceneFilterType,
|
||||||
|
SceneMarkerFilterType,
|
||||||
|
SortDirectionEnum,
|
||||||
|
} from "../../core/generated-graphql";
|
||||||
|
import { Criterion, ICriterionOption } from "./criteria/criterion";
|
||||||
|
import { FavoriteCriterion, FavoriteCriterionOption } from "./criteria/favorite";
|
||||||
|
import { HasMarkersCriterion, HasMarkersCriterionOption } from "./criteria/has-markers";
|
||||||
|
import { IsMissingCriterion, IsMissingCriterionOption } from "./criteria/is-missing";
|
||||||
|
import { NoneCriterionOption } from "./criteria/none";
|
||||||
|
import { PerformersCriterion, PerformersCriterionOption } from "./criteria/performers";
|
||||||
|
import { RatingCriterion, RatingCriterionOption } from "./criteria/rating";
|
||||||
|
import { ResolutionCriterion, ResolutionCriterionOption } from "./criteria/resolution";
|
||||||
|
import { StudiosCriterion, StudiosCriterionOption } from "./criteria/studios";
|
||||||
|
import { SceneTagsCriterionOption, TagsCriterion, TagsCriterionOption } from "./criteria/tags";
|
||||||
|
import { makeCriteria } from "./criteria/utils";
|
||||||
|
import {
|
||||||
|
DisplayMode,
|
||||||
|
FilterMode,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface IQueryParameters {
|
||||||
|
sortby?: string;
|
||||||
|
sortdir?: string;
|
||||||
|
disp?: string;
|
||||||
|
q?: string;
|
||||||
|
p?: string;
|
||||||
|
c?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: handle customCriteria
|
||||||
|
export class ListFilterModel {
|
||||||
|
public filterMode: FilterMode = FilterMode.Scenes;
|
||||||
|
public searchTerm?: string;
|
||||||
|
public currentPage = 1;
|
||||||
|
public itemsPerPage = 40;
|
||||||
|
public sortDirection: "asc" | "desc" = "asc";
|
||||||
|
public sortBy?: string;
|
||||||
|
public sortByOptions: string[] = [];
|
||||||
|
public displayMode: DisplayMode = DisplayMode.Grid;
|
||||||
|
public displayModeOptions: DisplayMode[] = [];
|
||||||
|
public criterionOptions: ICriterionOption[] = [];
|
||||||
|
public criteria: Array<Criterion<any, any>> = [];
|
||||||
|
public totalCount: number = 0;
|
||||||
|
|
||||||
|
public constructor(filterMode: FilterMode) {
|
||||||
|
switch (filterMode) {
|
||||||
|
case FilterMode.Scenes:
|
||||||
|
if (!!this.sortBy === false) { this.sortBy = "date"; }
|
||||||
|
this.sortByOptions = ["title", "rating", "date", "filesize", "duration", "framerate", "bitrate", "random"];
|
||||||
|
this.displayModeOptions = [
|
||||||
|
DisplayMode.Grid,
|
||||||
|
DisplayMode.List,
|
||||||
|
DisplayMode.Wall,
|
||||||
|
];
|
||||||
|
this.criterionOptions = [
|
||||||
|
new NoneCriterionOption(),
|
||||||
|
new RatingCriterionOption(),
|
||||||
|
new ResolutionCriterionOption(),
|
||||||
|
new HasMarkersCriterionOption(),
|
||||||
|
new IsMissingCriterionOption(),
|
||||||
|
new TagsCriterionOption(),
|
||||||
|
new PerformersCriterionOption(),
|
||||||
|
new StudiosCriterionOption(),
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case FilterMode.Performers:
|
||||||
|
if (!!this.sortBy === false) { this.sortBy = "name"; }
|
||||||
|
this.sortByOptions = ["name", "height", "birthdate", "scenes_count"];
|
||||||
|
this.displayModeOptions = [
|
||||||
|
DisplayMode.Grid,
|
||||||
|
DisplayMode.List,
|
||||||
|
];
|
||||||
|
this.criterionOptions = [
|
||||||
|
new NoneCriterionOption(),
|
||||||
|
new FavoriteCriterionOption(),
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case FilterMode.Studios:
|
||||||
|
if (!!this.sortBy === false) { this.sortBy = "name"; }
|
||||||
|
this.sortByOptions = ["name", "scenes_count"];
|
||||||
|
this.displayModeOptions = [
|
||||||
|
DisplayMode.Grid,
|
||||||
|
];
|
||||||
|
this.criterionOptions = [
|
||||||
|
new NoneCriterionOption(),
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case FilterMode.Galleries:
|
||||||
|
if (!!this.sortBy === false) { this.sortBy = "path"; }
|
||||||
|
this.sortByOptions = ["path"];
|
||||||
|
this.displayModeOptions = [
|
||||||
|
DisplayMode.List,
|
||||||
|
];
|
||||||
|
this.criterionOptions = [
|
||||||
|
new NoneCriterionOption(),
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case FilterMode.SceneMarkers:
|
||||||
|
if (!!this.sortBy === false) { this.sortBy = "title"; }
|
||||||
|
this.sortByOptions = ["title", "seconds", "scene_id", "random", "scenes_updated_at"];
|
||||||
|
this.displayModeOptions = [
|
||||||
|
DisplayMode.Wall,
|
||||||
|
];
|
||||||
|
this.criterionOptions = [
|
||||||
|
new NoneCriterionOption(),
|
||||||
|
new TagsCriterionOption(),
|
||||||
|
new SceneTagsCriterionOption(),
|
||||||
|
new PerformersCriterionOption(),
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.sortByOptions = [];
|
||||||
|
this.displayModeOptions = [];
|
||||||
|
this.criterionOptions = [
|
||||||
|
new NoneCriterionOption(),
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!!this.displayMode === false) { this.displayMode = this.displayModeOptions[0]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public configureFromQueryParameters(rawParms: any) {
|
||||||
|
const params = rawParms as IQueryParameters;
|
||||||
|
if (params.sortby !== undefined) {
|
||||||
|
this.sortBy = params.sortby;
|
||||||
|
}
|
||||||
|
if (params.sortdir === "asc" || params.sortdir === "desc") {
|
||||||
|
this.sortDirection = params.sortdir;
|
||||||
|
}
|
||||||
|
if (params.disp !== undefined) {
|
||||||
|
this.displayMode = parseInt(params.disp, 10);
|
||||||
|
}
|
||||||
|
if (params.q !== undefined) {
|
||||||
|
this.searchTerm = params.q;
|
||||||
|
}
|
||||||
|
if (params.p !== undefined) {
|
||||||
|
this.currentPage = Number(params.p);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.c !== undefined) {
|
||||||
|
this.criteria = [];
|
||||||
|
|
||||||
|
let jsonParameters: any[];
|
||||||
|
if (params.c instanceof Array) {
|
||||||
|
jsonParameters = params.c;
|
||||||
|
} else {
|
||||||
|
jsonParameters = [params.c];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const jsonString of jsonParameters) {
|
||||||
|
const encodedCriterion = JSON.parse(jsonString);
|
||||||
|
const criterion = makeCriteria(encodedCriterion.type);
|
||||||
|
criterion.value = encodedCriterion.value;
|
||||||
|
this.criteria.push(criterion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public makeQueryParameters(): string {
|
||||||
|
const encodedCriteria: string[] = [];
|
||||||
|
this.criteria.forEach((criterion) => {
|
||||||
|
const encodedCriterion: any = {};
|
||||||
|
encodedCriterion.type = criterion.type;
|
||||||
|
encodedCriterion.value = criterion.value;
|
||||||
|
const jsonCriterion = JSON.stringify(encodedCriterion);
|
||||||
|
encodedCriteria.push(jsonCriterion);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
sortby: this.sortBy,
|
||||||
|
sortdir: this.sortDirection,
|
||||||
|
disp: this.displayMode,
|
||||||
|
q: this.searchTerm,
|
||||||
|
p: this.currentPage,
|
||||||
|
c: encodedCriteria,
|
||||||
|
};
|
||||||
|
return queryString.stringify(result, {encode: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: These don't support multiple of the same criteria, only the last one set is used.
|
||||||
|
|
||||||
|
public makeFindFilter(): FindFilterType {
|
||||||
|
return {
|
||||||
|
q: this.searchTerm,
|
||||||
|
page: this.currentPage,
|
||||||
|
per_page: this.itemsPerPage,
|
||||||
|
sort: this.sortBy,
|
||||||
|
direction: this.sortDirection === "asc" ? SortDirectionEnum.Asc : SortDirectionEnum.Desc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public makeSceneFilter(): SceneFilterType {
|
||||||
|
const result: SceneFilterType = {};
|
||||||
|
this.criteria.forEach((criterion) => {
|
||||||
|
switch (criterion.type) {
|
||||||
|
case "rating":
|
||||||
|
result.rating = (criterion as RatingCriterion).value;
|
||||||
|
break;
|
||||||
|
case "resolution": {
|
||||||
|
switch ((criterion as ResolutionCriterion).value) {
|
||||||
|
case "240p": result.resolution = ResolutionEnum.Low; break;
|
||||||
|
case "480p": result.resolution = ResolutionEnum.Standard; break;
|
||||||
|
case "720p": result.resolution = ResolutionEnum.StandardHd; break;
|
||||||
|
case "1080p": result.resolution = ResolutionEnum.FullHd; break;
|
||||||
|
case "4k": result.resolution = ResolutionEnum.FourK; break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "hasMarkers":
|
||||||
|
result.has_markers = (criterion as HasMarkersCriterion).value;
|
||||||
|
break;
|
||||||
|
case "isMissing":
|
||||||
|
result.is_missing = (criterion as IsMissingCriterion).value;
|
||||||
|
break;
|
||||||
|
case "tags":
|
||||||
|
result.tags = (criterion as TagsCriterion).value.map((tag) => tag.id);
|
||||||
|
break;
|
||||||
|
case "performers":
|
||||||
|
result.performer_id = (criterion as PerformersCriterion).value[0].id; // TODO: Allow multiple
|
||||||
|
break;
|
||||||
|
case "studios":
|
||||||
|
result.studio_id = (criterion as StudiosCriterion).value[0].id; // TODO: Allow multiple
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public makePerformerFilter(): PerformerFilterType {
|
||||||
|
const result: PerformerFilterType = {};
|
||||||
|
this.criteria.forEach((criterion) => {
|
||||||
|
switch (criterion.type) {
|
||||||
|
case "favorite":
|
||||||
|
result.filter_favorites = (criterion as FavoriteCriterion).value === "true";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public makeSceneMarkerFilter(): SceneMarkerFilterType {
|
||||||
|
const result: SceneMarkerFilterType = {};
|
||||||
|
this.criteria.forEach((criterion) => {
|
||||||
|
switch (criterion.type) {
|
||||||
|
case "tags":
|
||||||
|
result.tags = (criterion as TagsCriterion).value.map((tag) => tag.id);
|
||||||
|
break;
|
||||||
|
case "sceneTags":
|
||||||
|
result.scene_tags = (criterion as TagsCriterion).value.map((tag) => tag.id);
|
||||||
|
break;
|
||||||
|
case "performers":
|
||||||
|
result.performers = (criterion as PerformersCriterion).value.map((performer) => performer.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
ui/v2/src/models/list-filter/types.ts
Normal file
18
ui/v2/src/models/list-filter/types.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export enum DisplayMode {
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
Wall,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum FilterMode {
|
||||||
|
Scenes,
|
||||||
|
Performers,
|
||||||
|
Studios,
|
||||||
|
Galleries,
|
||||||
|
SceneMarkers,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILabeledId {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
5
ui/v2/src/models/react-images.d.ts
vendored
Normal file
5
ui/v2/src/models/react-images.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
declare module "react-images" {
|
||||||
|
// typing module default export as `any` will allow you to access its members without compiler warning
|
||||||
|
var Lightbox: any;
|
||||||
|
export default Lightbox;
|
||||||
|
}
|
||||||
5
ui/v2/src/models/react-jw-player.d.ts
vendored
Normal file
5
ui/v2/src/models/react-jw-player.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
declare module "react-jw-player" {
|
||||||
|
// typing module default export as `any` will allow you to access its members without compiler warning
|
||||||
|
var ReactJSPlayer: any;
|
||||||
|
export default ReactJSPlayer;
|
||||||
|
}
|
||||||
1
ui/v2/src/models/types.ts
Normal file
1
ui/v2/src/models/types.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type HTMLInputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
1
ui/v2/src/react-app-env.d.ts
vendored
Normal file
1
ui/v2/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="react-scripts" />
|
||||||
143
ui/v2/src/serviceWorker.ts
Executable file
143
ui/v2/src/serviceWorker.ts
Executable file
@@ -0,0 +1,143 @@
|
|||||||
|
// This optional code is used to register a service worker.
|
||||||
|
// register() is not called by default.
|
||||||
|
|
||||||
|
// This lets the app load faster on subsequent visits in production, and gives
|
||||||
|
// it offline capabilities. However, it also means that developers (and users)
|
||||||
|
// will only see deployed updates on subsequent visits to a page, after all the
|
||||||
|
// existing tabs open on the page have been closed, since previously cached
|
||||||
|
// resources are updated in the background.
|
||||||
|
|
||||||
|
// To learn more about the benefits of this model and instructions on how to
|
||||||
|
// opt-in, read http://bit.ly/CRA-PWA
|
||||||
|
|
||||||
|
const isLocalhost = Boolean(
|
||||||
|
window.location.hostname === "localhost" ||
|
||||||
|
// [::1] is the IPv6 localhost address.
|
||||||
|
window.location.hostname === "[::1]" ||
|
||||||
|
// 127.0.0.1/8 is considered localhost for IPv4.
|
||||||
|
window.location.hostname.match(
|
||||||
|
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||||
|
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(config?: Config) {
|
||||||
|
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
|
||||||
|
// The URL constructor is available in all browsers that support SW.
|
||||||
|
const publicUrl = new URL(
|
||||||
|
(process as { env: { [key: string]: string } }).env.PUBLIC_URL,
|
||||||
|
window.location.href,
|
||||||
|
);
|
||||||
|
if (publicUrl.origin !== window.location.origin) {
|
||||||
|
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||||
|
// from what our page is served on. This might happen if a CDN is used to
|
||||||
|
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||||
|
|
||||||
|
if (isLocalhost) {
|
||||||
|
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||||
|
checkValidServiceWorker(swUrl, config);
|
||||||
|
|
||||||
|
// Add some additional logging to localhost, pointing developers to the
|
||||||
|
// service worker/PWA documentation.
|
||||||
|
navigator.serviceWorker.ready.then(() => {
|
||||||
|
console.log(
|
||||||
|
"This web app is being served cache-first by a service " +
|
||||||
|
"worker. To learn more, visit http://bit.ly/CRA-PWA",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Is not localhost. Just register service worker
|
||||||
|
registerValidSW(swUrl, config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerValidSW(swUrl: string, config?: Config) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register(swUrl)
|
||||||
|
.then((registration) => {
|
||||||
|
registration.onupdatefound = () => {
|
||||||
|
const installingWorker = registration.installing;
|
||||||
|
if (installingWorker == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
installingWorker.onstatechange = () => {
|
||||||
|
if (installingWorker.state === "installed") {
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
// At this point, the updated precached content has been fetched,
|
||||||
|
// but the previous service worker will still serve the older
|
||||||
|
// content until all client tabs are closed.
|
||||||
|
console.log(
|
||||||
|
"New content is available and will be used when all " +
|
||||||
|
"tabs for this page are closed. See http://bit.ly/CRA-PWA.",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute callback
|
||||||
|
if (config && config.onUpdate) {
|
||||||
|
config.onUpdate(registration);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// At this point, everything has been precached.
|
||||||
|
// It's the perfect time to display a
|
||||||
|
// "Content is cached for offline use." message.
|
||||||
|
console.log("Content is cached for offline use.");
|
||||||
|
|
||||||
|
// Execute callback
|
||||||
|
if (config && config.onSuccess) {
|
||||||
|
config.onSuccess(registration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error during service worker registration:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkValidServiceWorker(swUrl: string, config?: Config) {
|
||||||
|
// Check if the service worker can be found. If it can't reload the page.
|
||||||
|
fetch(swUrl)
|
||||||
|
.then((response) => {
|
||||||
|
// Ensure service worker exists, and that we really are getting a JS file.
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
if (
|
||||||
|
response.status === 404 ||
|
||||||
|
(contentType != null && contentType.indexOf("javascript") === -1)
|
||||||
|
) {
|
||||||
|
// No service worker found. Probably a different app. Reload the page.
|
||||||
|
navigator.serviceWorker.ready.then((registration) => {
|
||||||
|
registration.unregister().then(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Service worker found. Proceed as normal.
|
||||||
|
registerValidSW(swUrl, config);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.log(
|
||||||
|
"No internet connection found. App is running in offline mode.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregister() {
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
navigator.serviceWorker.ready.then((registration) => {
|
||||||
|
registration.unregister();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
26
ui/v2/src/styles/_blueprint-overrides.scss
Normal file
26
ui/v2/src/styles/_blueprint-overrides.scss
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
.bp3-popover-content {
|
||||||
|
padding: 10px;
|
||||||
|
max-width: 33vw;
|
||||||
|
min-width: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp3-select-popover .bp3-menu {
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp3-multi-select-popover .bp3-menu {
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-container .bp3-portal {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
67
ui/v2/src/styles/_scrollbars.scss
Normal file
67
ui/v2/src/styles/_scrollbars.scss
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/* scrollbars from semantic-ui */
|
||||||
|
|
||||||
|
/* Site */
|
||||||
|
|
||||||
|
::-webkit-selection {
|
||||||
|
background-color: #CCE2FF;
|
||||||
|
color: rgba(0, 0, 0, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-moz-selection {
|
||||||
|
background-color: #CCE2FF;
|
||||||
|
color: rgba(0, 0, 0, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: #CCE2FF;
|
||||||
|
color: rgba(0, 0, 0, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
|
||||||
|
textarea::-webkit-selection,
|
||||||
|
input::-webkit-selection {
|
||||||
|
background-color: rgba(100, 100, 100, 0.4);
|
||||||
|
color: rgba(0, 0, 0, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea::-moz-selection,
|
||||||
|
input::-moz-selection {
|
||||||
|
background-color: rgba(100, 100, 100, 0.4);
|
||||||
|
color: rgba(0, 0, 0, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea::selection,
|
||||||
|
input::selection {
|
||||||
|
background-color: rgba(100, 100, 100, 0.4);
|
||||||
|
color: rgba(0, 0, 0, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force Simple Scrollbars */
|
||||||
|
|
||||||
|
body ::-webkit-scrollbar {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body ::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body ::-webkit-scrollbar-thumb {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
-webkit-transition: color 0.2s ease;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body ::-webkit-scrollbar-thumb:window-inactive {
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
body ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(128, 135, 139, 0.8);
|
||||||
|
}
|
||||||
27
ui/v2/src/styles/form/_grid.scss
Normal file
27
ui/v2/src/styles/form/_grid.scss
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
.bp3-form-group .bp3-popover-target {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp3-html-table, .form-container {
|
||||||
|
& .bp3-popover-target, & textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
& textarea {
|
||||||
|
min-height: 250px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form .columns.is-gapless > .column {
|
||||||
|
margin: 5px 0;
|
||||||
|
padding: 0 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
form .columns {
|
||||||
|
margin-bottom: 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
form .buttons-container button {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
56
ui/v2/src/styles/shared/_details.scss
Normal file
56
ui/v2/src/styles/shared/_details.scss
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
.details-image-container {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 50px); // 50px for navbar
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
& img.performer {
|
||||||
|
height: 98%;
|
||||||
|
max-width: 98%;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0px 0px 10px black;
|
||||||
|
}
|
||||||
|
|
||||||
|
& img.studio {
|
||||||
|
height: 50%;
|
||||||
|
max-width: 50%;
|
||||||
|
object-fit: contain;
|
||||||
|
box-shadow: 0px 0px 10px black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-detail-container {
|
||||||
|
padding: 10px !important;
|
||||||
|
|
||||||
|
& .bp3-navbar {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
& .bp3-button {
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .bp3-file-input {
|
||||||
|
width: 190px;
|
||||||
|
|
||||||
|
& input {
|
||||||
|
min-width: unset;
|
||||||
|
max-width: 190px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .bp3-button.favorite .bp3-icon {
|
||||||
|
color: #ff7373 !important
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
& .bp3-popover-target {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
ui/v2/src/utils/color.ts
Normal file
12
ui/v2/src/utils/color.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export class ColorUtils {
|
||||||
|
public static classForRating(rating: number): string {
|
||||||
|
switch (rating) {
|
||||||
|
case 5: return "rating-5";
|
||||||
|
case 4: return "rating-4";
|
||||||
|
case 3: return "rating-3";
|
||||||
|
case 2: return "rating-2";
|
||||||
|
case 1: return "rating-1";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
ui/v2/src/utils/errors.ts
Normal file
24
ui/v2/src/utils/errors.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Intent, Position, Toaster } from "@blueprintjs/core";
|
||||||
|
import { ApolloError } from "apollo-boost";
|
||||||
|
|
||||||
|
const toaster = Toaster.create({
|
||||||
|
position: Position.TOP,
|
||||||
|
});
|
||||||
|
|
||||||
|
export class ErrorUtils {
|
||||||
|
public static handle(error: any) {
|
||||||
|
console.error(error);
|
||||||
|
toaster.show({
|
||||||
|
message: error.toString(),
|
||||||
|
intent: Intent.DANGER,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static handleApolloError(error: ApolloError) {
|
||||||
|
console.error(error);
|
||||||
|
toaster.show({
|
||||||
|
message: error.message,
|
||||||
|
intent: Intent.DANGER,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
44
ui/v2/src/utils/navigation.ts
Normal file
44
ui/v2/src/utils/navigation.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import * as GQL from "../core/generated-graphql";
|
||||||
|
import { PerformersCriterion } from "../models/list-filter/criteria/performers";
|
||||||
|
import { StudiosCriterion } from "../models/list-filter/criteria/studios";
|
||||||
|
import { TagsCriterion } from "../models/list-filter/criteria/tags";
|
||||||
|
import { ListFilterModel } from "../models/list-filter/filter";
|
||||||
|
import { FilterMode } from "../models/list-filter/types";
|
||||||
|
|
||||||
|
export class NavigationUtils {
|
||||||
|
public static makePerformerScenesUrl(performer: Partial<GQL.PerformerDataFragment>): string {
|
||||||
|
if (performer.id === undefined) { return "#"; }
|
||||||
|
const filter = new ListFilterModel(FilterMode.Scenes);
|
||||||
|
const criterion = new PerformersCriterion();
|
||||||
|
criterion.value = [{ id: performer.id, label: performer.name || `Performer ${performer.id}` }];
|
||||||
|
filter.criteria.push(criterion);
|
||||||
|
return `/scenes?${filter.makeQueryParameters()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static makeStudioScenesUrl(studio: Partial<GQL.StudioDataFragment>): string {
|
||||||
|
if (studio.id === undefined) { return "#"; }
|
||||||
|
const filter = new ListFilterModel(FilterMode.Scenes);
|
||||||
|
const criterion = new StudiosCriterion();
|
||||||
|
criterion.value = [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }];
|
||||||
|
filter.criteria.push(criterion);
|
||||||
|
return `/scenes?${filter.makeQueryParameters()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static makeTagScenesUrl(tag: Partial<GQL.TagDataFragment>): string {
|
||||||
|
if (tag.id === undefined) { return "#"; }
|
||||||
|
const filter = new ListFilterModel(FilterMode.Scenes);
|
||||||
|
const criterion = new TagsCriterion("tags");
|
||||||
|
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
|
||||||
|
filter.criteria.push(criterion);
|
||||||
|
return `/scenes?${filter.makeQueryParameters()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static makeTagSceneMarkersUrl(tag: Partial<GQL.TagDataFragment>): string {
|
||||||
|
if (tag.id === undefined) { return "#"; }
|
||||||
|
const filter = new ListFilterModel(FilterMode.SceneMarkers);
|
||||||
|
const criterion = new TagsCriterion("tags");
|
||||||
|
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
|
||||||
|
filter.criteria.push(criterion);
|
||||||
|
return `/scenes/markers?${filter.makeQueryParameters()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
164
ui/v2/src/utils/table.tsx
Normal file
164
ui/v2/src/utils/table.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { EditableText, HTMLSelect, InputGroup, IOptionProps, TextArea } from "@blueprintjs/core";
|
||||||
|
import React from "react";
|
||||||
|
import { FilterMultiSelect } from "../components/select/FilterMultiSelect";
|
||||||
|
import { FilterSelect } from "../components/select/FilterSelect";
|
||||||
|
|
||||||
|
export class TableUtils {
|
||||||
|
public static renderEditableTextTableRow(options: {
|
||||||
|
title: string;
|
||||||
|
value: string | number | undefined;
|
||||||
|
isEditing: boolean;
|
||||||
|
onChange: ((value: string) => void);
|
||||||
|
}) {
|
||||||
|
let stringValue = options.value;
|
||||||
|
if (typeof stringValue === "number") {
|
||||||
|
stringValue = stringValue.toString();
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>{options.title}</td>
|
||||||
|
<td>
|
||||||
|
<EditableText
|
||||||
|
disabled={!options.isEditing}
|
||||||
|
value={stringValue}
|
||||||
|
placeholder={options.title}
|
||||||
|
multiline={true}
|
||||||
|
onChange={(newValue) => options.onChange(newValue)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static renderTextArea(options: {
|
||||||
|
title: string,
|
||||||
|
value: string | undefined,
|
||||||
|
isEditing: boolean,
|
||||||
|
onChange: ((value: string) => void),
|
||||||
|
}) {
|
||||||
|
let element: JSX.Element;
|
||||||
|
if (options.isEditing) {
|
||||||
|
element = (
|
||||||
|
<TextArea
|
||||||
|
fill={true}
|
||||||
|
onChange={(newValue) => options.onChange(newValue.target.value)}
|
||||||
|
value={options.value}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
element = <p className="pre">{options.value}</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>{options.title}</td>
|
||||||
|
<td>
|
||||||
|
{element}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static renderInputGroup(options: {
|
||||||
|
title: string,
|
||||||
|
value: string | undefined,
|
||||||
|
isEditing: boolean,
|
||||||
|
onChange: ((value: string) => void),
|
||||||
|
}) {
|
||||||
|
let element: JSX.Element;
|
||||||
|
if (options.isEditing) {
|
||||||
|
element = (
|
||||||
|
<InputGroup
|
||||||
|
onChange={(newValue: any) => options.onChange(newValue.target.value)}
|
||||||
|
value={options.value}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
element = <span>{options.value}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>{options.title}</td>
|
||||||
|
<td>
|
||||||
|
{element}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static renderHtmlSelect(options: {
|
||||||
|
title: string,
|
||||||
|
value: string | number | undefined,
|
||||||
|
isEditing: boolean,
|
||||||
|
onChange: ((value: string) => void),
|
||||||
|
selectOptions: Array<string | number | IOptionProps>,
|
||||||
|
}) {
|
||||||
|
let stringValue = options.value;
|
||||||
|
if (typeof stringValue === "number") {
|
||||||
|
stringValue = stringValue.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
let element: JSX.Element;
|
||||||
|
if (options.isEditing) {
|
||||||
|
element = (
|
||||||
|
<HTMLSelect
|
||||||
|
options={options.selectOptions}
|
||||||
|
onChange={(event) => options.onChange(event.target.value)}
|
||||||
|
value={stringValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
element = <span>{options.value}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>{options.title}</td>
|
||||||
|
<td>
|
||||||
|
{element}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: isediting
|
||||||
|
public static renderFilterSelect(options: {
|
||||||
|
title: string,
|
||||||
|
type: "performers" | "studios" | "tags",
|
||||||
|
initialId: string | undefined,
|
||||||
|
onChange: ((id: string) => void),
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>{options.title}</td>
|
||||||
|
<td>
|
||||||
|
<FilterSelect
|
||||||
|
type={options.type}
|
||||||
|
onSelectItem={(item) => options.onChange(item.id)}
|
||||||
|
initialId={options.initialId}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: isediting
|
||||||
|
public static renderMultiSelect(options: {
|
||||||
|
title: string,
|
||||||
|
type: "performers" | "studios" | "tags",
|
||||||
|
initialIds: string[] | undefined,
|
||||||
|
onChange: ((ids: string[]) => void),
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>{options.title}</td>
|
||||||
|
<td>
|
||||||
|
<FilterMultiSelect
|
||||||
|
type={options.type}
|
||||||
|
onUpdate={(items) => options.onChange(items.map((i) => i.id))}
|
||||||
|
openOnKeyDown={true}
|
||||||
|
initialIds={options.initialIds}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
ui/v2/src/utils/text.ts
Normal file
57
ui/v2/src/utils/text.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export class TextUtils {
|
||||||
|
|
||||||
|
public static truncate(value?: string, limit: number = 100, tail: string = "..."): string {
|
||||||
|
if (!value) { return ""; }
|
||||||
|
return value.length > limit ? value.substring(0, limit) + tail : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static fileSize(bytes: number = 0, precision: number = 2): string {
|
||||||
|
if (isNaN(parseFloat(String(bytes))) || !isFinite(bytes)) { return "?"; }
|
||||||
|
|
||||||
|
let unit = 0;
|
||||||
|
while ( bytes >= 1024 ) {
|
||||||
|
bytes /= 1024;
|
||||||
|
unit++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.toFixed(+precision) + " " + this.units[unit];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static secondsToTimestamp(seconds: number): string {
|
||||||
|
return new Date(seconds * 1000).toISOString().substr(11, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static fileNameFromPath(path: string): string {
|
||||||
|
if (!!path === false) { return "No File Name"; }
|
||||||
|
return path.replace(/^.*[\\\/]/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static age(dateString?: string, fromDateString?: string): number {
|
||||||
|
if (!dateString) { return 0; }
|
||||||
|
|
||||||
|
const birthdate = new Date(dateString);
|
||||||
|
const fromDate = !!fromDateString ? new Date(fromDateString) : new Date();
|
||||||
|
|
||||||
|
let age = fromDate.getFullYear() - birthdate.getFullYear();
|
||||||
|
if (birthdate.getMonth() > fromDate.getMonth() ||
|
||||||
|
(birthdate.getMonth() >= fromDate.getMonth() && birthdate.getDay() > fromDate.getDay())) {
|
||||||
|
age -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bitRate(bitrate: number) {
|
||||||
|
const megabits = bitrate / 1000000;
|
||||||
|
return `${megabits.toFixed(2)} megabits per second`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static units = [
|
||||||
|
"bytes",
|
||||||
|
"kB",
|
||||||
|
"MB",
|
||||||
|
"GB",
|
||||||
|
"TB",
|
||||||
|
"PB",
|
||||||
|
];
|
||||||
|
}
|
||||||
14
ui/v2/src/utils/toasts.ts
Normal file
14
ui/v2/src/utils/toasts.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Intent, Position, Toaster } from "@blueprintjs/core";
|
||||||
|
|
||||||
|
const toaster = Toaster.create({
|
||||||
|
position: Position.TOP,
|
||||||
|
});
|
||||||
|
|
||||||
|
export class ToastUtils {
|
||||||
|
public static success(message: string) {
|
||||||
|
toaster.show({
|
||||||
|
message,
|
||||||
|
intent: Intent.SUCCESS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
28
ui/v2/tsconfig.json
Normal file
28
ui/v2/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
|
||||||
|
"downlevelIteration": true,
|
||||||
|
"experimentalDecorators": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user