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:
Stash Dev
2019-02-15 09:15:00 -08:00
parent c4d45db30c
commit 66d2c5ca04
102 changed files with 20432 additions and 29 deletions

View File

@@ -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 {

View File

@@ -0,0 +1,5 @@
fragment SlimStudioData on Studio {
id
name
image_path
}

View File

@@ -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
View File

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

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

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

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

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

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

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

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

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

17
ui/v2/codegen.yml Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

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

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, 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
View 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
View 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>
);
}
}

View File

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

View File

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

View File

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

View 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;
};

View File

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

View File

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

View File

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

View File

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

View 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
</>
);
};

View 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
</>
);
};

View 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
</>
);
};

View 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>
</>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
};

View File

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

View File

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

View 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();
};

View File

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

View File

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

View 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>
</>
);
};

View 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;
};

View File

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

View File

@@ -0,0 +1,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)) : ""}
&nbsp;|&nbsp;
{props.scene.file.duration !== undefined ? TextUtils.secondsToTimestamp(props.scene.file.duration) : ""}
&nbsp;|&nbsp;
{props.scene.file.width} x {props.scene.file.height}
</span>
{SceneHelpers.maybeRenderStudio(props.scene, 50, true)}
</Card>
);
};

View 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>
</>
);
};

View File

@@ -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()}
</>
);
};

View 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()}/>
</>
);
};

View 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>
</>
);
};

View 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();
};

View File

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

View File

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

View 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;
};

View 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();
}
}

View File

@@ -0,0 +1,128 @@
.scrubber-wrapper {
position: relative;
overflow: hidden;
margin: 5px 0;
}
#scrubber-back {
float: left;
}
#scrubber-forward {
float: right;
}
.scrubber-button {
width: 1.5%;
height: 100%;
line-height: 120px;
padding: 0;
text-align: center;
border: 1px solid #555;
font-weight: 800;
font-size: 20px;
color: #FFF;
cursor: pointer;
}
.scrubber-content {
-webkit-user-select: none;
-webkit-overflow-scrolling: touch;
cursor: -webkit-grab;
height: 120px;
width: 96%;
margin: 0 0.5%;
display: inline-block;
position: relative;
overflow: hidden;
}
.scrubber-content.dragging {
cursor: -webkit-grabbing;
}
.scrubber-tags-background {
background-color: #555;
position: absolute;
left: 0;
right: 0;
height: 20px;
}
#scrubber-position-indicator {
background-color: #CCC;
width: 100%;
left: -100%;
height: 20px;
z-index: 0;
position: absolute;
}
#scrubber-current-position {
background-color: #FFF;
width: 2px;
height: 30px;
left: 50%;
z-index: 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%;
}

View File

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

View File

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

View File

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

View File

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

View 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>
);
};

View 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"}}
/>
);
};

View File

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

View File

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

View 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() {}
}

File diff suppressed because it is too large Load Diff

View 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 };
}
}

View 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
View 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
View File

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

View File

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

View File

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

View File

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

View 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";
}

View 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";
}

View 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";
}

View 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";
}

View 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";
}

View 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";
}

View 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";
}

View 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";
}

View 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";
}

View 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();
}
}

View 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;
}
}

View 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
View 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
View 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;
}

View File

@@ -0,0 +1 @@
export type HTMLInputProps = React.InputHTMLAttributes<HTMLInputElement>;

1
ui/v2/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

143
ui/v2/src/serviceWorker.ts Executable file
View 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();
});
}
}

View 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;
}

View 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);
}

View 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;
}

View 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
View 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
View 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,
});
}
}

View 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
View 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
View 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
View 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
View 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