This commit is contained in:
Infinite
2020-01-20 21:56:23 +01:00
parent 9827647122
commit 244c8ff234
82 changed files with 3729 additions and 2304 deletions

View File

@@ -3,7 +3,9 @@
"parserOptions": { "parserOptions": {
"project": "./tsconfig.json" "project": "./tsconfig.json"
}, },
"plugins": ["@typescript-eslint"], "plugins": [
"@typescript-eslint"
],
"extends": [ "extends": [
"airbnb-typescript", "airbnb-typescript",
"prettier", "prettier",

View File

@@ -41,6 +41,7 @@
"eject": "react-scripts eject", "eject": "react-scripts eject",
"lint": "eslint --cache src/**/*.{ts,tsx}", "lint": "eslint --cache src/**/*.{ts,tsx}",
"lint:fix": "eslint --fix src/**/*.{ts,tsx}", "lint:fix": "eslint --fix src/**/*.{ts,tsx}",
"format": "prettier \"src/**/*.{js,jsx,ts,tsx}\"",
"gqlgen": "gql-gen --config codegen.yml" "gqlgen": "gql-gen --config codegen.yml"
}, },
"browserslist": [ "browserslist": [
@@ -68,14 +69,13 @@
"eslint-config-airbnb-typescript": "^6.3.1", "eslint-config-airbnb-typescript": "^6.3.1",
"eslint-config-prettier": "^6.9.0", "eslint-config-prettier": "^6.9.0",
"eslint-plugin-jsx-a11y": "^6.2.3", "eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-prettier": "^3.1.2",
"graphql-code-generator": "0.18.2", "graphql-code-generator": "0.18.2",
"graphql-codegen-add": "0.18.2", "graphql-codegen-add": "0.18.2",
"graphql-codegen-time": "0.18.2", "graphql-codegen-time": "0.18.2",
"graphql-codegen-typescript-client": "0.18.2", "graphql-codegen-typescript-client": "0.18.2",
"graphql-codegen-typescript-common": "0.18.2", "graphql-codegen-typescript-common": "0.18.2",
"graphql-codegen-typescript-react-apollo": "0.18.2", "graphql-codegen-typescript-react-apollo": "0.18.2",
"prettier": "^1.19.1", "prettier": "1.19.1",
"typescript": "~3.7.4" "typescript": "~3.7.4"
} }
} }

View File

@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import { ToastProvider } from 'src/hooks/Toast'; import { ToastProvider } from "src/hooks/Toast";
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from "@fortawesome/fontawesome-svg-core";
import { fas } from '@fortawesome/free-solid-svg-icons' import { fas } from "@fortawesome/free-solid-svg-icons";
import { ErrorBoundary } from "./components/ErrorBoundary"; import { ErrorBoundary } from "./components/ErrorBoundary";
import Galleries from "./components/Galleries/Galleries"; import Galleries from "./components/Galleries/Galleries";
import { MainNavbar } from "./components/MainNavbar"; import { MainNavbar } from "./components/MainNavbar";
@@ -15,8 +15,7 @@ import Studios from "./components/Studios/Studios";
import Tags from "./components/Tags/Tags"; import Tags from "./components/Tags/Tags";
import { SceneFilenameParser } from "./components/scenes/SceneFilenameParser"; import { SceneFilenameParser } from "./components/scenes/SceneFilenameParser";
import "bootstrap/dist/css/bootstrap.min.css";
import 'bootstrap/dist/css/bootstrap.min.css';
library.add(fas); library.add(fas);
@@ -35,7 +34,10 @@ export const App: React.FC = () => (
<Route path="/tags" component={Tags} /> <Route path="/tags" component={Tags} />
<Route path="/studios" component={Studios} /> <Route path="/studios" component={Studios} />
<Route path="/settings" component={Settings} /> <Route path="/settings" component={Settings} />
<Route path="/sceneFilenameParser" component={SceneFilenameParser} /> <Route
path="/sceneFilenameParser"
component={SceneFilenameParser}
/>
<Route component={PageNotFound} /> <Route component={PageNotFound} />
</Switch> </Switch>
</div> </div>

View File

@@ -9,7 +9,7 @@ export class ErrorBoundary extends React.Component<any, any> {
public componentDidCatch(error: any, errorInfo: any) { public componentDidCatch(error: any, errorInfo: any) {
this.setState({ this.setState({
error, error,
errorInfo, errorInfo
}); });
} }

View File

@@ -1,22 +1,21 @@
import React from "react"; import React from "react";
import { Spinner } from 'react-bootstrap'; import { Spinner } from "react-bootstrap";
import { useParams } from 'react-router-dom'; import { useParams } from "react-router-dom";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { GalleryViewer } from "./GalleryViewer"; import { GalleryViewer } from "./GalleryViewer";
export const Gallery: React.FC = () => { export const Gallery: React.FC = () => {
const { id = '' } = useParams(); const { id = "" } = useParams();
const { data, error, loading } = StashService.useFindGallery(id); const { data, error, loading } = StashService.useFindGallery(id);
const gallery = data?.findGallery; const gallery = data?.findGallery;
if (loading || !gallery) if (loading || !gallery)
return <Spinner animation="border" variant="light" />; return <Spinner animation="border" variant="light" />;
if (error) if (error) return <div>{error.message}</div>;
return <div>{error.message}</div>;
return ( return (
<div style={{width: "75vw", margin: "0 auto"}}> <div style={{ width: "75vw", margin: "0 auto" }}>
<GalleryViewer gallery={gallery as any} /> <GalleryViewer gallery={gallery as any} />
</div> </div>
); );

View File

@@ -1,24 +1,33 @@
import React from "react"; import React from "react";
import { Table } from 'react-bootstrap'; import { Table } from "react-bootstrap";
import { QueryHookResult } from "react-apollo-hooks"; import { QueryHookResult } from "react-apollo-hooks";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { FindGalleriesQuery, FindGalleriesVariables } from "src/core/generated-graphql"; import {
FindGalleriesQuery,
FindGalleriesVariables
} from "src/core/generated-graphql";
import { useGalleriesList } from "src/hooks"; import { useGalleriesList } from "src/hooks";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
export const GalleryList: React.FC = () => { export const GalleryList: React.FC = () => {
const listData = useGalleriesList({ const listData = useGalleriesList({
renderContent, renderContent
}); });
function renderContent(result: QueryHookResult<FindGalleriesQuery, FindGalleriesVariables>, filter: ListFilterModel) { function renderContent(
if (!result.data || !result.data.findGalleries) { return; } result: QueryHookResult<FindGalleriesQuery, FindGalleriesVariables>,
filter: ListFilterModel
) {
if (!result.data || !result.data.findGalleries) {
return;
}
if (filter.displayMode === DisplayMode.Grid) { if (filter.displayMode === DisplayMode.Grid) {
return <h1>TODO</h1>; return <h1>TODO</h1>;
} if (filter.displayMode === DisplayMode.List) { }
if (filter.displayMode === DisplayMode.List) {
return ( return (
<Table style={{margin: "0 auto"}}> <Table style={{ margin: "0 auto" }}>
<thead> <thead>
<tr> <tr>
<th>Preview</th> <th>Preview</th>
@@ -26,20 +35,27 @@ export const GalleryList: React.FC = () => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{result.data.findGalleries.galleries.map((gallery) => ( {result.data.findGalleries.galleries.map(gallery => (
<tr key={gallery.id}> <tr key={gallery.id}>
<td> <td>
<Link to={`/galleries/${gallery.id}`}> <Link to={`/galleries/${gallery.id}`}>
{gallery.files.length > 0 ? <img alt="" src={`${gallery.files[0].path}?thumb=true`} /> : undefined} {gallery.files.length > 0 ? (
<img alt="" src={`${gallery.files[0].path}?thumb=true`} />
) : (
undefined
)}
</Link> </Link>
</td> </td>
<td><Link to={`/galleries/${gallery.id}`}>{gallery.path}</Link></td> <td>
<Link to={`/galleries/${gallery.id}`}>{gallery.path}</Link>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</Table> </Table>
); );
} if (filter.displayMode === DisplayMode.Wall) { }
if (filter.displayMode === DisplayMode.Wall) {
return <h1>TODO</h1>; return <h1>TODO</h1>;
} }
} }

View File

@@ -11,7 +11,10 @@ export const GalleryViewer: FunctionComponent<IProps> = ({ gallery }) => {
const [currentImage, setCurrentImage] = useState<number>(0); const [currentImage, setCurrentImage] = useState<number>(0);
const [lightboxIsOpen, setLightboxIsOpen] = useState<boolean>(false); const [lightboxIsOpen, setLightboxIsOpen] = useState<boolean>(false);
function openLightbox(_event: React.MouseEvent<Element>, obj: {index: number}) { function openLightbox(
_event: React.MouseEvent<Element>,
obj: { index: number }
) {
setCurrentImage(obj.index); setCurrentImage(obj.index);
setLightboxIsOpen(true); setLightboxIsOpen(true);
} }
@@ -26,8 +29,15 @@ export const GalleryViewer: FunctionComponent<IProps> = ({ gallery }) => {
setCurrentImage(currentImage + 1); setCurrentImage(currentImage + 1);
} }
const photos = gallery.files.map((file) => ({src: file.path || "", caption: file.name})); const photos = gallery.files.map(file => ({
const thumbs = gallery.files.map((file) => ({src: `${file.path}?thumb=true` || "", width: 1, height: 1})); src: file.path || "",
caption: file.name
}));
const thumbs = gallery.files.map(file => ({
src: `${file.path}?thumb=true` || "",
width: 1,
height: 1
}));
return ( return (
<div> <div>
<Gallery photos={thumbs} columns={15} onClick={openLightbox} /> <Gallery photos={thumbs} columns={15} onClick={openLightbox} />

View File

@@ -1,72 +1,77 @@
import React from "react"; import React from "react";
import { Nav, Navbar, Button } from "react-bootstrap"; import { Nav, Navbar, Button } from "react-bootstrap";
import { IconName } from '@fortawesome/fontawesome-svg-core'; import { IconName } from "@fortawesome/fontawesome-svg-core";
import { LinkContainer } from 'react-router-bootstrap'; import { LinkContainer } from "react-router-bootstrap";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { Icon } from 'src/components/Shared' import { Icon } from "src/components/Shared";
interface IMenuItem { interface IMenuItem {
text: string; text: string;
href: string; href: string;
icon: IconName; icon: IconName;
} }
const menuItems:IMenuItem[] = [ const menuItems: IMenuItem[] = [
{ {
icon: "play-circle", icon: "play-circle",
text: "Scenes", text: "Scenes",
href: "/scenes" href: "/scenes"
}, },
{ {
href: "/scenes/markers", href: "/scenes/markers",
icon: "map-marker-alt", icon: "map-marker-alt",
text: "Markers" text: "Markers"
}, },
{ {
href: "/galleries", href: "/galleries",
icon: "image", icon: "image",
text: "Galleries" text: "Galleries"
}, },
{ {
href: "/performers", href: "/performers",
icon: "user", icon: "user",
text: "Performers" text: "Performers"
}, },
{ {
href: "/studios", href: "/studios",
icon: "video", icon: "video",
text: "Studios" text: "Studios"
}, },
{ {
href: "/tags", href: "/tags",
icon: "tag", icon: "tag",
text: "Tags" text: "Tags"
} }
]; ];
export const MainNavbar: React.FC = () => { export const MainNavbar: React.FC = () => {
const location = useLocation(); const location = useLocation();
const path = location.pathname === '/performers' const path =
? '/performers/new' location.pathname === "/performers"
: location.pathname === '/studios' ? "/performers/new"
? '/studios/new' : null; : location.pathname === "/studios"
const newButton = path === null ? '' : ( ? "/studios/new"
<LinkContainer to={path}> : null;
<Button variant="primary">New</Button> const newButton =
</LinkContainer> path === null ? (
); ""
) : (
<LinkContainer to={path}>
<Button variant="primary">New</Button>
</LinkContainer>
);
return ( return (
<Navbar fixed="top" variant="dark" bg="dark"> <Navbar fixed="top" variant="dark" bg="dark">
<Navbar.Brand href="#home"> <Navbar.Brand href="#home">
<Link to="/"> <Link to="/">
<Button variant="secondary">Stash</Button> <Button variant="secondary">Stash</Button>
</Link> </Link>
</Navbar.Brand> </Navbar.Brand>
<Nav className="mr-auto"> <Nav className="mr-auto">
{menuItems.map((i) => ( {menuItems.map(i => (
<LinkContainer <LinkContainer
activeClassName="active" activeClassName="active"
exact exact
@@ -82,12 +87,10 @@ export const MainNavbar: React.FC = () => {
</Nav> </Nav>
<Nav> <Nav>
{newButton} {newButton}
<LinkContainer <LinkContainer exact to="/settings">
exact <Button variant="secondary">
to="/settings"> <Icon icon="cog" />
<Button variant="secondary"> </Button>
<Icon icon="cog" />
</Button>
</LinkContainer> </LinkContainer>
</Nav> </Nav>
</Navbar> </Navbar>

View File

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

View File

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

View File

@@ -1,12 +1,14 @@
import React from "react"; import React from "react";
import { Table, Spinner } from 'react-bootstrap'; import { Table, Spinner } from "react-bootstrap";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
export const SettingsAboutPanel: React.FC = () => { export const SettingsAboutPanel: React.FC = () => {
const { data, error, loading } = StashService.useVersion(); const { data, error, loading } = StashService.useVersion();
function maybeRenderTag() { function maybeRenderTag() {
if (!data || !data.version || !data.version.version) { return; } if (!data || !data.version || !data.version.version) {
return;
}
return ( return (
<tr> <tr>
<td>Version:</td> <td>Version:</td>
@@ -16,30 +18,32 @@ export const SettingsAboutPanel: React.FC = () => {
} }
function renderVersion() { function renderVersion() {
if (!data || !data.version) { return; } if (!data || !data.version) {
return;
}
return ( return (
<> <>
<Table> <Table>
<tbody> <tbody>
{maybeRenderTag()} {maybeRenderTag()}
<tr> <tr>
<td>Build hash:</td> <td>Build hash:</td>
<td>{data.version.hash}</td> <td>{data.version.hash}</td>
</tr> </tr>
<tr> <tr>
<td>Build time:</td> <td>Build time:</td>
<td>{data.version.build_time}</td> <td>{data.version.build_time}</td>
</tr> </tr>
</tbody> </tbody>
</Table> </Table>
</> </>
); );
} }
return ( return (
<> <>
<h4>About</h4> <h4>About</h4>
{!data || loading ? <Spinner animation="border" variant="light" /> : ''} {!data || loading ? <Spinner animation="border" variant="light" /> : ""}
{error ? <span>error.message</span> : ''} {error ? <span>error.message</span> : ""}
{renderVersion()} {renderVersion()}
</> </>
); );

View File

@@ -1,26 +1,34 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, Form, InputGroup, Spinner } from 'react-bootstrap'; import { Button, Form, InputGroup, Spinner } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { useToast } from 'src/hooks'; import { useToast } from "src/hooks";
import { Icon } from 'src/components/Shared'; import { Icon } from "src/components/Shared";
import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect"; import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect";
export const SettingsConfigurationPanel: React.FC = () => { export const SettingsConfigurationPanel: React.FC = () => {
const Toast = useToast(); const Toast = useToast();
// Editing config state // Editing config state
const [stashes, setStashes] = useState<string[]>([]); const [stashes, setStashes] = useState<string[]>([]);
const [databasePath, setDatabasePath] = useState<string | undefined>(undefined); const [databasePath, setDatabasePath] = useState<string | undefined>(
const [generatedPath, setGeneratedPath] = useState<string | undefined>(undefined); undefined
const [maxTranscodeSize, setMaxTranscodeSize] = useState<GQL.StreamingResolutionEnum | undefined>(undefined); );
const [maxStreamingTranscodeSize, setMaxStreamingTranscodeSize] = useState<GQL.StreamingResolutionEnum | undefined>(undefined); const [generatedPath, setGeneratedPath] = useState<string | undefined>(
undefined
);
const [maxTranscodeSize, setMaxTranscodeSize] = useState<
GQL.StreamingResolutionEnum | undefined
>(undefined);
const [maxStreamingTranscodeSize, setMaxStreamingTranscodeSize] = useState<
GQL.StreamingResolutionEnum | undefined
>(undefined);
const [username, setUsername] = useState<string | undefined>(undefined); const [username, setUsername] = useState<string | undefined>(undefined);
const [password, setPassword] = useState<string | undefined>(undefined); const [password, setPassword] = useState<string | undefined>(undefined);
const [logFile, setLogFile] = useState<string | undefined>(); const [logFile, setLogFile] = useState<string | undefined>();
const [logOut, setLogOut] = useState<boolean>(true); const [logOut, setLogOut] = useState<boolean>(true);
const [logLevel, setLogLevel] = useState<string>("Info"); const [logLevel, setLogLevel] = useState<string>("Info");
const [logAccess, setLogAccess] = useState<boolean>(true); const [logAccess, setLogAccess] = useState<boolean>(true);
const [excludes, setExcludes] = useState<(string)[]>([]); const [excludes, setExcludes] = useState<string[]>([]);
const { data, error, loading } = StashService.useConfiguration(); const { data, error, loading } = StashService.useConfiguration();
@@ -36,12 +44,11 @@ export const SettingsConfigurationPanel: React.FC = () => {
logOut, logOut,
logLevel, logLevel,
logAccess, logAccess,
excludes, excludes
}); });
useEffect(() => { useEffect(() => {
if (!data?.configuration || error) if (!data?.configuration || error) return;
return;
const conf = data.configuration; const conf = data.configuration;
if (conf.general) { if (conf.general) {
@@ -65,27 +72,26 @@ export const SettingsConfigurationPanel: React.FC = () => {
} }
function excludeRegexChanged(idx: number, value: string) { function excludeRegexChanged(idx: number, value: string) {
const newExcludes = excludes.map((regex, i)=> { const newExcludes = excludes.map((regex, i) => {
const ret = ( idx !== i ) ? regex : value ; const ret = idx !== i ? regex : value;
return ret return ret;
}) });
setExcludes(newExcludes); setExcludes(newExcludes);
} }
function excludeRemoveRegex(idx: number) { function excludeRemoveRegex(idx: number) {
const newExcludes = excludes.filter((_regex, i) => i !== idx ); const newExcludes = excludes.filter((_regex, i) => i !== idx);
setExcludes(newExcludes); setExcludes(newExcludes);
} }
function excludeAddRegex() { function excludeAddRegex() {
const demo = "sample\\.mp4$" const demo = "sample\\.mp4$";
const newExcludes = excludes.concat(demo); const newExcludes = excludes.concat(demo);
setExcludes(newExcludes); setExcludes(newExcludes);
} }
async function onSave() { async function onSave() {
try { try {
const result = await updateGeneralConfig(); const result = await updateGeneralConfig();
@@ -106,35 +112,46 @@ export const SettingsConfigurationPanel: React.FC = () => {
GQL.StreamingResolutionEnum.Original GQL.StreamingResolutionEnum.Original
].map(resolutionToString); ].map(resolutionToString);
function resolutionToString(r : GQL.StreamingResolutionEnum | undefined) { function resolutionToString(r: GQL.StreamingResolutionEnum | undefined) {
switch (r) { switch (r) {
case GQL.StreamingResolutionEnum.Low: return "240p"; case GQL.StreamingResolutionEnum.Low:
case GQL.StreamingResolutionEnum.Standard: return "480p"; return "240p";
case GQL.StreamingResolutionEnum.StandardHd: return "720p"; case GQL.StreamingResolutionEnum.Standard:
case GQL.StreamingResolutionEnum.FullHd: return "1080p"; return "480p";
case GQL.StreamingResolutionEnum.FourK: return "4k"; case GQL.StreamingResolutionEnum.StandardHd:
case GQL.StreamingResolutionEnum.Original: return "Original"; return "720p";
case GQL.StreamingResolutionEnum.FullHd:
return "1080p";
case GQL.StreamingResolutionEnum.FourK:
return "4k";
case GQL.StreamingResolutionEnum.Original:
return "Original";
} }
return "Original"; return "Original";
} }
function translateQuality(quality : string) { function translateQuality(quality: string) {
switch (quality) { switch (quality) {
case "240p": return GQL.StreamingResolutionEnum.Low; case "240p":
case "480p": return GQL.StreamingResolutionEnum.Standard; return GQL.StreamingResolutionEnum.Low;
case "720p": return GQL.StreamingResolutionEnum.StandardHd; case "480p":
case "1080p": return GQL.StreamingResolutionEnum.FullHd; return GQL.StreamingResolutionEnum.Standard;
case "4k": return GQL.StreamingResolutionEnum.FourK; case "720p":
case "Original": return GQL.StreamingResolutionEnum.Original; return GQL.StreamingResolutionEnum.StandardHd;
case "1080p":
return GQL.StreamingResolutionEnum.FullHd;
case "4k":
return GQL.StreamingResolutionEnum.FourK;
case "Original":
return GQL.StreamingResolutionEnum.Original;
} }
return GQL.StreamingResolutionEnum.Original; return GQL.StreamingResolutionEnum.Original;
} }
if(error) if (error) return <h1>{error.message}</h1>;
return <h1>{error.message}</h1>; if (!data?.configuration || loading)
if(!data?.configuration || loading)
return <Spinner animation="border" variant="light" />; return <Spinner animation="border" variant="light" />;
return ( return (
@@ -147,37 +164,56 @@ export const SettingsConfigurationPanel: React.FC = () => {
directories={stashes} directories={stashes}
onDirectoriesChanged={onStashesChanged} onDirectoriesChanged={onStashesChanged}
/> />
<Form.Text className="text-muted">Directory locations to your content</Form.Text> <Form.Text className="text-muted">
Directory locations to your content
</Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="database-path"> <Form.Group id="database-path">
<Form.Label>Database Path</Form.Label> <Form.Label>Database Path</Form.Label>
<Form.Control defaultValue={databasePath} onChange={(e: any) => setDatabasePath(e.target.value)} /> <Form.Control
<Form.Text className="text-muted">File location for the SQLite database (requires restart)</Form.Text> defaultValue={databasePath}
onChange={(e: any) => setDatabasePath(e.target.value)}
/>
<Form.Text className="text-muted">
File location for the SQLite database (requires restart)
</Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="generated-path"> <Form.Group id="generated-path">
<Form.Label>Generated Path</Form.Label> <Form.Label>Generated Path</Form.Label>
<Form.Control defaultValue={generatedPath} onChange={(e: any) => setGeneratedPath(e.target.value)} /> <Form.Control
<Form.Text className="text-muted">Directory location for the generated files (scene markers, scene previews, sprites, etc)</Form.Text> defaultValue={generatedPath}
onChange={(e: any) => setGeneratedPath(e.target.value)}
/>
<Form.Text className="text-muted">
Directory location for the generated files (scene markers, scene
previews, sprites, etc)
</Form.Text>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<Form.Label>Excluded Patterns</Form.Label> <Form.Label>Excluded Patterns</Form.Label>
{ excludes ? excludes.map((regexp, i) => ( {excludes
<InputGroup> ? excludes.map((regexp, i) => (
<Form.Control <InputGroup>
value={regexp} <Form.Control
onChange={(e: any) => excludeRegexChanged(i, e.target.value)} value={regexp}
/> onChange={(e: any) =>
<InputGroup.Append> excludeRegexChanged(i, e.target.value)
<Button variant="danger" onClick={() => excludeRemoveRegex(i)}> }
<Icon icon="minus" /> />
</Button> <InputGroup.Append>
</InputGroup.Append> <Button
</InputGroup> variant="danger"
)) : '' onClick={() => excludeRemoveRegex(i)}
} >
<Icon icon="minus" />
</Button>
</InputGroup.Append>
</InputGroup>
))
: ""}
<Button variant="danger" onClick={() => excludeAddRegex()}> <Button variant="danger" onClick={() => excludeAddRegex()}>
<Icon icon="plus" /> <Icon icon="plus" />
@@ -189,7 +225,9 @@ export const SettingsConfigurationPanel: React.FC = () => {
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<span>Regexps of files/paths to exclude from Scan and add to Clean</span> <span>
Regexps of files/paths to exclude from Scan and add to Clean
</span>
<Icon icon="question-circle" /> <Icon icon="question-circle" />
</a> </a>
</p> </p>
@@ -205,23 +243,41 @@ export const SettingsConfigurationPanel: React.FC = () => {
<Form.Label>Maximum transcode size</Form.Label> <Form.Label>Maximum transcode size</Form.Label>
<Form.Control <Form.Control
as="select" as="select"
onChange={(event:React.FormEvent<HTMLSelectElement>) => setMaxTranscodeSize(translateQuality(event.currentTarget.value))} onChange={(event: React.FormEvent<HTMLSelectElement>) =>
setMaxTranscodeSize(translateQuality(event.currentTarget.value))
}
value={resolutionToString(maxTranscodeSize)} value={resolutionToString(maxTranscodeSize)}
> >
{ transcodeQualities.map(q => (<option key={q} value={q}>{q}</option>))} {transcodeQualities.map(q => (
<option key={q} value={q}>
{q}
</option>
))}
</Form.Control> </Form.Control>
<Form.Text className="text-muted">Maximum size for generated transcodes</Form.Text> <Form.Text className="text-muted">
Maximum size for generated transcodes
</Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="streaming-transcode-size"> <Form.Group id="streaming-transcode-size">
<Form.Label>Maximum streaming transcode size</Form.Label> <Form.Label>Maximum streaming transcode size</Form.Label>
<Form.Control <Form.Control
as="select" as="select"
onChange={(event:React.FormEvent<HTMLSelectElement>) => setMaxStreamingTranscodeSize(translateQuality(event.currentTarget.value))} onChange={(event: React.FormEvent<HTMLSelectElement>) =>
setMaxStreamingTranscodeSize(
translateQuality(event.currentTarget.value)
)
}
value={resolutionToString(maxStreamingTranscodeSize)} value={resolutionToString(maxStreamingTranscodeSize)}
> >
{ transcodeQualities.map(q => (<option key={q} value={q}>{q}</option>))} {transcodeQualities.map(q => (
<option key={q} value={q}>
{q}
</option>
))}
</Form.Control> </Form.Control>
<Form.Text className="text-muted">Maximum size for transcoded streams</Form.Text> <Form.Text className="text-muted">
Maximum size for transcoded streams
</Form.Text>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>
@@ -231,13 +287,28 @@ export const SettingsConfigurationPanel: React.FC = () => {
<h4>Authentication</h4> <h4>Authentication</h4>
<Form.Group id="username"> <Form.Group id="username">
<Form.Label>Username</Form.Label> <Form.Label>Username</Form.Label>
<Form.Control defaultValue={username} onChange={(e: React.FormEvent<HTMLInputElement>) => setUsername(e.currentTarget.value)} /> <Form.Control
<Form.Text className="text-muted">Username to access Stash. Leave blank to disable user authentication</Form.Text> defaultValue={username}
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setUsername(e.currentTarget.value)
}
/>
<Form.Text className="text-muted">
Username to access Stash. Leave blank to disable user authentication
</Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="password"> <Form.Group id="password">
<Form.Label>Password</Form.Label> <Form.Label>Password</Form.Label>
<Form.Control type="password" defaultValue={password} onChange={(e: React.FormEvent<HTMLInputElement>) => setPassword(e.currentTarget.value)} /> <Form.Control
<Form.Text className="text-muted">Password to access Stash. Leave blank to disable user authentication</Form.Text> type="password"
defaultValue={password}
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setPassword(e.currentTarget.value)
}
/>
<Form.Text className="text-muted">
Password to access Stash. Leave blank to disable user authentication
</Form.Text>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>
@@ -246,8 +317,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
<h4>Logging</h4> <h4>Logging</h4>
<Form.Group id="log-file"> <Form.Group id="log-file">
<Form.Label>Log file</Form.Label> <Form.Label>Log file</Form.Label>
<Form.Control defaultValue={logFile} onChange={(e: React.FormEvent<HTMLInputElement>) => setLogFile(e.currentTarget.value)} /> <Form.Control
<Form.Text className="text-muted">Path to the file to output logging to. Blank to disable file logging. Requires restart.</Form.Text> defaultValue={logFile}
onChange={(e: React.FormEvent<HTMLInputElement>) =>
setLogFile(e.currentTarget.value)
}
/>
<Form.Text className="text-muted">
Path to the file to output logging to. Blank to disable file logging.
Requires restart.
</Form.Text>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
@@ -256,17 +335,26 @@ export const SettingsConfigurationPanel: React.FC = () => {
label="Log to terminal" label="Log to terminal"
onChange={() => setLogOut(!logOut)} onChange={() => setLogOut(!logOut)}
/> />
<Form.Text className="text-muted">Logs to the terminal in addition to a file. Always true if file logging is disabled. Requires restart.</Form.Text> <Form.Text className="text-muted">
Logs to the terminal in addition to a file. Always true if file
logging is disabled. Requires restart.
</Form.Text>
</Form.Group> </Form.Group>
<Form.Group id="log-level"> <Form.Group id="log-level">
<Form.Label>Log Level</Form.Label> <Form.Label>Log Level</Form.Label>
<Form.Control <Form.Control
as="select" as="select"
onChange={(event:React.FormEvent<HTMLSelectElement>) => setLogLevel(event.currentTarget.value)} onChange={(event: React.FormEvent<HTMLSelectElement>) =>
setLogLevel(event.currentTarget.value)
}
value={logLevel} value={logLevel}
> >
{ ["Debug", "Info", "Warning", "Error"].map(o => (<option key={o} value={o}>{o}</option>)) } {["Debug", "Info", "Warning", "Error"].map(o => (
<option key={o} value={o}>
{o}
</option>
))}
</Form.Control> </Form.Control>
</Form.Group> </Form.Group>
@@ -276,12 +364,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
label="Log http access" label="Log http access"
onChange={() => setLogAccess(!logAccess)} onChange={() => setLogAccess(!logAccess)}
/> />
<Form.Text className="text-muted">Logs http access to the terminal. Requires restart.</Form.Text> <Form.Text className="text-muted">
Logs http access to the terminal. Requires restart.
</Form.Text>
</Form.Group> </Form.Group>
<hr /> <hr />
<Button variant="primary" onClick={() => onSave()}>Save</Button> <Button variant="primary" onClick={() => onSave()}>
Save
</Button>
</> </>
); );
}; };

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, Form, Spinner } from 'react-bootstrap'; import { Button, Form, Spinner } from "react-bootstrap";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { useToast } from 'src/hooks'; import { useToast } from "src/hooks";
export const SettingsInterfacePanel: React.FC = () => { export const SettingsInterfacePanel: React.FC = () => {
const Toast = useToast(); const Toast = useToast();
@@ -25,8 +25,7 @@ export const SettingsInterfacePanel: React.FC = () => {
}); });
useEffect(() => { useEffect(() => {
if (config.error) if (config.error) return;
return;
const iCfg = config?.data?.configuration?.interface; const iCfg = config?.data?.configuration?.interface;
setSoundOnPreview(iCfg?.soundOnPreview ?? true); setSoundOnPreview(iCfg?.soundOnPreview ?? true);
@@ -51,8 +50,12 @@ export const SettingsInterfacePanel: React.FC = () => {
return ( return (
<> <>
{config.error ? <h1>{config.error.message}</h1> : ''} {config.error ? <h1>{config.error.message}</h1> : ""}
{(!config?.data?.configuration || config.loading) ? <Spinner animation="border" variant="light" /> : ''} {!config?.data?.configuration || config.loading ? (
<Spinner animation="border" variant="light" />
) : (
""
)}
<h4>User Interface</h4> <h4>User Interface</h4>
<Form.Group> <Form.Group>
<Form.Label>Scene / Marker Wall</Form.Label> <Form.Label>Scene / Marker Wall</Form.Label>
@@ -66,7 +69,9 @@ export const SettingsInterfacePanel: React.FC = () => {
label="Enable sound" label="Enable sound"
onChange={() => setSoundOnPreview(!soundOnPreview)} onChange={() => setSoundOnPreview(!soundOnPreview)}
/> />
<Form.Text className="text-muted" >Configuration for wall items</Form.Text> <Form.Text className="text-muted">
Configuration for wall items
</Form.Text>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
@@ -75,31 +80,38 @@ export const SettingsInterfacePanel: React.FC = () => {
checked={showStudioAsText} checked={showStudioAsText}
label="Show Studios as text" label="Show Studios as text"
onChange={() => { onChange={() => {
setShowStudioAsText(!showStudioAsText) setShowStudioAsText(!showStudioAsText);
}} }}
/> />
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<Form.Label>Scene Player</Form.Label> <Form.Label>Scene Player</Form.Label>
<Form.Check <Form.Check
checked={autostartVideo} checked={autostartVideo}
label="Auto-start video" label="Auto-start video"
onChange={() => { onChange={() => {
setAutostartVideo(!autostartVideo) setAutostartVideo(!autostartVideo);
}} }}
/> />
<Form.Group id="max-loop-duration"> <Form.Group id="max-loop-duration">
<Form.Label>Maximum loop duration</Form.Label> <Form.Label>Maximum loop duration</Form.Label>
<Form.Control <Form.Control
type="number" type="number"
defaultValue={maximumLoopDuration} defaultValue={maximumLoopDuration}
onChange={(event:React.FormEvent<HTMLInputElement>) => setMaximumLoopDuration(Number.parseInt(event.currentTarget.value, 10) ?? 0)} onChange={(event: React.FormEvent<HTMLInputElement>) =>
setMaximumLoopDuration(
Number.parseInt(event.currentTarget.value, 10) ?? 0
)
}
min={0} min={0}
step={1} step={1}
/> />
<Form.Text className="text-muted">Maximum scene duration - in seconds - where scene player will loop the video - 0 to disable</Form.Text> <Form.Text className="text-muted">
Maximum scene duration - in seconds - where scene player will loop
the video - 0 to disable
</Form.Text>
</Form.Group> </Form.Group>
</Form.Group> </Form.Group>
@@ -109,7 +121,7 @@ export const SettingsInterfacePanel: React.FC = () => {
checked={cssEnabled} checked={cssEnabled}
label="Custom CSS enabled" label="Custom CSS enabled"
onChange={() => { onChange={() => {
setCSSEnabled(!cssEnabled) setCSSEnabled(!cssEnabled);
}} }}
/> />
@@ -117,13 +129,17 @@ export const SettingsInterfacePanel: React.FC = () => {
as="textarea" as="textarea"
value={css} value={css}
onChange={(e: any) => setCSS(e.target.value)} onChange={(e: any) => setCSS(e.target.value)}
rows={16}> rows={16}
</Form.Control> ></Form.Control>
<Form.Text className="text-muted">Page must be reloaded for changes to take effect.</Form.Text> <Form.Text className="text-muted">
Page must be reloaded for changes to take effect.
</Form.Text>
</Form.Group> </Form.Group>
<hr /> <hr />
<Button variant="primary" onClick={() => onSave()}>Save</Button> <Button variant="primary" onClick={() => onSave()}>
Save
</Button>
</> </>
); );
}; };

View File

@@ -1,13 +1,13 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Form, Col } from 'react-bootstrap'; import { Form, Col } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
function convertTime(logEntry: GQL.LogEntryDataFragment) { function convertTime(logEntry: GQL.LogEntryDataFragment) {
function pad(val : number) { function pad(val: number) {
let ret = val.toString(); let ret = val.toString();
if (val <= 9) { if (val <= 9) {
ret = `0${ ret}`; ret = `0${ret}`;
} }
return ret; return ret;
@@ -16,21 +16,23 @@ function convertTime(logEntry: GQL.LogEntryDataFragment) {
const date = new Date(logEntry.time); const date = new Date(logEntry.time);
const month = date.getMonth() + 1; const month = date.getMonth() + 1;
const day = date.getDate(); const day = date.getDate();
let dateStr = `${date.getFullYear() }-${ pad(month) }-${ pad(day)}`; let dateStr = `${date.getFullYear()}-${pad(month)}-${pad(day)}`;
dateStr += ` ${ pad(date.getHours()) }:${ pad(date.getMinutes()) }:${ pad(date.getSeconds())}`; dateStr += ` ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
date.getSeconds()
)}`;
return dateStr; return dateStr;
} }
function levelClass(level : string) { function levelClass(level: string) {
return level.toLowerCase().trim(); return level.toLowerCase().trim();
} }
interface ILogElementProps { interface ILogElementProps {
logEntry : LogEntry logEntry: LogEntry;
} }
const LogElement: React.FC<ILogElementProps> = ({ logEntry }) => { const LogElement: React.FC<ILogElementProps> = ({ logEntry }) => {
// pad to maximum length of level enum // pad to maximum length of level enum
const level = logEntry.level.padEnd(GQL.LogLevel.Progress.length); const level = logEntry.level.padEnd(GQL.LogLevel.Progress.length);
@@ -39,11 +41,10 @@ const LogElement: React.FC<ILogElementProps> = ({ logEntry }) => {
<span>{logEntry.time}</span>&nbsp; <span>{logEntry.time}</span>&nbsp;
<span className={levelClass(logEntry.level)}>{level}</span>&nbsp; <span className={levelClass(logEntry.level)}>{level}</span>&nbsp;
<span>{logEntry.message}</span> <span>{logEntry.message}</span>
<br/> <br />
</> </>
); );
} };
class LogEntry { class LogEntry {
public time: string; public time: string;
@@ -77,15 +78,17 @@ export const SettingsLogsPanel: React.FC = () => {
const newData = (data?.loggingSubscribe ?? []).map(e => new LogEntry(e)); const newData = (data?.loggingSubscribe ?? []).map(e => new LogEntry(e));
const filteredLogEntries = [...newData.reverse(), ...oldData] const filteredLogEntries = [...newData.reverse(), ...oldData]
.filter(filterByLogLevel).slice(0, MAX_LOG_ENTRIES); .filter(filterByLogLevel)
.slice(0, MAX_LOG_ENTRIES);
const maybeRenderError = error const maybeRenderError = error ? (
? <div className="error">Error connecting to log server: {error.message}</div> <div className="error">Error connecting to log server: {error.message}</div>
: ''; ) : (
""
);
function filterByLogLevel(logEntry : LogEntry) { function filterByLogLevel(logEntry: LogEntry) {
if (logLevel === "Debug") if (logLevel === "Debug") return true;
return true;
const logLevelIndex = logLevels.indexOf(logLevel); const logLevelIndex = logLevels.indexOf(logLevel);
const levelIndex = logLevels.indexOf(logEntry.level); const levelIndex = logLevels.indexOf(logEntry.level);
@@ -104,17 +107,21 @@ export const SettingsLogsPanel: React.FC = () => {
<Form.Control <Form.Control
as="select" as="select"
defaultValue={logLevel} defaultValue={logLevel}
onChange={(event) => setLogLevel(event.currentTarget.value)} onChange={event => setLogLevel(event.currentTarget.value)}
> >
{ logLevels.map(level => (<option key={level} value={level}>{level}</option>)) } {logLevels.map(level => (
<option key={level} value={level}>
{level}
</option>
))}
</Form.Control> </Form.Control>
</Col> </Col>
</Form.Row> </Form.Row>
<div className="logs"> <div className="logs">
{maybeRenderError} {maybeRenderError}
{filteredLogEntries.map((logEntry) => {filteredLogEntries.map(logEntry => (
<LogElement logEntry={logEntry} key={logEntry.id}/> <LogElement logEntry={logEntry} key={logEntry.id} />
)} ))}
</div> </div>
</> </>
); );

View File

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

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Button, Form, ProgressBar } from 'react-bootstrap'; import { Button, Form, ProgressBar } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { useToast } from 'src/hooks'; import { useToast } from "src/hooks";
import { Modal } from 'src/components/Shared'; import { Modal } from "src/components/Shared";
import { GenerateButton } from "./GenerateButton"; import { GenerateButton } from "./GenerateButton";
export const SettingsTasksPanel: React.FC = () => { export const SettingsTasksPanel: React.FC = () => {
@@ -22,7 +22,7 @@ export const SettingsTasksPanel: React.FC = () => {
const metadataUpdate = StashService.useMetadataUpdate(); const metadataUpdate = StashService.useMetadataUpdate();
function statusToText(s: string) { function statusToText(s: string) {
switch(s) { switch (s) {
case "Idle": case "Idle":
return "Idle"; return "Idle";
case "Scan": case "Scan":
@@ -38,7 +38,7 @@ export const SettingsTasksPanel: React.FC = () => {
case "Auto Tag": case "Auto Tag":
return "Auto tagging scenes"; return "Auto tagging scenes";
default: default:
return "Idle" return "Idle";
} }
} }
@@ -68,7 +68,9 @@ export const SettingsTasksPanel: React.FC = () => {
function onImport() { function onImport() {
setIsImportAlertOpen(false); setIsImportAlertOpen(false);
StashService.queryMetadataImport().then(() => { jobStatus.refetch()}); StashService.queryMetadataImport().then(() => {
jobStatus.refetch();
});
} }
function renderImportAlert() { function renderImportAlert() {
@@ -76,12 +78,12 @@ export const SettingsTasksPanel: React.FC = () => {
<Modal <Modal
show={isImportAlertOpen} show={isImportAlertOpen}
icon="trash-alt" icon="trash-alt"
accept={{ text: 'Import', variant: 'danger', onClick: onImport }} accept={{ text: "Import", variant: "danger", onClick: onImport }}
cancel={{ onClick: () => setIsImportAlertOpen(false) }} cancel={{ onClick: () => setIsImportAlertOpen(false) }}
> >
<p> <p>
Are you sure you want to import? This will delete the database and re-import from Are you sure you want to import? This will delete the database and
your exported metadata. re-import from your exported metadata.
</p> </p>
</Modal> </Modal>
); );
@@ -89,7 +91,9 @@ export const SettingsTasksPanel: React.FC = () => {
function onClean() { function onClean() {
setIsCleanAlertOpen(false); setIsCleanAlertOpen(false);
StashService.queryMetadataClean().then(() => { jobStatus.refetch()}); StashService.queryMetadataClean().then(() => {
jobStatus.refetch();
});
} }
function renderCleanAlert() { function renderCleanAlert() {
@@ -97,13 +101,13 @@ export const SettingsTasksPanel: React.FC = () => {
<Modal <Modal
show={isCleanAlertOpen} show={isCleanAlertOpen}
icon="trash-alt" icon="trash-alt"
accept={{ text: 'Clean', variant: 'danger', onClick: onClean }} accept={{ text: "Clean", variant: "danger", onClick: onClean }}
cancel={{ onClick: () => setIsCleanAlertOpen(false) }} cancel={{ onClick: () => setIsCleanAlertOpen(false) }}
> >
<p> <p>
Are you sure you want to Clean? Are you sure you want to Clean? This will delete db information and
This will delete db information and generated content generated content for all scenes that are no longer found in the
for all scenes that are no longer found in the filesystem. filesystem.
</p> </p>
</Modal> </Modal>
); );
@@ -111,7 +115,7 @@ export const SettingsTasksPanel: React.FC = () => {
async function onScan() { async function onScan() {
try { try {
await StashService.queryMetadataScan({useFileMetadata}); await StashService.queryMetadataScan({ useFileMetadata });
Toast.success({ content: "Started scan" }); Toast.success({ content: "Started scan" });
jobStatus.refetch(); jobStatus.refetch();
} catch (e) { } catch (e) {
@@ -125,7 +129,7 @@ export const SettingsTasksPanel: React.FC = () => {
performers: autoTagPerformers ? wildcard : [], performers: autoTagPerformers ? wildcard : [],
studios: autoTagStudios ? wildcard : [], studios: autoTagStudios ? wildcard : [],
tags: autoTagTags ? wildcard : [] tags: autoTagTags ? wildcard : []
} };
} }
async function onAutoTag() { async function onAutoTag() {
@@ -145,7 +149,15 @@ export const SettingsTasksPanel: React.FC = () => {
return ( return (
<Form.Group> <Form.Group>
<Button id="stop" variant="danger" onClick={() => StashService.queryStopJob().then(() => jobStatus.refetch())}>Stop</Button> <Button
id="stop"
variant="danger"
onClick={() =>
StashService.queryStopJob().then(() => jobStatus.refetch())
}
>
Stop
</Button>
</Form.Group> </Form.Group>
); );
} }
@@ -153,11 +165,15 @@ export const SettingsTasksPanel: React.FC = () => {
function renderJobStatus() { function renderJobStatus() {
return ( return (
<> <>
<Form.Group> <Form.Group>
<h5>Status: {status}</h5> <h5>Status: {status}</h5>
{ status !== "Idle" ? <ProgressBar now={progress} label={`${progress}%`} /> : '' } {status !== "Idle" ? (
</Form.Group> <ProgressBar now={progress} label={`${progress}%`} />
{maybeRenderStop()} ) : (
""
)}
</Form.Group>
{maybeRenderStop()}
</> </>
); );
} }
@@ -180,8 +196,12 @@ export const SettingsTasksPanel: React.FC = () => {
label="Set name, date, details from metadata (if present)" label="Set name, date, details from metadata (if present)"
onChange={() => setUseFileMetadata(!useFileMetadata)} onChange={() => setUseFileMetadata(!useFileMetadata)}
/> />
<Button id="scan" type="submit" onClick={() => onScan()}>Scan</Button> <Button id="scan" type="submit" onClick={() => onScan()}>
<Form.Text className="text-muted">Scan for new content and add it to the database.</Form.Text> Scan
</Button>
<Form.Text className="text-muted">
Scan for new content and add it to the database.
</Form.Text>
</Form.Group> </Form.Group>
<hr /> <hr />
@@ -204,8 +224,12 @@ export const SettingsTasksPanel: React.FC = () => {
label="Tags" label="Tags"
onChange={() => setAutoTagTags(!autoTagTags)} onChange={() => setAutoTagTags(!autoTagTags)}
/> />
<Button id="autoTag" type="submit" onClick={() => onAutoTag()}>Auto Tag</Button> <Button id="autoTag" type="submit" onClick={() => onAutoTag()}>
<Form.Text className="text-muted">Auto-tag content based on filenames.</Form.Text> Auto Tag
</Button>
<Form.Text className="text-muted">
Auto-tag content based on filenames.
</Form.Text>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
@@ -219,21 +243,50 @@ export const SettingsTasksPanel: React.FC = () => {
<h4>Generated Content</h4> <h4>Generated Content</h4>
<GenerateButton /> <GenerateButton />
<Form.Group> <Form.Group>
<Button id="clean" variant="danger" onClick={() => setIsCleanAlertOpen(true)}>Clean</Button> <Button
<Form.Text className="text-muted">Check for missing files and remove them from the database. This is a destructive action.</Form.Text> id="clean"
variant="danger"
onClick={() => setIsCleanAlertOpen(true)}
>
Clean
</Button>
<Form.Text className="text-muted">
Check for missing files and remove them from the database. This is a
destructive action.
</Form.Text>
</Form.Group> </Form.Group>
<hr /> <hr />
<h4>Metadata</h4> <h4>Metadata</h4>
<Form.Group> <Form.Group>
<Button id="export" type="submit"onClick={() => StashService.queryMetadataExport().then(() => { jobStatus.refetch()})}>Export</Button> <Button
<Form.Text className="text-muted">Export the database content into JSON format.</Form.Text> id="export"
type="submit"
onClick={() =>
StashService.queryMetadataExport().then(() => {
jobStatus.refetch();
})
}
>
Export
</Button>
<Form.Text className="text-muted">
Export the database content into JSON format.
</Form.Text>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<Button id="import" variant="danger" onClick={() => setIsImportAlertOpen(true)}>Import</Button> <Button
<Form.Text className="text-muted">Import from exported JSON. This is a destructive action.</Form.Text> id="import"
variant="danger"
onClick={() => setIsImportAlertOpen(true)}
>
Import
</Button>
<Form.Text className="text-muted">
Import from exported JSON. This is a destructive action.
</Form.Text>
</Form.Group> </Form.Group>
</> </>
); );

View File

@@ -1,4 +1,12 @@
import { Button, Form, Modal, Nav, Navbar, OverlayTrigger, Popover } from 'react-bootstrap'; import {
Button,
Form,
Modal,
Nav,
Navbar,
OverlayTrigger,
Popover
} from "react-bootstrap";
import React, { useState } from "react"; import React, { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -17,56 +25,85 @@ interface IProps {
// TODO: only for performers. make generic // TODO: only for performers. make generic
scrapers?: GQL.ListPerformerScrapersListPerformerScrapers[]; scrapers?: GQL.ListPerformerScrapersListPerformerScrapers[];
onDisplayScraperDialog?: (scraper: GQL.ListPerformerScrapersListPerformerScrapers) => void; onDisplayScraperDialog?: (
scraper: GQL.ListPerformerScrapersListPerformerScrapers
) => void;
} }
export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => { export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
function renderEditButton() { function renderEditButton() {
if (props.isNew) { return; } if (props.isNew) {
return;
}
return ( return (
<Button <Button variant="primary" onClick={() => props.onToggleEdit()}>
variant="primary" {props.isEditing ? "Cancel" : "Edit"}
onClick={() => props.onToggleEdit()}
>
{ props.isEditing ? "Cancel" : "Edit"}
</Button> </Button>
); );
} }
function renderSaveButton() { function renderSaveButton() {
if (!props.isEditing) { return; } if (!props.isEditing) {
return <Button variant="success" onClick={() => props.onSave()}>Save</Button>; return;
}
return (
<Button variant="success" onClick={() => props.onSave()}>
Save
</Button>
);
} }
function renderDeleteButton() { function renderDeleteButton() {
if (props.isNew || props.isEditing) { return; } if (props.isNew || props.isEditing) {
return <Button variant="danger" onClick={() => setIsDeleteAlertOpen(true)}>Delete</Button>; return;
}
return (
<Button variant="danger" onClick={() => setIsDeleteAlertOpen(true)}>
Delete
</Button>
);
} }
function renderImageInput() { function renderImageInput() {
if (!props.isEditing) { return; } if (!props.isEditing) {
return ( return;
<Form.Group controlId="cover-file"> }
<Form.Label>Choose image...</Form.Label> return (
<Form.Control type="file" accept=".jpg,.jpeg,.png" onChange={props.onImageChange} /> <Form.Group controlId="cover-file">
</Form.Group> <Form.Label>Choose image...</Form.Label>
) <Form.Control
type="file"
accept=".jpg,.jpeg,.png"
onChange={props.onImageChange}
/>
</Form.Group>
);
} }
function renderScraperMenu() { function renderScraperMenu() {
if (!props.performer || !props.isEditing) { return; } if (!props.performer || !props.isEditing) {
return;
}
const popover = ( const popover = (
<Popover id="scraper-popover"> <Popover id="scraper-popover">
<Popover.Content> <Popover.Content>
<div> <div>
{ props.scrapers ? props.scrapers.map((s) => ( {props.scrapers
<Button variant="link" onClick={() => props.onDisplayScraperDialog && props.onDisplayScraperDialog(s) }> ? props.scrapers.map(s => (
{s.name} <Button
</Button> variant="link"
)) : ''} onClick={() =>
props.onDisplayScraperDialog &&
props.onDisplayScraperDialog(s)
}
>
{s.name}
</Button>
))
: ""}
</div> </div>
</Popover.Content> </Popover.Content>
</Popover> </Popover>
@@ -80,53 +117,63 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
} }
function renderAutoTagButton() { function renderAutoTagButton() {
if (props.isNew || props.isEditing) { return; } if (props.isNew || props.isEditing) {
return;
}
if (props.onAutoTag) { if (props.onAutoTag) {
return (<Button onClick={() => { return (
if (props.onAutoTag) { props.onAutoTag() } <Button
}}>Auto Tag</Button>) onClick={() => {
if (props.onAutoTag) {
props.onAutoTag();
}
}}
>
Auto Tag
</Button>
);
} }
} }
function renderScenesButton() { function renderScenesButton() {
if (props.isEditing) { return; } if (props.isEditing) {
return;
}
let linkSrc: string = "#"; let linkSrc: string = "#";
if (props.performer) { if (props.performer) {
linkSrc = NavUtils.makePerformerScenesUrl(props.performer); linkSrc = NavUtils.makePerformerScenesUrl(props.performer);
} else if (props.studio) { } else if (props.studio) {
linkSrc = NavUtils.makeStudioScenesUrl(props.studio); linkSrc = NavUtils.makeStudioScenesUrl(props.studio);
} }
return ( return <Link to={linkSrc}>Scenes</Link>;
<Link to={linkSrc}>
Scenes
</Link>
);
} }
function renderDeleteAlert() { function renderDeleteAlert() {
const name = props?.studio?.name ?? props?.performer?.name; const name = props?.studio?.name ?? props?.performer?.name;
return ( return (
<Modal <Modal show={isDeleteAlertOpen}>
show={isDeleteAlertOpen} <Modal.Body>Are you sure you want to delete {name}?</Modal.Body>
>
<Modal.Body>
Are you sure you want to delete {name}?
</Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button variant="danger" onClick={props.onDelete}>Delete</Button> <Button variant="danger" onClick={props.onDelete}>
<Button variant="secondary" onClick={() => setIsDeleteAlertOpen(false)}>Cancel</Button> Delete
</Button>
<Button
variant="secondary"
onClick={() => setIsDeleteAlertOpen(false)}
>
Cancel
</Button>
</Modal.Footer> </Modal.Footer>
</Modal> </Modal>
); );
} }
return ( return (
<> <>
{renderDeleteAlert()} {renderDeleteAlert()}
<Navbar bg="dark"> <Navbar bg="dark">
<Nav className="mr-auto ml-auto"> <Nav className="mr-auto ml-auto">
{renderEditButton()} {renderEditButton()}
{renderScraperMenu()} {renderScraperMenu()}
{renderImageInput()} {renderImageInput()}
@@ -135,8 +182,8 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
{renderAutoTagButton()} {renderAutoTagButton()}
{renderScenesButton()} {renderScenesButton()}
{renderDeleteButton()} {renderDeleteButton()}
</Nav> </Nav>
</Navbar> </Navbar>
</> </>
); );
}; };

View File

@@ -1,23 +1,25 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Button, ButtonGroup, InputGroup, Form } from 'react-bootstrap'; import { Button, ButtonGroup, InputGroup, Form } from "react-bootstrap";
import { Icon } from 'src/components/Shared' import { Icon } from "src/components/Shared";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
interface IProps { interface IProps {
disabled?: boolean disabled?: boolean;
numericValue: number numericValue: number;
onValueChange(valueAsNumber: number): void onValueChange(valueAsNumber: number): void;
onReset?(): void onReset?(): void;
} }
export const DurationInput: React.FC<IProps> = (props: IProps) => { export const DurationInput: React.FC<IProps> = (props: IProps) => {
const [value, setValue] = useState<string>(secondsToString(props.numericValue)); const [value, setValue] = useState<string>(
secondsToString(props.numericValue)
);
useEffect(() => { useEffect(() => {
setValue(secondsToString(props.numericValue)); setValue(secondsToString(props.numericValue));
}, [props.numericValue]); }, [props.numericValue]);
function secondsToString(seconds : number) { function secondsToString(seconds: number) {
let ret = TextUtils.secondsToTimestamp(seconds); let ret = TextUtils.secondsToTimestamp(seconds);
if (ret.startsWith("00:")) { if (ret.startsWith("00:")) {
@@ -31,7 +33,7 @@ export const DurationInput: React.FC<IProps> = (props: IProps) => {
return ret; return ret;
} }
function stringToSeconds(v : string) { function stringToSeconds(v: string) {
if (!v) { if (!v) {
return 0; return 0;
} }
@@ -44,7 +46,7 @@ export const DurationInput: React.FC<IProps> = (props: IProps) => {
let seconds = 0; let seconds = 0;
let factor = 1; let factor = 1;
while(splits.length > 0) { while (splits.length > 0) {
const thisSplit = splits.pop(); const thisSplit = splits.pop();
if (thisSplit === undefined) { if (thisSplit === undefined) {
return 0; return 0;
@@ -76,23 +78,15 @@ export const DurationInput: React.FC<IProps> = (props: IProps) => {
function renderButtons() { function renderButtons() {
return ( return (
<ButtonGroup <ButtonGroup vertical>
vertical <Button disabled={props.disabled} onClick={() => increment()}>
>
<Button
disabled={props.disabled}
onClick={() => increment()}
>
<Icon icon="chevron-up" /> <Icon icon="chevron-up" />
</Button> </Button>
<Button <Button disabled={props.disabled} onClick={() => decrement()}>
disabled={props.disabled}
onClick={() => decrement()}
>
<Icon icon="chevron-down" /> <Icon icon="chevron-down" />
</Button> </Button>
</ButtonGroup> </ButtonGroup>
) );
} }
function onReset() { function onReset() {
@@ -104,12 +98,10 @@ export const DurationInput: React.FC<IProps> = (props: IProps) => {
function maybeRenderReset() { function maybeRenderReset() {
if (props.onReset) { if (props.onReset) {
return ( return (
<Button <Button onClick={() => onReset()}>
onClick={() => onReset()}
>
<Icon icon="clock" /> <Icon icon="clock" />
</Button> </Button>
) );
} }
} }
@@ -119,15 +111,15 @@ export const DurationInput: React.FC<IProps> = (props: IProps) => {
<Form.Control <Form.Control
disabled={props.disabled} disabled={props.disabled}
value={value} value={value}
onChange={(e : any) => setValue(e.target.value)} onChange={(e: any) => setValue(e.target.value)}
onBlur={() => props.onValueChange(stringToSeconds(value))} onBlur={() => props.onValueChange(stringToSeconds(value))}
placeholder="hh:mm:ss" placeholder="hh:mm:ss"
/> />
<InputGroup.Append> <InputGroup.Append>
{ maybeRenderReset() } {maybeRenderReset()}
{ renderButtons() } {renderButtons()}
</InputGroup.Append> </InputGroup.Append>
</InputGroup> </InputGroup>
</Form.Group> </Form.Group>
) );
}; };

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, InputGroup, Form, Modal, Spinner } from 'react-bootstrap'; import { Button, InputGroup, Form, Modal, Spinner } from "react-bootstrap";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
interface IProps { interface IProps {
@@ -11,13 +11,15 @@ export const FolderSelect: React.FC<IProps> = (props: IProps) => {
const [currentDirectory, setCurrentDirectory] = useState<string>(""); const [currentDirectory, setCurrentDirectory] = useState<string>("");
const [isDisplayingDialog, setIsDisplayingDialog] = useState<boolean>(false); const [isDisplayingDialog, setIsDisplayingDialog] = useState<boolean>(false);
const [selectedDirectories, setSelectedDirectories] = useState<string[]>([]); const [selectedDirectories, setSelectedDirectories] = useState<string[]>([]);
const { data, error, loading } = StashService.useDirectories(currentDirectory); const { data, error, loading } = StashService.useDirectories(
currentDirectory
);
useEffect(() => { useEffect(() => {
setSelectedDirectories(props.directories); setSelectedDirectories(props.directories);
}, [props.directories]); }, [props.directories]);
const selectableDirectories:string[] = data?.directories ?? []; const selectableDirectories: string[] = data?.directories ?? [];
function onSelectDirectory() { function onSelectDirectory() {
selectedDirectories.push(currentDirectory); selectedDirectories.push(currentDirectory);
@@ -28,7 +30,9 @@ export const FolderSelect: React.FC<IProps> = (props: IProps) => {
} }
function onRemoveDirectory(directory: string) { function onRemoveDirectory(directory: string) {
const newSelectedDirectories = selectedDirectories.filter((dir) => dir !== directory); const newSelectedDirectories = selectedDirectories.filter(
dir => dir !== directory
);
setSelectedDirectories(newSelectedDirectories); setSelectedDirectories(newSelectedDirectories);
props.onDirectoriesChanged(newSelectedDirectories); props.onDirectoriesChanged(newSelectedDirectories);
} }
@@ -40,9 +44,7 @@ export const FolderSelect: React.FC<IProps> = (props: IProps) => {
onHide={() => setIsDisplayingDialog(false)} onHide={() => setIsDisplayingDialog(false)}
title="" title=""
> >
<Modal.Header> <Modal.Header>Select Directory</Modal.Header>
Select Directory
</Modal.Header>
<Modal.Body> <Modal.Body>
<div className="dialog-content"> <div className="dialog-content">
<InputGroup> <InputGroup>
@@ -52,11 +54,23 @@ export const FolderSelect: React.FC<IProps> = (props: IProps) => {
defaultValue={currentDirectory} defaultValue={currentDirectory}
/> />
<InputGroup.Append> <InputGroup.Append>
{(!data || !data.directories || loading) ? <Spinner animation="border" variant="light" /> : ''} {!data || !data.directories || loading ? (
<Spinner animation="border" variant="light" />
) : (
""
)}
</InputGroup.Append> </InputGroup.Append>
</InputGroup> </InputGroup>
{selectableDirectories.map((path) => { {selectableDirectories.map(path => {
return <Button variant="link" key={path} onClick={() => setCurrentDirectory(path)}>{path}</Button>; return (
<Button
variant="link"
key={path}
onClick={() => setCurrentDirectory(path)}
>
{path}
</Button>
);
})} })}
</div> </div>
</Modal.Body> </Modal.Body>
@@ -69,11 +83,18 @@ export const FolderSelect: React.FC<IProps> = (props: IProps) => {
return ( return (
<> <>
{error ? <h1>{error.message}</h1> : ''} {error ? <h1>{error.message}</h1> : ""}
{renderDialog()} {renderDialog()}
<Form.Group> <Form.Group>
{selectedDirectories.map((path) => { {selectedDirectories.map(path => {
return <div key={path}>{path} <Button variant="link" onClick={() => onRemoveDirectory(path)}>Remove</Button></div>; return (
<div key={path}>
{path}{" "}
<Button variant="link" onClick={() => onRemoveDirectory(path)}>
Remove
</Button>
</div>
);
})} })}
</Form.Group> </Form.Group>

View File

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

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from "react";
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconName } from '@fortawesome/fontawesome-svg-core'; import { IconName } from "@fortawesome/fontawesome-svg-core";
interface IIcon { interface IIcon {
icon: IconName; icon: IconName;

View File

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

View File

@@ -1,20 +1,20 @@
import React, { useState, useCallback } from "react"; import React, { useState, useCallback } from "react";
import Select, { ValueType } from 'react-select'; import Select, { ValueType } from "react-select";
import CreatableSelect from 'react-select/creatable'; import CreatableSelect from "react-select/creatable";
import { debounce } from 'lodash'; import { debounce } from "lodash";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { useToast } from 'src/hooks'; import { useToast } from "src/hooks";
type ValidTypes = type ValidTypes =
GQL.AllPerformersForFilterAllPerformers | | GQL.AllPerformersForFilterAllPerformers
GQL.AllTagsForFilterAllTags | | GQL.AllTagsForFilterAllTags
GQL.AllStudiosForFilterAllStudios; | GQL.AllStudiosForFilterAllStudios;
type Option = { value:string, label:string }; type Option = { value: string; label: string };
interface ITypeProps { interface ITypeProps {
type?: 'performers' | 'studios' | 'tags'; type?: "performers" | "studios" | "tags";
} }
interface IFilterProps { interface IFilterProps {
initialIds: string[]; initialIds: string[];
@@ -40,131 +40,221 @@ interface ISelectProps {
interface ISceneGallerySelect { interface ISceneGallerySelect {
initialId?: string; initialId?: string;
sceneId: string; sceneId: string;
onSelect: (item: GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined) => void; onSelect: (
item: GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined
) => void;
} }
const getSelectedValues = (selectedItems:ValueType<Option>) => ( const getSelectedValues = (selectedItems: ValueType<Option>) =>
(Array.isArray(selectedItems) ? selectedItems : [selectedItems]) (Array.isArray(selectedItems) ? selectedItems : [selectedItems]).map(
.map(item => item.value) item => item.value
); );
export const SceneGallerySelect: React.FC<ISceneGallerySelect> = (props) => { export const SceneGallerySelect: React.FC<ISceneGallerySelect> = props => {
const { data, loading } = StashService.useValidGalleriesForScene(props.sceneId); const { data, loading } = StashService.useValidGalleriesForScene(
props.sceneId
);
const galleries = data?.validGalleriesForScene ?? []; const galleries = data?.validGalleriesForScene ?? [];
const items = (galleries.length > 0 ? [{ path: 'None', id: '0' }, ...galleries] : []) const items = (galleries.length > 0
.map(g => ({ label: g.path, value: g.id })); ? [{ path: "None", id: "0" }, ...galleries]
: []
).map(g => ({ label: g.path, value: g.id }));
const onChange = (selectedItems:ValueType<Option>) => { const onChange = (selectedItems: ValueType<Option>) => {
const selectedItem = getSelectedValues(selectedItems)[0]; const selectedItem = getSelectedValues(selectedItems)[0];
props.onSelect(galleries.find(g => g.id === selectedItem.value)); props.onSelect(galleries.find(g => g.id === selectedItem.value));
}; };
const initialId = props.initialId ? [props.initialId] : []; const initialId = props.initialId ? [props.initialId] : [];
return <SelectComponent onChange={onChange} isLoading={loading} items={items} initialIds={initialId} /> return (
<SelectComponent
onChange={onChange}
isLoading={loading}
items={items}
initialIds={initialId}
/>
);
}; };
interface IScrapePerformerSuggestProps { interface IScrapePerformerSuggestProps {
scraperId: string; scraperId: string;
onSelectPerformer: (query: GQL.ScrapePerformerListScrapePerformerList) => void; onSelectPerformer: (
query: GQL.ScrapePerformerListScrapePerformerList
) => void;
placeholder?: string; placeholder?: string;
} }
export const ScrapePerformerSuggest: React.FC<IScrapePerformerSuggestProps> = (props) => { export const ScrapePerformerSuggest: React.FC<IScrapePerformerSuggestProps> = props => {
const [query, setQuery] = React.useState<string>(""); const [query, setQuery] = React.useState<string>("");
const { data, loading } = StashService.useScrapePerformerList(props.scraperId, query); const { data, loading } = StashService.useScrapePerformerList(
props.scraperId,
const onInputChange = useCallback(debounce((input:string) => { setQuery(input)}, 500), []); query
const onChange = (selectedItems:ValueType<Option>) => (
props.onSelectPerformer(getSelectedValues(selectedItems)[0])
); );
const onInputChange = useCallback(
debounce((input: string) => {
setQuery(input);
}, 500),
[]
);
const onChange = (selectedItems: ValueType<Option>) =>
props.onSelectPerformer(getSelectedValues(selectedItems)[0]);
const performers = data?.scrapePerformerList ?? []; const performers = data?.scrapePerformerList ?? [];
const items = performers.map(item => ({ label: item.name ?? '', value: item.name ?? '' })); const items = performers.map(item => ({
return <SelectComponent onChange={onChange} onInputChange={onInputChange} isLoading={loading} items={items} initialIds={[]} placeholder={props.placeholder} /> label: item.name ?? "",
} value: item.name ?? ""
}));
return (
<SelectComponent
onChange={onChange}
onInputChange={onInputChange}
isLoading={loading}
items={items}
initialIds={[]}
placeholder={props.placeholder}
/>
);
};
interface IMarkerSuggestProps { interface IMarkerSuggestProps {
initialMarkerTitle?: string; initialMarkerTitle?: string;
onChange: (title:string) => void; onChange: (title: string) => void;
} }
export const MarkerTitleSuggest: React.FC<IMarkerSuggestProps> = (props) => { export const MarkerTitleSuggest: React.FC<IMarkerSuggestProps> = props => {
const { data, loading } = StashService.useMarkerStrings(); const { data, loading } = StashService.useMarkerStrings();
const suggestions = data?.markerStrings ?? []; const suggestions = data?.markerStrings ?? [];
const onChange = (selectedItems:ValueType<Option>) => ( const onChange = (selectedItems: ValueType<Option>) =>
props.onChange(getSelectedValues(selectedItems)[0]) props.onChange(getSelectedValues(selectedItems)[0]);
const items = suggestions.map(item => ({
label: item?.title ?? "",
value: item?.title ?? ""
}));
const initialIds = props.initialMarkerTitle ? [props.initialMarkerTitle] : [];
return (
<SelectComponent
creatable
onChange={onChange}
isLoading={loading}
items={items}
initialIds={initialIds}
/>
);
};
export const FilterSelect: React.FC<IFilterProps & ITypeProps> = props =>
props.type === "performers" ? (
<PerformerSelect {...(props as IFilterProps)} />
) : props.type === "studios" ? (
<StudioSelect {...(props as IFilterProps)} />
) : (
<TagSelect {...(props as IFilterProps)} />
); );
const items = suggestions.map(item => ({ label: item?.title ?? '', value: item?.title ?? '' })); export const PerformerSelect: React.FC<IFilterProps> = props => {
const initialIds = props.initialMarkerTitle ? [props.initialMarkerTitle] : [];
return <SelectComponent creatable onChange={onChange} isLoading={loading} items={items} initialIds={initialIds} />
}
export const FilterSelect: React.FC<IFilterProps & ITypeProps> = (props) => (
props.type === 'performers' ? <PerformerSelect {...props as IFilterProps} />
: props.type === 'studios' ? <StudioSelect {...props as IFilterProps} />
: <TagSelect {...props as IFilterProps} />
);
export const PerformerSelect: React.FC<IFilterProps> = (props) => {
const { data, loading } = StashService.useAllPerformersForFilter(); const { data, loading } = StashService.useAllPerformersForFilter();
const normalizedData = data?.allPerformers ?? []; const normalizedData = data?.allPerformers ?? [];
const items:Option[] = normalizedData.map(item => ({ value: item.id, label: item.name ?? '' })); const items: Option[] = normalizedData.map(item => ({
const placeholder = props.noSelectionString ?? "Select performer..." value: item.id,
label: item.name ?? ""
}));
const placeholder = props.noSelectionString ?? "Select performer...";
const onChange = (selectedItems:ValueType<Option>) => { const onChange = (selectedItems: ValueType<Option>) => {
const selectedIds = getSelectedValues(selectedItems); const selectedIds = getSelectedValues(selectedItems);
props.onSelect(normalizedData.filter(item => selectedIds.indexOf(item.id) !== -1)); props.onSelect(
normalizedData.filter(item => selectedIds.indexOf(item.id) !== -1)
);
}; };
return <SelectComponent {...props} onChange={onChange} type="performers" isLoading={loading} items={items} placeholder={placeholder} /> return (
} <SelectComponent
{...props}
onChange={onChange}
type="performers"
isLoading={loading}
items={items}
placeholder={placeholder}
/>
);
};
export const StudioSelect: React.FC<IFilterProps> = (props) => { export const StudioSelect: React.FC<IFilterProps> = props => {
const { data, loading } = StashService.useAllStudiosForFilter(); const { data, loading } = StashService.useAllStudiosForFilter();
const normalizedData = data?.allStudios ?? []; const normalizedData = data?.allStudios ?? [];
const items:Option[] = normalizedData.map(item => ({ value: item.id, label: item.name })); const items: Option[] = normalizedData.map(item => ({
const placeholder = props.noSelectionString ?? "Select studio..." value: item.id,
label: item.name
}));
const placeholder = props.noSelectionString ?? "Select studio...";
const onChange = (selectedItems:ValueType<Option>) => { const onChange = (selectedItems: ValueType<Option>) => {
const selectedIds = getSelectedValues(selectedItems); const selectedIds = getSelectedValues(selectedItems);
props.onSelect(normalizedData.filter(item => selectedIds.indexOf(item.id) !== -1)); props.onSelect(
normalizedData.filter(item => selectedIds.indexOf(item.id) !== -1)
);
}; };
return <SelectComponent {...props} onChange={onChange} type="studios" isLoading={loading} items={items} placeholder={placeholder} /> return (
} <SelectComponent
{...props}
onChange={onChange}
type="studios"
isLoading={loading}
items={items}
placeholder={placeholder}
/>
);
};
export const TagSelect: React.FC<IFilterProps> = (props) => { export const TagSelect: React.FC<IFilterProps> = props => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const { data, loading: dataLoading } = StashService.useAllTagsForFilter(); const { data, loading: dataLoading } = StashService.useAllTagsForFilter();
const createTag = StashService.useTagCreate({name: ''}); const createTag = StashService.useTagCreate({ name: "" });
const Toast = useToast(); const Toast = useToast();
const placeholder = props.noSelectionString ?? "Select tags..." const placeholder = props.noSelectionString ?? "Select tags...";
const tags = data?.allTags ?? []; const tags = data?.allTags ?? [];
const selected = tags.filter(tag => selectedIds.indexOf(tag.id) !== -1).map(tag => ({value: tag.id, label: tag.name})); const selected = tags
const items:Option[] = tags.map(item => ({ value: item.id, label: item.name })); .filter(tag => selectedIds.indexOf(tag.id) !== -1)
.map(tag => ({ value: tag.id, label: tag.name }));
const items: Option[] = tags.map(item => ({
value: item.id,
label: item.name
}));
const onCreate = async (tagName: string) => { const onCreate = async (tagName: string) => {
try { try {
setLoading(true); setLoading(true);
const result = await createTag({ const result = await createTag({
variables: { name: tagName }, variables: { name: tagName }
}); });
setSelectedIds([...selectedIds, result.data.tagCreate.id]); setSelectedIds([...selectedIds, result.data.tagCreate.id]);
props.onSelect([...tags, result.data.tagCreate].filter(item => selected.indexOf(item.id) !== -1)); props.onSelect(
[...tags, result.data.tagCreate].filter(
item => selected.indexOf(item.id) !== -1
)
);
setLoading(false); setLoading(false);
Toast.success({ content: (<span>Created tag: <b>{tagName}</b></span>) }); Toast.success({
content: (
<span>
Created tag: <b>{tagName}</b>
</span>
)
});
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
}; };
const onChange = (selectedItems:ValueType<Option>) => { const onChange = (selectedItems: ValueType<Option>) => {
const selectedValues = getSelectedValues(selectedItems); const selectedValues = getSelectedValues(selectedItems);
setSelectedIds(selectedValues); setSelectedIds(selectedValues);
props.onSelect(tags.filter(item => selectedValues.indexOf(item.id) !== -1)); props.onSelect(tags.filter(item => selectedValues.indexOf(item.id) !== -1));
@@ -183,23 +273,24 @@ export const TagSelect: React.FC<IFilterProps> = (props) => {
selectedOptions={selected} selectedOptions={selected}
/> />
); );
} };
const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({ const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
type, type,
initialIds, initialIds,
onChange, onChange,
className, className,
items, items,
selectedOptions, selectedOptions,
isLoading, isLoading,
onCreateOption, onCreateOption,
creatable = false, creatable = false,
isMulti = false, isMulti = false,
onInputChange, onInputChange,
placeholder placeholder
}) => { }) => {
const defaultValue = items.filter(item => initialIds?.indexOf(item.value) !== -1) ?? null; const defaultValue =
items.filter(item => initialIds?.indexOf(item.value) !== -1) ?? null;
const props = { const props = {
className, className,
@@ -208,14 +299,19 @@ const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
onChange, onChange,
isMulti, isMulti,
defaultValue, defaultValue,
noOptionsMessage: () => (type !== 'tags' ? 'None' : null), noOptionsMessage: () => (type !== "tags" ? "None" : null),
placeholder, placeholder,
onInputChange onInputChange
} };
return ( return creatable ? (
creatable <CreatableSelect
? <CreatableSelect {...props} isLoading={isLoading} isDisabled={isLoading} onCreateOption={onCreateOption} /> {...props}
: <Select {...props} isLoading={isLoading} /> isLoading={isLoading}
isDisabled={isLoading}
onCreateOption={onCreateOption}
/>
) : (
<Select {...props} isLoading={isLoading} />
); );
}; };

View File

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

View File

@@ -6,11 +6,11 @@ export {
PerformerSelect, PerformerSelect,
StudioSelect, StudioSelect,
TagSelect TagSelect
} from './Select'; } from "./Select";
export { default as Icon } from './Icon'; export { default as Icon } from "./Icon";
export { default as Modal } from './Modal'; export { default as Modal } from "./Modal";
export { DetailsEditNavbar } from './DetailsEditNavbar'; export { DetailsEditNavbar } from "./DetailsEditNavbar";
export { DurationInput } from './DurationInput'; export { DurationInput } from "./DurationInput";
export { TagLink } from './TagLink'; export { TagLink } from "./TagLink";
export { HoverPopover } from './HoverPopover'; export { HoverPopover } from "./HoverPopover";

View File

@@ -1,4 +1,4 @@
import { Spinner } from 'react-bootstrap'; import { Spinner } from "react-bootstrap";
import React, { FunctionComponent } from "react"; import React, { FunctionComponent } from "react";
import { StashService } from "../core/StashService"; import { StashService } from "../core/StashService";
@@ -6,7 +6,9 @@ export const Stats: FunctionComponent = () => {
const { data, error, loading } = StashService.useStats(); const { data, error, loading } = StashService.useStats();
function renderStats() { function renderStats() {
if (!data || !data.stats) { return; } if (!data || !data.stats) {
return;
}
return ( return (
<nav id="details-container" className="level"> <nav id="details-container" className="level">
<div className="level-item has-text-centered"> <div className="level-item has-text-centered">
@@ -45,10 +47,13 @@ export const Stats: FunctionComponent = () => {
return ( return (
<div id="details-container"> <div id="details-container">
{!data || loading ? {!data || loading ? (
<Spinner animation="border" role="status" size="sm"> <Spinner animation="border" role="status" size="sm">
<span className="sr-only">Loading...</span> <span className="sr-only">Loading...</span>
</Spinner> : undefined} </Spinner>
) : (
undefined
)}
{error ? <span>error.message</span> : undefined} {error ? <span>error.message</span> : undefined}
{renderStats()} {renderStats()}

View File

@@ -1,4 +1,4 @@
import { Card } from 'react-bootstrap'; import { Card } from "react-bootstrap";
import React from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -9,18 +9,14 @@ interface IProps {
export const StudioCard: React.FC<IProps> = ({ studio }) => { export const StudioCard: React.FC<IProps> = ({ studio }) => {
return ( return (
<Card <Card className="col-4">
className="col-4"
>
<Link <Link
to={`/studios/${studio.id}`} to={`/studios/${studio.id}`}
className="studio previewable image" className="studio previewable image"
style={{backgroundImage: `url(${studio.image_path})`}} style={{ backgroundImage: `url(${studio.image_path})` }}
/> />
<div className="card-section"> <div className="card-section">
<h4 className="text-truncate"> <h4 className="text-truncate">{studio.name}</h4>
{studio.name}
</h4>
<span>{studio.scene_count} scenes.</span> <span>{studio.scene_count} scenes.</span>
</div> </div>
</Card> </Card>

View File

@@ -1,37 +1,45 @@
/* eslint-disable react/no-this-in-sfc */ /* eslint-disable react/no-this-in-sfc */
import { Form, Spinner, Table } from 'react-bootstrap'; import { Form, Spinner, Table } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useHistory } from 'react-router-dom'; import { useParams, useHistory } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { ImageUtils, TableUtils } from "src/utils"; import { ImageUtils, TableUtils } from "src/utils";
import { DetailsEditNavbar } from "src/components/Shared"; import { DetailsEditNavbar } from "src/components/Shared";
import { useToast} from "src/hooks"; import { useToast } from "src/hooks";
export const Studio: React.FC = () => { export const Studio: React.FC = () => {
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const { id = 'new' } = useParams(); const { id = "new" } = useParams();
const isNew = id === "new"; const isNew = id === "new";
// Editing state // Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew); const [isEditing, setIsEditing] = useState<boolean>(isNew);
// Editing studio state // Editing studio state
const [image, setImage] = useState<string|undefined>(undefined); const [image, setImage] = useState<string | undefined>(undefined);
const [name, setName] = useState<string|undefined>(undefined); const [name, setName] = useState<string | undefined>(undefined);
const [url, setUrl] = useState<string|undefined>(undefined); const [url, setUrl] = useState<string | undefined>(undefined);
// Studio state // Studio state
const [studio, setStudio] = useState<Partial<GQL.StudioDataFragment>>({}); const [studio, setStudio] = useState<Partial<GQL.StudioDataFragment>>({});
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined); const [imagePreview, setImagePreview] = useState<string | undefined>(
undefined
);
const { data, error, loading } = StashService.useFindStudio(id); const { data, error, loading } = StashService.useFindStudio(id);
const updateStudio = StashService.useStudioUpdate(getStudioInput() as GQL.StudioUpdateInput); const updateStudio = StashService.useStudioUpdate(
const createStudio = StashService.useStudioCreate(getStudioInput() as GQL.StudioCreateInput); getStudioInput() as GQL.StudioUpdateInput
const deleteStudio = StashService.useStudioDestroy(getStudioInput() as GQL.StudioDestroyInput); );
const createStudio = StashService.useStudioCreate(
getStudioInput() as GQL.StudioCreateInput
);
const deleteStudio = StashService.useStudioDestroy(
getStudioInput() as GQL.StudioDestroyInput
);
function updateStudioEditState(state: Partial<GQL.StudioDataFragment>) { function updateStudioEditState(state: Partial<GQL.StudioDataFragment>) {
setName(state.name); setName(state.name);
@@ -64,15 +72,14 @@ export const Studio: React.FC = () => {
if (!isNew && !isEditing) { if (!isNew && !isEditing) {
if (!data?.findStudio || loading) if (!data?.findStudio || loading)
return <Spinner animation="border" variant="light" />; return <Spinner animation="border" variant="light" />;
if (error) if (error) return <div>{error.message}</div>;
return <div>{error.message}</div>;
} }
function getStudioInput() { function getStudioInput() {
const input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput> = { const input: Partial<GQL.StudioCreateInput | GQL.StudioUpdateInput> = {
name, name,
url, url,
image, image
}; };
if (!isNew) { if (!isNew) {
@@ -85,7 +92,7 @@ export const Studio: React.FC = () => {
try { try {
if (!isNew) { if (!isNew) {
const result = await updateStudio(); const result = await updateStudio();
updateStudioData(result.data.studioUpdate) updateStudioData(result.data.studioUpdate);
setIsEditing(false); setIsEditing(false);
} else { } else {
const result = await createStudio(); const result = await createStudio();
@@ -101,7 +108,7 @@ export const Studio: React.FC = () => {
return; return;
} }
try { try {
await StashService.queryMetadataAutoTag({ studios: [studio.id]}); await StashService.queryMetadataAutoTag({ studios: [studio.id] });
Toast.success({ content: "Started auto tagging" }); Toast.success({ content: "Started auto tagging" });
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -125,41 +132,50 @@ export const Studio: React.FC = () => {
// TODO: CSS class // TODO: CSS class
return ( return (
<div className="columns is-multiline no-spacing"> <div className="columns is-multiline no-spacing">
<div className="column is-half details-image-container"> <div className="column is-half details-image-container">
<img className="studio" alt="" src={imagePreview} /> <img className="studio" alt="" src={imagePreview} />
</div>
<div className="column is-half details-detail-container">
<DetailsEditNavbar
studio={studio}
isNew={isNew}
isEditing={isEditing}
onToggleEdit={() => { setIsEditing(!isEditing); updateStudioEditState(studio); }}
onSave={onSave}
onDelete={onDelete}
onAutoTag={onAutoTag}
onImageChange={onImageChangeHandler}
/>
<h1>
{ !isEditing
? <span>{studio.name}</span>
: <Form.Group controlId="studio-name">
<Form.Label>Name</Form.Label>
<Form.Control
defaultValue={studio.name || ''}
placeholder="Name"
onChange={(event:any) => setName(event.target.value)}
/>
</Form.Group>
}
</h1>
<Table style={{width: "100%"}}>
<tbody>
{TableUtils.renderInputGroup({title: "URL", value: studio.url, isEditing, onChange: (val:string) => setUrl(val)})}
</tbody>
</Table>
</div>
</div> </div>
<div className="column is-half details-detail-container">
<DetailsEditNavbar
studio={studio}
isNew={isNew}
isEditing={isEditing}
onToggleEdit={() => {
setIsEditing(!isEditing);
updateStudioEditState(studio);
}}
onSave={onSave}
onDelete={onDelete}
onAutoTag={onAutoTag}
onImageChange={onImageChangeHandler}
/>
<h1>
{!isEditing ? (
<span>{studio.name}</span>
) : (
<Form.Group controlId="studio-name">
<Form.Label>Name</Form.Label>
<Form.Control
defaultValue={studio.name || ""}
placeholder="Name"
onChange={(event: any) => setName(event.target.value)}
/>
</Form.Group>
)}
</h1>
<Table style={{ width: "100%" }}>
<tbody>
{TableUtils.renderInputGroup({
title: "URL",
value: studio.url,
isEditing,
onChange: (val: string) => setUrl(val)
})}
</tbody>
</Table>
</div>
</div>
); );
}; };

View File

@@ -1,6 +1,9 @@
import React from "react"; import React from "react";
import { QueryHookResult } from "react-apollo-hooks"; import { QueryHookResult } from "react-apollo-hooks";
import { FindStudiosQuery, FindStudiosVariables } from "src/core/generated-graphql"; import {
FindStudiosQuery,
FindStudiosVariables
} from "src/core/generated-graphql";
import { useStudiosList } from "src/hooks"; import { useStudiosList } from "src/hooks";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
@@ -8,20 +11,29 @@ import { StudioCard } from "./StudioCard";
export const StudioList: React.FC = () => { export const StudioList: React.FC = () => {
const listData = useStudiosList({ const listData = useStudiosList({
renderContent, renderContent
}); });
function renderContent(result: QueryHookResult<FindStudiosQuery, FindStudiosVariables>, filter: ListFilterModel) { function renderContent(
if (!result.data || !result.data.findStudios) { return; } result: QueryHookResult<FindStudiosQuery, FindStudiosVariables>,
filter: ListFilterModel
) {
if (!result.data || !result.data.findStudios) {
return;
}
if (filter.displayMode === DisplayMode.Grid) { if (filter.displayMode === DisplayMode.Grid) {
return ( return (
<div className="grid"> <div className="grid">
{result.data.findStudios.studios.map((studio) => (<StudioCard key={studio.id} studio={studio} />))} {result.data.findStudios.studios.map(studio => (
<StudioCard key={studio.id} studio={studio} />
))}
</div> </div>
); );
} if (filter.displayMode === DisplayMode.List) { }
if (filter.displayMode === DisplayMode.List) {
return <h1>TODO</h1>; return <h1>TODO</h1>;
} if (filter.displayMode === DisplayMode.Wall) { }
if (filter.displayMode === DisplayMode.Wall) {
return <h1>TODO</h1>; return <h1>TODO</h1>;
} }
} }

View File

@@ -1,23 +1,33 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Form, Spinner } from 'react-bootstrap'; import { Button, Form, Spinner } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { NavUtils } from "src/utils"; import { NavUtils } from "src/utils";
import { Icon, Modal } from 'src/components/Shared'; import { Icon, Modal } from "src/components/Shared";
import { useToast } from 'src/hooks'; import { useToast } from "src/hooks";
export const TagList: React.FC = () => { export const TagList: React.FC = () => {
const Toast = useToast(); const Toast = useToast();
// Editing / New state // Editing / New state
const [name, setName] = useState(''); const [name, setName] = useState("");
const [editingTag, setEditingTag] = useState<Partial<GQL.TagDataFragment> | null>(null); const [editingTag, setEditingTag] = useState<Partial<
const [deletingTag, setDeletingTag] = useState<Partial<GQL.TagDataFragment> | null>(null); GQL.TagDataFragment
> | null>(null);
const [deletingTag, setDeletingTag] = useState<Partial<
GQL.TagDataFragment
> | null>(null);
const { data, error } = StashService.useAllTags(); const { data, error } = StashService.useAllTags();
const updateTag = StashService.useTagUpdate(getTagInput() as GQL.TagUpdateInput); const updateTag = StashService.useTagUpdate(
const createTag = StashService.useTagCreate(getTagInput() as GQL.TagCreateInput); getTagInput() as GQL.TagUpdateInput
const deleteTag = StashService.useTagDestroy(getDeleteTagInput() as GQL.TagDestroyInput); );
const createTag = StashService.useTagCreate(
getTagInput() as GQL.TagCreateInput
);
const deleteTag = StashService.useTagDestroy(
getDeleteTagInput() as GQL.TagDestroyInput
);
function getTagInput() { function getTagInput() {
const tagInput: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { name }; const tagInput: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { name };
@@ -29,7 +39,7 @@ export const TagList: React.FC = () => {
function getDeleteTagInput() { function getDeleteTagInput() {
const tagInput: Partial<GQL.TagDestroyInput> = {}; const tagInput: Partial<GQL.TagDestroyInput> = {};
if (deletingTag) { if (deletingTag) {
tagInput.id = deletingTag.id; tagInput.id = deletingTag.id;
} }
return tagInput; return tagInput;
} }
@@ -49,11 +59,10 @@ export const TagList: React.FC = () => {
} }
} }
async function onAutoTag(tag : GQL.TagDataFragment) { async function onAutoTag(tag: GQL.TagDataFragment) {
if (!tag) if (!tag) return;
return;
try { try {
await StashService.queryMetadataAutoTag({ tags: [tag.id]}); await StashService.queryMetadataAutoTag({ tags: [tag.id] });
Toast.success({ content: "Started auto tagging" }); Toast.success({ content: "Started auto tagging" });
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -75,55 +84,71 @@ export const TagList: React.FC = () => {
onHide={() => {}} onHide={() => {}}
show={!!deletingTag} show={!!deletingTag}
icon="trash-alt" icon="trash-alt"
accept={{ onClick: onDelete, variant: 'danger', text: 'Delete' }} accept={{ onClick: onDelete, variant: "danger", text: "Delete" }}
cancel={{ onClick: () => setDeletingTag(null) }} cancel={{ onClick: () => setDeletingTag(null) }}
> >
<span>Are you sure you want to delete {deletingTag && deletingTag.name}?</span> <span>
Are you sure you want to delete {deletingTag && deletingTag.name}?
</span>
</Modal> </Modal>
); );
if (!data?.allTags) if (!data?.allTags) return <Spinner animation="border" variant="light" />;
return <Spinner animation="border" variant="light" />; if (error) return <div>{error.message}</div>;
if (error)
return <div>{error.message}</div>;
const tagElements = data.allTags.map((tag) => { const tagElements = data.allTags.map(tag => {
return ( return (
<> <>
{deleteAlert} {deleteAlert}
<div key={tag.id} className="tag-list-row"> <div key={tag.id} className="tag-list-row">
<Button variant="link" onClick={() => setEditingTag(tag)}>{tag.name}</Button> <Button variant="link" onClick={() => setEditingTag(tag)}>
<div style={{float: "right"}}> {tag.name}
<Button onClick={() => onAutoTag(tag)}>Auto Tag</Button>
<Link to={NavUtils.makeTagScenesUrl(tag)}>Scenes: {tag.scene_count}</Link>
<Link to={NavUtils.makeTagSceneMarkersUrl(tag)}>
Markers: {tag.scene_marker_count}
</Link>
<span>Total: {(tag.scene_count || 0) + (tag.scene_marker_count || 0)}</span>
<Button variant="danger" onClick={() => setDeletingTag(tag)}>
<Icon icon="trash-alt" color="danger" />
</Button> </Button>
<div style={{ float: "right" }}>
<Button onClick={() => onAutoTag(tag)}>Auto Tag</Button>
<Link to={NavUtils.makeTagScenesUrl(tag)}>
Scenes: {tag.scene_count}
</Link>
<Link to={NavUtils.makeTagSceneMarkersUrl(tag)}>
Markers: {tag.scene_marker_count}
</Link>
<span>
Total: {(tag.scene_count || 0) + (tag.scene_marker_count || 0)}
</span>
<Button variant="danger" onClick={() => setDeletingTag(tag)}>
<Icon icon="trash-alt" color="danger" />
</Button>
</div>
</div> </div>
</div>
</> </>
); );
}); });
return ( return (
<div id="tag-list-container"> <div id="tag-list-container">
<Button variant="primary" style={{marginTop: "20px"}} onClick={() => setEditingTag({})}>New Tag</Button> <Button
variant="primary"
style={{ marginTop: "20px" }}
onClick={() => setEditingTag({})}
>
New Tag
</Button>
<Modal <Modal
show={!!editingTag} show={!!editingTag}
header={editingTag && editingTag.id ? "Edit Tag" : "New Tag"} header={editingTag && editingTag.id ? "Edit Tag" : "New Tag"}
onHide={() => setEditingTag(null)} onHide={() => setEditingTag(null)}
accept={{ onClick: onEdit, variant: 'danger', text: (editingTag?.id ? 'Update' : 'Create') }} accept={{
onClick: onEdit,
variant: "danger",
text: editingTag?.id ? "Update" : "Create"
}}
> >
<Form.Group controlId="tag-name"> <Form.Group controlId="tag-name">
<Form.Label>Name</Form.Label> <Form.Label>Name</Form.Label>
<Form.Control <Form.Control
onChange={(newValue: any) => setName(newValue.target.value)} onChange={(newValue: any) => setName(newValue.target.value)}
defaultValue={(editingTag && editingTag.name) || ''} defaultValue={(editingTag && editingTag.name) || ""}
/> />
</Form.Group> </Form.Group>
</Modal> </Modal>

View File

@@ -11,18 +11,27 @@ interface IWallItemProps {
sceneMarker?: GQL.SceneMarkerDataFragment; sceneMarker?: GQL.SceneMarkerDataFragment;
origin?: string; origin?: string;
onOverlay: (show: boolean) => void; onOverlay: (show: boolean) => void;
clickHandler?: (item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment) => void; clickHandler?: (
item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment
) => void;
} }
export const WallItem: FunctionComponent<IWallItemProps> = (props: IWallItemProps) => { export const WallItem: FunctionComponent<IWallItemProps> = (
props: IWallItemProps
) => {
const [videoPath, setVideoPath] = useState<string | undefined>(undefined); const [videoPath, setVideoPath] = useState<string | undefined>(undefined);
const [previewPath, setPreviewPath] = useState<string>(""); const [previewPath, setPreviewPath] = useState<string>("");
const [screenshotPath, setScreenshotPath] = useState<string>(""); const [screenshotPath, setScreenshotPath] = useState<string>("");
const [title, setTitle] = useState<string>(""); const [title, setTitle] = useState<string>("");
const [tags, setTags] = useState<JSX.Element[]>([]); const [tags, setTags] = useState<JSX.Element[]>([]);
const config = StashService.useConfiguration(); const config = StashService.useConfiguration();
const videoHoverHook = VideoHoverHook.useVideoHover({resetOnMouseLeave: true}); const videoHoverHook = VideoHoverHook.useVideoHover({
const showTextContainer = !!config.data && !!config.data.configuration ? config.data.configuration.interface.wallShowTitle : true; resetOnMouseLeave: true
});
const showTextContainer =
!!config.data && !!config.data.configuration
? config.data.configuration.interface.wallShowTitle
: true;
function onMouseEnter() { function onMouseEnter() {
VideoHoverHook.onMouseEnter(videoHoverHook); VideoHoverHook.onMouseEnter(videoHoverHook);
@@ -45,7 +54,9 @@ export const WallItem: FunctionComponent<IWallItemProps> = (props: IWallItemProp
} }
function onClick() { function onClick() {
if (props.clickHandler === undefined) { return; } if (props.clickHandler === undefined) {
return;
}
if (props.scene !== undefined) { if (props.scene !== undefined) {
props.clickHandler(props.scene); props.clickHandler(props.scene);
} else if (props.sceneMarker !== undefined) { } else if (props.sceneMarker !== undefined) {
@@ -65,18 +76,28 @@ export const WallItem: FunctionComponent<IWallItemProps> = (props: IWallItemProp
function onTransitionEnd(event: React.TransitionEvent<HTMLDivElement>) { function onTransitionEnd(event: React.TransitionEvent<HTMLDivElement>) {
const target = event.currentTarget; const target = event.currentTarget;
if (target.classList.contains("double-scale") && target.parentElement) { if (target.classList.contains("double-scale") && target.parentElement) {
target.parentElement.style.zIndex = '10'; target.parentElement.style.zIndex = "10";
} else if(target.parentElement) { } else if (target.parentElement) {
target.parentElement.style.zIndex = ''; target.parentElement.style.zIndex = "";
} }
} }
useEffect(() => { useEffect(() => {
if (props.sceneMarker) { if (props.sceneMarker) {
setPreviewPath(props.sceneMarker.preview); setPreviewPath(props.sceneMarker.preview);
setTitle(`${props.sceneMarker!.title} - ${TextUtils.secondsToTimestamp(props.sceneMarker.seconds)}`); setTitle(
const thisTags = props.sceneMarker.tags.map((tag) => (<span key={tag.id}>{tag.name}</span>)); `${props.sceneMarker!.title} - ${TextUtils.secondsToTimestamp(
thisTags.unshift(<span key={props.sceneMarker.primary_tag.id}>{props.sceneMarker.primary_tag.name}</span>); props.sceneMarker.seconds
)}`
);
const thisTags = props.sceneMarker.tags.map(tag => (
<span key={tag.id}>{tag.name}</span>
));
thisTags.unshift(
<span key={props.sceneMarker.primary_tag.id}>
{props.sceneMarker.primary_tag.name}
</span>
);
setTags(thisTags); setTags(thisTags);
} else if (props.scene) { } else if (props.scene) {
setPreviewPath(props.scene.paths.webp || ""); setPreviewPath(props.scene.paths.webp || "");
@@ -93,9 +114,13 @@ export const WallItem: FunctionComponent<IWallItemProps> = (props: IWallItemProp
} }
const className = ["scene-wall-item-container"]; const className = ["scene-wall-item-container"];
if (videoHoverHook.isHovering.current) { className.push("double-scale"); } if (videoHoverHook.isHovering.current) {
className.push("double-scale");
}
const style: React.CSSProperties = {}; const style: React.CSSProperties = {};
if (props.origin) { style.transformOrigin = props.origin; } if (props.origin) {
style.transformOrigin = props.origin;
}
return ( return (
<div className="wall grid-item"> <div className="wall grid-item">
<div <div
@@ -110,20 +135,24 @@ export const WallItem: FunctionComponent<IWallItemProps> = (props: IWallItemProp
<video <video
src={videoPath} src={videoPath}
poster={screenshotPath} poster={screenshotPath}
style={videoHoverHook.isHovering.current ? {} : {display: "none"}} style={videoHoverHook.isHovering.current ? {} : { display: "none" }}
autoPlay autoPlay
loop loop
ref={videoHoverHook.videoEl} ref={videoHoverHook.videoEl}
/> />
<img alt="Preview" src={previewPath || screenshotPath} onError={() => previewNotFound()} /> <img
{showTextContainer ? alt="Preview"
src={previewPath || screenshotPath}
onError={() => previewNotFound()}
/>
{showTextContainer ? (
<div className="scene-wall-item-text-container"> <div className="scene-wall-item-text-container">
<div style={{lineHeight: 1}}> <div style={{ lineHeight: 1 }}>{title}</div>
{title}
</div>
{tags} {tags}
</div> : '' </div>
} ) : (
""
)}
</Link> </Link>
</div> </div>
</div> </div>

View File

@@ -6,10 +6,14 @@ import "./Wall.scss";
interface IWallPanelProps { interface IWallPanelProps {
scenes?: GQL.SlimSceneDataFragment[]; scenes?: GQL.SlimSceneDataFragment[];
sceneMarkers?: GQL.SceneMarkerDataFragment[]; sceneMarkers?: GQL.SceneMarkerDataFragment[];
clickHandler?: (item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment) => void; clickHandler?: (
item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment
) => void;
} }
export const WallPanel: FunctionComponent<IWallPanelProps> = (props: IWallPanelProps) => { export const WallPanel: FunctionComponent<IWallPanelProps> = (
props: IWallPanelProps
) => {
const [showOverlay, setShowOverlay] = useState<boolean>(false); const [showOverlay, setShowOverlay] = useState<boolean>(false);
function onOverlay(show: boolean) { function onOverlay(show: boolean) {
@@ -22,25 +26,47 @@ export const WallPanel: FunctionComponent<IWallPanelProps> = (props: IWallPanelP
const endRemaining = total % rowSize; const endRemaining = total % rowSize;
// First row // First row
if (total === 1) { return "top"; } if (total === 1) {
if (index === 0) { return "top left"; } return "top";
if (index === rowSize - 1 || (total < rowSize && index === total - 1)) { return "top right"; } }
if (index < rowSize) { 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 // Bottom row
if (isAtEnd && index === total - 1) { return "bottom right"; } if (isAtEnd && index === total - 1) {
if (isAtStart && index === total - rowSize) { return "bottom left"; } return "bottom right";
if (endRemaining !== 0 && index >= total - endRemaining) { return "bottom"; } }
if (endRemaining === 0 && index >= total - rowSize) { return "bottom"; } 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 // Everything else
if (isAtStart) { return "center left"; } if (isAtStart) {
if (isAtEnd) { return "center right"; } return "center left";
}
if (isAtEnd) {
return "center right";
}
return "center"; return "center";
} }
function maybeRenderScenes() { function maybeRenderScenes() {
if (props.scenes === undefined) { return; } if (props.scenes === undefined) {
return;
}
return props.scenes.map((scene, index) => { return props.scenes.map((scene, index) => {
const origin = getOrigin(index, 5, props.scenes!.length); const origin = getOrigin(index, 5, props.scenes!.length);
return ( return (
@@ -56,7 +82,9 @@ export const WallPanel: FunctionComponent<IWallPanelProps> = (props: IWallPanelP
} }
function maybeRenderSceneMarkers() { function maybeRenderSceneMarkers() {
if (props.sceneMarkers === undefined) { return; } if (props.sceneMarkers === undefined) {
return;
}
return props.sceneMarkers.map((marker, index) => { return props.sceneMarkers.map((marker, index) => {
const origin = getOrigin(index, 5, props.sceneMarkers!.length); const origin = getOrigin(index, 5, props.sceneMarkers!.length);
return ( return (

View File

@@ -1,9 +1,12 @@
import _ from "lodash"; import _ from "lodash";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { Button, Form, Modal, OverlayTrigger, Tooltip } from 'react-bootstrap' import { Button, Form, Modal, OverlayTrigger, Tooltip } from "react-bootstrap";
import { Icon , FilterSelect } from 'src/components/Shared'; import { Icon, FilterSelect } from "src/components/Shared";
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { Criterion, CriterionType } from "src/models/list-filter/criteria/criterion"; import {
Criterion,
CriterionType
} from "src/models/list-filter/criteria/criterion";
import { NoneCriterion } from "src/models/list-filter/criteria/none"; import { NoneCriterion } from "src/models/list-filter/criteria/none";
import { PerformersCriterion } from "src/models/list-filter/criteria/performers"; import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
@@ -11,7 +14,6 @@ import { TagsCriterion } from "src/models/list-filter/criteria/tags";
import { makeCriteria } from "src/models/list-filter/criteria/utils"; import { makeCriteria } from "src/models/list-filter/criteria/utils";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
interface IAddFilterProps { interface IAddFilterProps {
onAddCriterion: (criterion: Criterion, oldId?: string) => void; onAddCriterion: (criterion: Criterion, oldId?: string) => void;
onCancel: () => void; onCancel: () => void;
@@ -19,17 +21,23 @@ interface IAddFilterProps {
editingCriterion?: Criterion; editingCriterion?: Criterion;
} }
export const AddFilter: React.FC<IAddFilterProps> = (props: IAddFilterProps) => { export const AddFilter: React.FC<IAddFilterProps> = (
const defaultValue= useRef<string|number|undefined>(); props: IAddFilterProps
) => {
const defaultValue = useRef<string | number | undefined>();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [criterion, setCriterion] = useState<Criterion<any, any>>(new NoneCriterion()); const [criterion, setCriterion] = useState<Criterion<any, any>>(
new NoneCriterion()
);
const valueStage = useRef<any>(criterion.value); const valueStage = useRef<any>(criterion.value);
// Configure if we are editing an existing criterion // Configure if we are editing an existing criterion
useEffect(() => { useEffect(() => {
if (!props.editingCriterion) { return; } if (!props.editingCriterion) {
return;
}
setIsOpen(true); setIsOpen(true);
setCriterion(props.editingCriterion); setCriterion(props.editingCriterion);
}, [props.editingCriterion]); }, [props.editingCriterion]);
@@ -40,7 +48,9 @@ export const AddFilter: React.FC<IAddFilterProps> = (props: IAddFilterProps) =>
setCriterion(newCriterion); setCriterion(newCriterion);
} }
function onChangedModifierSelect(event: React.ChangeEvent<HTMLSelectElement>) { function onChangedModifierSelect(
event: React.ChangeEvent<HTMLSelectElement>
) {
const newCriterion = _.cloneDeep(criterion); const newCriterion = _.cloneDeep(criterion);
newCriterion.modifier = event.target.value as any; newCriterion.modifier = event.target.value as any;
setCriterion(newCriterion); setCriterion(newCriterion);
@@ -65,7 +75,10 @@ export const AddFilter: React.FC<IAddFilterProps> = (props: IAddFilterProps) =>
function onAddFilter() { function onAddFilter() {
if (!Array.isArray(criterion.value) && defaultValue.current) { if (!Array.isArray(criterion.value) && defaultValue.current) {
const value = defaultValue.current; const value = defaultValue.current;
if (criterion.options && (value === undefined || value === "" || typeof value === "number")) { if (
criterion.options &&
(value === undefined || value === "" || typeof value === "number")
) {
criterion.value = criterion.options[0]; criterion.value = criterion.options[0];
} else if (typeof value === "number" && value === undefined) { } else if (typeof value === "number" && value === undefined) {
criterion.value = 0; criterion.value = 0;
@@ -73,7 +86,9 @@ export const AddFilter: React.FC<IAddFilterProps> = (props: IAddFilterProps) =>
criterion.value = ""; criterion.value = "";
} }
} }
const oldId = props.editingCriterion ? props.editingCriterion.getId() : undefined; const oldId = props.editingCriterion
? props.editingCriterion.getId()
: undefined;
props.onAddCriterion(criterion, oldId); props.onAddCriterion(criterion, oldId);
onToggle(); onToggle();
} }
@@ -87,10 +102,14 @@ export const AddFilter: React.FC<IAddFilterProps> = (props: IAddFilterProps) =>
} }
const maybeRenderFilterPopoverContents = () => { const maybeRenderFilterPopoverContents = () => {
if (criterion.type === "none") { return; } if (criterion.type === "none") {
return;
}
function renderModifier() { function renderModifier() {
if (criterion.modifierOptions.length === 0) { return; } if (criterion.modifierOptions.length === 0) {
return;
}
return ( return (
<div> <div>
<Form.Control <Form.Control
@@ -98,8 +117,8 @@ export const AddFilter: React.FC<IAddFilterProps> = (props: IAddFilterProps) =>
onChange={onChangedModifierSelect} onChange={onChangedModifierSelect}
value={criterion.modifier} value={criterion.modifier}
> >
{ criterion.modifierOptions.map(c => ( {criterion.modifierOptions.map(c => (
<option value={c.value}>{c.label}</option> <option value={c.value}>{c.label}</option>
))} ))}
</Form.Control> </Form.Control>
</div> </div>
@@ -108,7 +127,10 @@ export const AddFilter: React.FC<IAddFilterProps> = (props: IAddFilterProps) =>
function renderSelect() { function renderSelect() {
// Hide the value select if the modifier is "IsNull" or "NotNull" // Hide the value select if the modifier is "IsNull" or "NotNull"
if (criterion.modifier === CriterionModifier.IsNull || criterion.modifier === CriterionModifier.NotNull) { if (
criterion.modifier === CriterionModifier.IsNull ||
criterion.modifier === CriterionModifier.NotNull
) {
return; return;
} }
@@ -127,61 +149,58 @@ export const AddFilter: React.FC<IAddFilterProps> = (props: IAddFilterProps) =>
return ( return (
<FilterSelect <FilterSelect
type={type} type={type}
onSelect={(items) => { onSelect={items => {
criterion.value = items.map(i => ({id: i.id, label: i.name!})) } criterion.value = items.map(i => ({ id: i.id, label: i.name! }));
} }}
initialIds={criterion.value.map((labeled: any) => labeled.id)} initialIds={criterion.value.map((labeled: any) => labeled.id)}
/> />
); );
} }
if (criterion.options) { if (criterion.options) {
defaultValue.current = criterion.value; defaultValue.current = criterion.value;
return ( return (
<Form.Control <Form.Control
as="select" as="select"
onChange={onChangedSingleSelect} onChange={onChangedSingleSelect}
value={criterion.value} value={criterion.value}
> >
{ criterion.options.map(c => ( {criterion.options.map(c => (
<option value={c}>{c}</option> <option value={c}>{c}</option>
))} ))}
</Form.Control> </Form.Control>
); );
} }
return ( return (
<Form.Control <Form.Control
type={criterion.inputType} type={criterion.inputType}
onChange={onChangedInput} onChange={onChangedInput}
onBlur={onBlurInput} onBlur={onBlurInput}
value={criterion.value || ""} value={criterion.value || ""}
/> />
) );
} }
return ( return (
<> <>
<Form.Group> <Form.Group>{renderModifier()}</Form.Group>
{renderModifier()} <Form.Group>{renderSelect()}</Form.Group>
</Form.Group>
<Form.Group>
{renderSelect()}
</Form.Group>
</> </>
); );
}; };
function maybeRenderFilterSelect() { function maybeRenderFilterSelect() {
if (props.editingCriterion) { return; } if (props.editingCriterion) {
return;
}
return ( return (
<Form.Group controlId="filter"> <Form.Group controlId="filter">
<Form.Label>Filter</Form.Label> <Form.Label>Filter</Form.Label>
<Form.Control <Form.Control
as="select" as="select"
onChange={onChangedCriteriaType} onChange={onChangedCriteriaType}
value={criterion.type}> value={criterion.type}
{ props.filter.criterionOptions.map(c => ( >
<option value={c.value}>{c.label}</option> {props.filter.criterionOptions.map(c => (
<option value={c.value}>{c.label}</option>
))} ))}
</Form.Control> </Form.Control>
</Form.Group> </Form.Group>
@@ -195,17 +214,12 @@ export const AddFilter: React.FC<IAddFilterProps> = (props: IAddFilterProps) =>
placement="top" placement="top"
overlay={<Tooltip id="filter-tooltip">Filter</Tooltip>} overlay={<Tooltip id="filter-tooltip">Filter</Tooltip>}
> >
<Button <Button onClick={() => onToggle()} active={isOpen}>
onClick={() => onToggle()}
active={isOpen}
>
<Icon icon="filter" /> <Icon icon="filter" />
</Button> </Button>
</OverlayTrigger> </OverlayTrigger>
<Modal <Modal show={isOpen} onHide={() => onToggle()}>
show={isOpen}
onHide={() => onToggle()}>
<Modal.Header>{title}</Modal.Header> <Modal.Header>{title}</Modal.Header>
<Modal.Body> <Modal.Body>
<div className="dialog-content"> <div className="dialog-content">
@@ -214,7 +228,9 @@ export const AddFilter: React.FC<IAddFilterProps> = (props: IAddFilterProps) =>
</div> </div>
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button onClick={onAddFilter} disabled={criterion.type === "none"}>{title}</Button> <Button onClick={onAddFilter} disabled={criterion.type === "none"}>
{title}
</Button>
</Modal.Footer> </Modal.Footer>
</Modal> </Modal>
</> </>

View File

@@ -1,8 +1,16 @@
import { debounce } from "lodash"; import { debounce } from "lodash";
import React, { SyntheticEvent, useCallback, useState } from "react"; import React, { SyntheticEvent, useCallback, useState } from "react";
import { Badge, Button, ButtonGroup, Dropdown, Form, OverlayTrigger, Tooltip } from 'react-bootstrap'; import {
Badge,
Button,
ButtonGroup,
Dropdown,
Form,
OverlayTrigger,
Tooltip
} from "react-bootstrap";
import { Icon } from 'src/components/Shared'; import { Icon } from "src/components/Shared";
import { Criterion } from "src/models/list-filter/criteria/criterion"; import { Criterion } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
@@ -31,14 +39,19 @@ interface IListFilterProps {
const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120"]; const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120"];
export const ListFilter: React.FC<IListFilterProps> = (props: IListFilterProps) => { export const ListFilter: React.FC<IListFilterProps> = (
props: IListFilterProps
) => {
const searchCallback = useCallback( const searchCallback = useCallback(
debounce((event: any) => { debounce((event: any) => {
props.onChangeQuery(event.target.value); props.onChangeQuery(event.target.value);
}, 500), [props.onChangeQuery] }, 500),
[props.onChangeQuery]
); );
const [editingCriterion, setEditingCriterion] = useState<Criterion | undefined>(undefined); const [editingCriterion, setEditingCriterion] = useState<
Criterion | undefined
>(undefined);
function onChangePageSize(event: SyntheticEvent<HTMLSelectElement>) { function onChangePageSize(event: SyntheticEvent<HTMLSelectElement>) {
const val = event!.currentTarget!.value; const val = event!.currentTarget!.value;
@@ -76,39 +89,55 @@ export const ListFilter: React.FC<IListFilterProps> = (props: IListFilterProps)
let removedCriterionId = ""; let removedCriterionId = "";
function onRemoveCriterionTag(criterion?: Criterion) { function onRemoveCriterionTag(criterion?: Criterion) {
if (!criterion) { return; } if (!criterion) {
return;
}
setEditingCriterion(undefined); setEditingCriterion(undefined);
removedCriterionId = criterion.getId(); removedCriterionId = criterion.getId();
props.onRemoveCriterion(criterion); props.onRemoveCriterion(criterion);
} }
function onClickCriterionTag(criterion?: Criterion) { function onClickCriterionTag(criterion?: Criterion) {
if (!criterion || removedCriterionId !== "") { return; } if (!criterion || removedCriterionId !== "") {
return;
}
setEditingCriterion(criterion); setEditingCriterion(criterion);
} }
function renderSortByOptions() { function renderSortByOptions() {
return props.filter.sortByOptions.map((option) => ( return props.filter.sortByOptions.map(option => (
<Dropdown.Item onClick={onChangeSortBy} key={option}>{option}</Dropdown.Item> <Dropdown.Item onClick={onChangeSortBy} key={option}>
{option}
</Dropdown.Item>
)); ));
} }
function renderDisplayModeOptions() { function renderDisplayModeOptions() {
function getIcon(option: DisplayMode) { function getIcon(option: DisplayMode) {
switch (option) { switch (option) {
case DisplayMode.Grid: return "th-large"; case DisplayMode.Grid:
case DisplayMode.List: return "list"; return "th-large";
case DisplayMode.Wall: return "square"; case DisplayMode.List:
return "list";
case DisplayMode.Wall:
return "square";
} }
} }
function getLabel(option: DisplayMode) { function getLabel(option: DisplayMode) {
switch (option) { switch (option) {
case DisplayMode.Grid: return "Grid"; case DisplayMode.Grid:
case DisplayMode.List: return "List"; return "Grid";
case DisplayMode.Wall: return "Wall"; case DisplayMode.List:
return "List";
case DisplayMode.Wall:
return "Wall";
} }
} }
return props.filter.displayModeOptions.map((option) => ( return props.filter.displayModeOptions.map(option => (
<OverlayTrigger overlay={<Tooltip id="display-mode-tooltip">{getLabel(option)}</Tooltip>}> <OverlayTrigger
overlay={
<Tooltip id="display-mode-tooltip">{getLabel(option)}</Tooltip>
}
>
<Button <Button
key={option} key={option}
active={props.filter.displayMode === option} active={props.filter.displayMode === option}
@@ -121,7 +150,7 @@ export const ListFilter: React.FC<IListFilterProps> = (props: IListFilterProps)
} }
function renderFilterTags() { function renderFilterTags() {
return props.filter.criteria.map((criterion) => ( return props.filter.criteria.map(criterion => (
<Badge <Badge
className="tag-item" className="tag-item"
variant="secondary" variant="secondary"
@@ -149,25 +178,30 @@ export const ListFilter: React.FC<IListFilterProps> = (props: IListFilterProps)
function renderSelectAll() { function renderSelectAll() {
if (props.onSelectAll) { if (props.onSelectAll) {
return <Dropdown.Item onClick={() => onSelectAll()}>Select All</Dropdown.Item>; return (
<Dropdown.Item onClick={() => onSelectAll()}>Select All</Dropdown.Item>
);
} }
} }
function renderSelectNone() { function renderSelectNone() {
if (props.onSelectNone) { if (props.onSelectNone) {
return <Dropdown.Item onClick={() => onSelectNone()}>Select None</Dropdown.Item>; return (
<Dropdown.Item onClick={() => onSelectNone()}>
Select None
</Dropdown.Item>
);
} }
} }
function renderMore() { function renderMore() {
const options = [ const options = [renderSelectAll(), renderSelectNone()];
renderSelectAll(),
renderSelectNone()
];
if (props.otherOperations) { if (props.otherOperations) {
props.otherOperations.forEach((o) => { props.otherOperations.forEach(o => {
options.push(<Dropdown.Item onClick={o.onClick}>{o.text}</Dropdown.Item>); options.push(
<Dropdown.Item onClick={o.onClick}>{o.text}</Dropdown.Item>
);
}); });
} }
@@ -179,15 +213,13 @@ export const ListFilter: React.FC<IListFilterProps> = (props: IListFilterProps)
<Icon icon="ellipsis-h" /> <Icon icon="ellipsis-h" />
</Button> </Button>
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu> <Dropdown.Menu>{options}</Dropdown.Menu>
{options}
</Dropdown.Menu>
</Dropdown> </Dropdown>
); );
} }
} }
function onChangeZoom(v : number) { function onChangeZoom(v: number) {
if (props.onChangeZoom) { if (props.onChangeZoom) {
props.onChangeZoom(v); props.onChangeZoom(v);
} }
@@ -201,9 +233,11 @@ export const ListFilter: React.FC<IListFilterProps> = (props: IListFilterProps)
type="range" type="range"
min={0} min={0}
max={3} max={3}
onChange={(event: any) => onChangeZoom(Number.parseInt(event.target.value, 10))} onChange={(event: any) =>
onChangeZoom(Number.parseInt(event.target.value, 10))
}
/> />
</span> </span>
); );
} }
} }
@@ -224,23 +258,35 @@ export const ListFilter: React.FC<IListFilterProps> = (props: IListFilterProps)
value={props.filter.itemsPerPage.toString()} value={props.filter.itemsPerPage.toString()}
className="filter-item" className="filter-item"
> >
{ PAGE_SIZE_OPTIONS.map(s => <option value={s}>{s}</option>) } {PAGE_SIZE_OPTIONS.map(s => (
<option value={s}>{s}</option>
))}
</Form.Control> </Form.Control>
<ButtonGroup className="filter-item"> <ButtonGroup className="filter-item">
<Dropdown> <Dropdown>
<Dropdown.Toggle variant="secondary" id="more-menu"> <Dropdown.Toggle variant="secondary" id="more-menu">
<Button>{props.filter.sortBy}</Button> <Button>{props.filter.sortBy}</Button>
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu> <Dropdown.Menu>{renderSortByOptions()}</Dropdown.Menu>
{renderSortByOptions()}
</Dropdown.Menu>
</Dropdown> </Dropdown>
<OverlayTrigger overlay={ <OverlayTrigger
<Tooltip id="sort-direction-tooltip">{props.filter.sortDirection === "asc" ? "Ascending" : "Descending"}</Tooltip> overlay={
}> <Tooltip id="sort-direction-tooltip">
{props.filter.sortDirection === "asc"
? "Ascending"
: "Descending"}
</Tooltip>
}
>
<Button onClick={onChangeSortDirection}> <Button onClick={onChangeSortDirection}>
<Icon icon={props.filter.sortDirection === "asc" ? "caret-up" : "caret-down"} /> <Icon
icon={
props.filter.sortDirection === "asc"
? "caret-up"
: "caret-down"
}
/>
</Button> </Button>
</OverlayTrigger> </OverlayTrigger>
</ButtonGroup> </ButtonGroup>
@@ -258,11 +304,15 @@ export const ListFilter: React.FC<IListFilterProps> = (props: IListFilterProps)
{maybeRenderZoom()} {maybeRenderZoom()}
<ButtonGroup className="filter-item"> <ButtonGroup className="filter-item">{renderMore()}</ButtonGroup>
{renderMore()}
</ButtonGroup>
</div> </div>
<div style={{display: "flex", justifyContent: "center", margin: "10px auto"}}> <div
style={{
display: "flex",
justifyContent: "center",
margin: "10px auto"
}}
>
{renderFilterTags()} {renderFilterTags()}
</div> </div>
</> </>

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Button, ButtonGroup } from 'react-bootstrap'; import { Button, ButtonGroup } from "react-bootstrap";
interface IPaginationProps { interface IPaginationProps {
itemsPerPage: number; itemsPerPage: number;
@@ -8,7 +8,12 @@ interface IPaginationProps {
onChangePage: (page: number) => void; onChangePage: (page: number) => void;
} }
export const Pagination: React.FC<IPaginationProps> = ({ itemsPerPage, currentPage, totalItems, onChangePage }) => { export const Pagination: React.FC<IPaginationProps> = ({
itemsPerPage,
currentPage,
totalItems,
onChangePage
}) => {
const totalPages = Math.ceil(totalItems / itemsPerPage); const totalPages = Math.ceil(totalItems / itemsPerPage);
let startPage: number; let startPage: number;
@@ -28,35 +33,44 @@ export const Pagination: React.FC<IPaginationProps> = ({ itemsPerPage, currentPa
endPage = currentPage + 4; endPage = currentPage + 4;
} }
const pages = [...Array((endPage + 1) - startPage).keys()].map((i) => startPage + i); const pages = [...Array(endPage + 1 - startPage).keys()].map(
i => startPage + i
);
const pageButtons = pages.map((page: number) => ( const pageButtons = pages.map((page: number) => (
<Button <Button
key={page} key={page}
active={currentPage === page} active={currentPage === page}
onClick={() => onChangePage(page)} onClick={() => onChangePage(page)}
>{page}</Button> >
{page}
</Button>
)); ));
return ( return (
<ButtonGroup className="filter-container"> <ButtonGroup className="filter-container">
<Button <Button disabled={currentPage === 1} onClick={() => onChangePage(1)}>
disabled={currentPage === 1} First
onClick={() => onChangePage(1)} </Button>
>First</Button>
<Button <Button
disabled={currentPage === 1} disabled={currentPage === 1}
onClick={() => onChangePage(currentPage - 1)} onClick={() => onChangePage(currentPage - 1)}
>Previous</Button> >
Previous
</Button>
{pageButtons} {pageButtons}
<Button <Button
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
onClick={() => onChangePage(currentPage + 1)} onClick={() => onChangePage(currentPage + 1)}
>Next</Button> >
Next
</Button>
<Button <Button
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
onClick={() => onChangePage(totalPages)} onClick={() => onChangePage(totalPages)}
>Last</Button> >
Last
</Button>
</ButtonGroup> </ButtonGroup>
); );
} };

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Card } from 'react-bootstrap'; import { Card } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { NavUtils, TextUtils } from "src/utils"; import { NavUtils, TextUtils } from "src/utils";
@@ -9,17 +9,19 @@ interface IPerformerCardProps {
ageFromDate?: string; ageFromDate?: string;
} }
export const PerformerCard: React.FC<IPerformerCardProps> = (props: IPerformerCardProps) => { export const PerformerCard: React.FC<IPerformerCardProps> = (
props: IPerformerCardProps
) => {
const age = TextUtils.age(props.performer.birthdate, props.ageFromDate); const age = TextUtils.age(props.performer.birthdate, props.ageFromDate);
const ageString = `${age} years old${props.ageFromDate ? " in this scene." : "."}`; const ageString = `${age} years old${
props.ageFromDate ? " in this scene." : "."
}`;
function maybeRenderFavoriteBanner() { function maybeRenderFavoriteBanner() {
if (props.performer.favorite === false) { return; } if (props.performer.favorite === false) {
return ( return;
<div className="rating-banner rating-5"> }
FAVORITE return <div className="rating-banner rating-5">FAVORITE</div>;
</div>
);
} }
return ( return (
@@ -27,16 +29,19 @@ export const PerformerCard: React.FC<IPerformerCardProps> = (props: IPerformerCa
<Link <Link
to={`/performers/${props.performer.id}`} to={`/performers/${props.performer.id}`}
className="performer previewable image" className="performer previewable image"
style={{backgroundImage: `url(${props.performer.image_path})`}} style={{ backgroundImage: `url(${props.performer.image_path})` }}
> >
{maybeRenderFavoriteBanner()} {maybeRenderFavoriteBanner()}
</Link> </Link>
<div className="card-section"> <div className="card-section">
<h4 className="text-truncate"> <h4 className="text-truncate">{props.performer.name}</h4>
{props.performer.name} {age !== 0 ? <div>{ageString}</div> : ""}
</h4> <span>
{age !== 0 ? <div>{ageString}</div> : ''} Stars in {props.performer.scene_count}{" "}
<span>Stars in {props.performer.scene_count} <Link to={NavUtils.makePerformerScenesUrl(props.performer)}>scenes</Link>. <Link to={NavUtils.makePerformerScenesUrl(props.performer)}>
scenes
</Link>
.
</span> </span>
</div> </div>
</Card> </Card>

View File

@@ -1,24 +1,33 @@
/* eslint-disable react/no-this-in-sfc */ /* eslint-disable react/no-this-in-sfc */
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, Form, Spinner, Table } from 'react-bootstrap'; import { Button, Form, Spinner, Table } from "react-bootstrap";
import { useParams, useHistory } from 'react-router-dom'; import { useParams, useHistory } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { DetailsEditNavbar, Icon, Modal, ScrapePerformerSuggest } from "src/components/Shared"; import {
import { ImageUtils, TableUtils } from 'src/utils' DetailsEditNavbar,
import { useToast } from 'src/hooks'; Icon,
Modal,
ScrapePerformerSuggest
} from "src/components/Shared";
import { ImageUtils, TableUtils } from "src/utils";
import { useToast } from "src/hooks";
export const Performer: React.FC = () => { export const Performer: React.FC = () => {
const Toast = useToast(); const Toast = useToast();
const history = useHistory(); const history = useHistory();
const { id = 'new' } = useParams(); const { id = "new" } = useParams();
const isNew = id === "new"; const isNew = id === "new";
// Editing state // Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew); const [isEditing, setIsEditing] = useState<boolean>(isNew);
const [isDisplayingScraperDialog, setIsDisplayingScraperDialog] = useState<GQL.ListPerformerScrapersListPerformerScrapers | undefined>(undefined); const [isDisplayingScraperDialog, setIsDisplayingScraperDialog] = useState<
const [scrapePerformerDetails, setScrapePerformerDetails] = useState<GQL.ScrapePerformerListScrapePerformerList | undefined>(undefined); GQL.ListPerformerScrapersListPerformerScrapers | undefined
>(undefined);
const [scrapePerformerDetails, setScrapePerformerDetails] = useState<
GQL.ScrapePerformerListScrapePerformerList | undefined
>(undefined);
// Editing performer state // Editing performer state
const [image, setImage] = useState<string | undefined>(undefined); const [image, setImage] = useState<string | undefined>(undefined);
@@ -30,9 +39,13 @@ export const Performer: React.FC = () => {
const [country, setCountry] = useState<string | undefined>(undefined); const [country, setCountry] = useState<string | undefined>(undefined);
const [eyeColor, setEyeColor] = useState<string | undefined>(undefined); const [eyeColor, setEyeColor] = useState<string | undefined>(undefined);
const [height, setHeight] = useState<string | undefined>(undefined); const [height, setHeight] = useState<string | undefined>(undefined);
const [measurements, setMeasurements] = useState<string | undefined>(undefined); const [measurements, setMeasurements] = useState<string | undefined>(
undefined
);
const [fakeTits, setFakeTits] = useState<string | undefined>(undefined); const [fakeTits, setFakeTits] = useState<string | undefined>(undefined);
const [careerLength, setCareerLength] = useState<string | undefined>(undefined); const [careerLength, setCareerLength] = useState<string | undefined>(
undefined
);
const [tattoos, setTattoos] = useState<string | undefined>(undefined); const [tattoos, setTattoos] = useState<string | undefined>(undefined);
const [piercings, setPiercings] = useState<string | undefined>(undefined); const [piercings, setPiercings] = useState<string | undefined>(undefined);
const [url, setUrl] = useState<string | undefined>(undefined); const [url, setUrl] = useState<string | undefined>(undefined);
@@ -40,21 +53,35 @@ export const Performer: React.FC = () => {
const [instagram, setInstagram] = useState<string | undefined>(undefined); const [instagram, setInstagram] = useState<string | undefined>(undefined);
// Performer state // Performer state
const [performer, setPerformer] = useState<Partial<GQL.PerformerDataFragment>>({}); const [performer, setPerformer] = useState<
const [imagePreview, setImagePreview] = useState<string | undefined>(undefined); Partial<GQL.PerformerDataFragment>
>({});
const [imagePreview, setImagePreview] = useState<string | undefined>(
undefined
);
// Network state // Network state
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const Scrapers = StashService.useListPerformerScrapers(); const Scrapers = StashService.useListPerformerScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.ListPerformerScrapersListPerformerScrapers[]>([]); const [queryableScrapers, setQueryableScrapers] = useState<
GQL.ListPerformerScrapersListPerformerScrapers[]
>([]);
const { data, error } = StashService.useFindPerformer(id); const { data, error } = StashService.useFindPerformer(id);
const updatePerformer = StashService.usePerformerUpdate(getPerformerInput() as GQL.PerformerUpdateInput); const updatePerformer = StashService.usePerformerUpdate(
const createPerformer = StashService.usePerformerCreate(getPerformerInput() as GQL.PerformerCreateInput); getPerformerInput() as GQL.PerformerUpdateInput
const deletePerformer = StashService.usePerformerDestroy(getPerformerInput() as GQL.PerformerDestroyInput); );
const createPerformer = StashService.usePerformerCreate(
getPerformerInput() as GQL.PerformerCreateInput
);
const deletePerformer = StashService.usePerformerDestroy(
getPerformerInput() as GQL.PerformerDestroyInput
);
function updatePerformerEditState(state: Partial<GQL.PerformerDataFragment | GQL.ScrapeFreeonesScrapeFreeones>) { function updatePerformerEditState(
state: Partial<GQL.PerformerDataFragment | GQL.ScrapeFreeonesScrapeFreeones>
) {
if ((state as GQL.PerformerDataFragment).favorite !== undefined) { if ((state as GQL.PerformerDataFragment).favorite !== undefined) {
setFavorite((state as GQL.PerformerDataFragment).favorite); setFavorite((state as GQL.PerformerDataFragment).favorite);
} }
@@ -77,8 +104,7 @@ export const Performer: React.FC = () => {
useEffect(() => { useEffect(() => {
setIsLoading(false); setIsLoading(false);
if(data?.findPerformer) if (data?.findPerformer) setPerformer(data.findPerformer);
setPerformer(data.findPerformer);
}, [data]); }, [data]);
useEffect(() => { useEffect(() => {
@@ -96,25 +122,28 @@ export const Performer: React.FC = () => {
ImageUtils.usePasteImage(onImageLoad); ImageUtils.usePasteImage(onImageLoad);
useEffect(() => { useEffect(() => {
let newQueryableScrapers : GQL.ListPerformerScrapersListPerformerScrapers[] = []; let newQueryableScrapers: GQL.ListPerformerScrapersListPerformerScrapers[] = [];
if (!!Scrapers.data && Scrapers.data.listPerformerScrapers) { if (!!Scrapers.data && Scrapers.data.listPerformerScrapers) {
newQueryableScrapers = Scrapers.data.listPerformerScrapers.filter((s) => { newQueryableScrapers = Scrapers.data.listPerformerScrapers.filter(s => {
return s.performer && s.performer.supported_scrapes.includes(GQL.ScrapeType.Name); return (
s.performer &&
s.performer.supported_scrapes.includes(GQL.ScrapeType.Name)
);
}); });
} }
setQueryableScrapers(newQueryableScrapers); setQueryableScrapers(newQueryableScrapers);
}, [Scrapers.data]); }, [Scrapers.data]);
if ((!isNew && !isEditing && !data?.findPerformer) || isLoading) if ((!isNew && !isEditing && !data?.findPerformer) || isLoading)
return <Spinner animation="border" variant="light" />; return <Spinner animation="border" variant="light" />;
if (error) if (error) return <div>{error.message}</div>;
return <div>{error.message}</div>;
function getPerformerInput() { function getPerformerInput() {
const performerInput: Partial<GQL.PerformerCreateInput | GQL.PerformerUpdateInput> = { const performerInput: Partial<
GQL.PerformerCreateInput | GQL.PerformerUpdateInput
> = {
name, name,
aliases, aliases,
favorite, favorite,
@@ -131,7 +160,7 @@ export const Performer: React.FC = () => {
url, url,
twitter, twitter,
instagram, instagram,
image, image
}; };
if (!isNew) { if (!isNew) {
@@ -167,7 +196,7 @@ export const Performer: React.FC = () => {
setIsLoading(false); setIsLoading(false);
// redirect to performers page // redirect to performers page
history.push('/performers'); history.push("/performers");
} }
async function onAutoTag() { async function onAutoTag() {
@@ -175,7 +204,7 @@ export const Performer: React.FC = () => {
return; return;
} }
try { try {
await StashService.queryMetadataAutoTag({ performers: [performer.id]}); await StashService.queryMetadataAutoTag({ performers: [performer.id] });
Toast.success({ content: "Started auto tagging" }); Toast.success({ content: "Started auto tagging" });
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -186,13 +215,14 @@ export const Performer: React.FC = () => {
ImageUtils.onImageChange(event, onImageLoad); ImageUtils.onImageChange(event, onImageLoad);
} }
function onDisplayFreeOnesDialog(scraper: GQL.ListPerformerScrapersListPerformerScrapers) { function onDisplayFreeOnesDialog(
scraper: GQL.ListPerformerScrapersListPerformerScrapers
) {
setIsDisplayingScraperDialog(scraper); setIsDisplayingScraperDialog(scraper);
} }
function getQueryScraperPerformerInput() { function getQueryScraperPerformerInput() {
if (!scrapePerformerDetails) if (!scrapePerformerDetails) return {};
return {};
const { __typename, ...ret } = scrapePerformerDetails; const { __typename, ...ret } = scrapePerformerDetails;
return ret; return ret;
@@ -202,11 +232,12 @@ export const Performer: React.FC = () => {
setIsDisplayingScraperDialog(undefined); setIsDisplayingScraperDialog(undefined);
setIsLoading(true); setIsLoading(true);
try { try {
if (!scrapePerformerDetails || !isDisplayingScraperDialog) if (!scrapePerformerDetails || !isDisplayingScraperDialog) return;
return; const result = await StashService.queryScrapePerformer(
const result = await StashService.queryScrapePerformer(isDisplayingScraperDialog.id, getQueryScraperPerformerInput()); isDisplayingScraperDialog.id,
if (!result?.data?.scrapePerformer) getQueryScraperPerformerInput()
return; );
if (!result?.data?.scrapePerformer) return;
updatePerformerEditState(result.data.scrapePerformer); updatePerformerEditState(result.data.scrapePerformer);
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -215,12 +246,13 @@ export const Performer: React.FC = () => {
} }
async function onScrapePerformerURL() { async function onScrapePerformerURL() {
if (!url) if (!url) return;
return;
setIsLoading(true); setIsLoading(true);
try { try {
const result = await StashService.queryScrapePerformerURL(url); const result = await StashService.queryScrapePerformerURL(url);
if (!result.data || !result.data.scrapePerformerURL) { return; } if (!result.data || !result.data.scrapePerformerURL) {
return;
}
updatePerformerEditState(result.data.scrapePerformerURL); updatePerformerEditState(result.data.scrapePerformerURL);
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -235,7 +267,7 @@ export const Performer: React.FC = () => {
value: ethnicity, value: ethnicity,
isEditing, isEditing,
onChange: (value: string) => setEthnicity(value), onChange: (value: string) => setEthnicity(value),
selectOptions: ["white", "black", "asian", "hispanic"], selectOptions: ["white", "black", "asian", "hispanic"]
}); });
} }
@@ -250,8 +282,10 @@ export const Performer: React.FC = () => {
<div className="dialog-content"> <div className="dialog-content">
<ScrapePerformerSuggest <ScrapePerformerSuggest
placeholder="Performer name" placeholder="Performer name"
scraperId={isDisplayingScraperDialog ? isDisplayingScraperDialog.id : ""} scraperId={
onSelectPerformer={(query) => setScrapePerformerDetails(query)} isDisplayingScraperDialog ? isDisplayingScraperDialog.id : ""
}
onSelectPerformer={query => setScrapePerformerDetails(query)}
/> />
</div> </div>
</Modal> </Modal>
@@ -259,9 +293,12 @@ export const Performer: React.FC = () => {
} }
function urlScrapable(scrapedUrl: string) { function urlScrapable(scrapedUrl: string) {
return !!scrapedUrl && (Scrapers?.data?.listPerformerScrapers ?? []).some(s => ( return (
(s?.performer?.urls ?? []).some(u => scrapedUrl.includes(u)) !!scrapedUrl &&
)); (Scrapers?.data?.listPerformerScrapers ?? []).some(s =>
(s?.performer?.urls ?? []).some(u => scrapedUrl.includes(u))
)
);
} }
function maybeRenderScrapeButton() { function maybeRenderScrapeButton() {
@@ -269,12 +306,10 @@ export const Performer: React.FC = () => {
return undefined; return undefined;
} }
return ( return (
<Button <Button id="scrape-url-button" onClick={() => onScrapePerformerURL()}>
id="scrape-url-button"
onClick={() => onScrapePerformerURL()}>
<Icon icon="file-upload" /> <Icon icon="file-upload" />
</Button> </Button>
) );
} }
function renderURLField() { function renderURLField() {
@@ -290,7 +325,9 @@ export const Performer: React.FC = () => {
readOnly={!isEditing} readOnly={!isEditing}
plaintext={!isEditing} plaintext={!isEditing}
placeholder="URL" placeholder="URL"
onChange={(event: React.FormEvent<HTMLInputElement>) => setUrl(event.currentTarget.value) } onChange={(event: React.FormEvent<HTMLInputElement>) =>
setUrl(event.currentTarget.value)
}
/> />
</td> </td>
</tr> </tr>
@@ -309,7 +346,10 @@ export const Performer: React.FC = () => {
performer={performer} performer={performer}
isNew={isNew} isNew={isNew}
isEditing={isEditing} isEditing={isEditing}
onToggleEdit={() => { setIsEditing(!isEditing); updatePerformerEditState(performer); }} onToggleEdit={() => {
setIsEditing(!isEditing);
updatePerformerEditState(performer);
}}
onSave={onSave} onSave={onSave}
onDelete={onDelete} onDelete={onDelete}
onImageChange={onImageChangeHandler} onImageChange={onImageChangeHandler}
@@ -319,12 +359,12 @@ export const Performer: React.FC = () => {
/> />
<h1> <h1>
<Form.Control <Form.Control
readOnly={!isEditing} readOnly={!isEditing}
plaintext={!isEditing} plaintext={!isEditing}
defaultValue={name} defaultValue={name}
placeholder="Name" placeholder="Name"
onChange={(event: any) => setName(event.target.value)} onChange={(event: any) => setName(event.target.value)}
/> />
</h1> </h1>
<h6> <h6>
<Form.Group className="aliases-field" controlId="aliases"> <Form.Group className="aliases-field" controlId="aliases">
@@ -334,12 +374,14 @@ export const Performer: React.FC = () => {
readOnly={!isEditing} readOnly={!isEditing}
plaintext={!isEditing} plaintext={!isEditing}
placeholder="Aliases" placeholder="Aliases"
onChange={(event: React.FormEvent<HTMLInputElement>) => setAliases(event.currentTarget.value) } onChange={(event: React.FormEvent<HTMLInputElement>) =>
setAliases(event.currentTarget.value)
}
/> />
</Form.Group> </Form.Group>
</h6> </h6>
<div> <div>
<span style={{fontWeight: 300}}>Favorite:</span> <span style={{ fontWeight: 300 }}>Favorite:</span>
<Button <Button
disabled={!isEditing} disabled={!isEditing}
className={favorite ? "favorite" : undefined} className={favorite ? "favorite" : undefined}
@@ -349,32 +391,76 @@ export const Performer: React.FC = () => {
</Button> </Button>
</div> </div>
<Table id="performer-details" style={{width: "100%"}}> <Table id="performer-details" style={{ width: "100%" }}>
<tbody> <tbody>
{TableUtils.renderInputGroup( {TableUtils.renderInputGroup({
{title: "Birthdate (YYYY-MM-DD)", value: birthdate, isEditing, onChange: setBirthdate})} title: "Birthdate (YYYY-MM-DD)",
value: birthdate,
isEditing,
onChange: setBirthdate
})}
{renderEthnicity()} {renderEthnicity()}
{TableUtils.renderInputGroup( {TableUtils.renderInputGroup({
{title: "Eye Color", value: eyeColor, isEditing, onChange: setEyeColor})} title: "Eye Color",
{TableUtils.renderInputGroup( value: eyeColor,
{title: "Country", value: country, isEditing, onChange: setCountry})} isEditing,
{TableUtils.renderInputGroup( onChange: setEyeColor
{title: "Height (CM)", value: height, isEditing, onChange: setHeight})} })}
{TableUtils.renderInputGroup( {TableUtils.renderInputGroup({
{title: "Measurements", value: measurements, isEditing, onChange: setMeasurements})} title: "Country",
{TableUtils.renderInputGroup( value: country,
{title: "Fake Tits", value: fakeTits, isEditing, onChange: setFakeTits})} isEditing,
{TableUtils.renderInputGroup( onChange: setCountry
{title: "Career Length", value: careerLength, isEditing, onChange: setCareerLength})} })}
{TableUtils.renderInputGroup( {TableUtils.renderInputGroup({
{title: "Tattoos", value: tattoos, isEditing, onChange: setTattoos})} title: "Height (CM)",
{TableUtils.renderInputGroup( value: height,
{title: "Piercings", value: piercings, isEditing, onChange: setPiercings})} isEditing,
onChange: setHeight
})}
{TableUtils.renderInputGroup({
title: "Measurements",
value: measurements,
isEditing,
onChange: setMeasurements
})}
{TableUtils.renderInputGroup({
title: "Fake Tits",
value: fakeTits,
isEditing,
onChange: setFakeTits
})}
{TableUtils.renderInputGroup({
title: "Career Length",
value: careerLength,
isEditing,
onChange: setCareerLength
})}
{TableUtils.renderInputGroup({
title: "Tattoos",
value: tattoos,
isEditing,
onChange: setTattoos
})}
{TableUtils.renderInputGroup({
title: "Piercings",
value: piercings,
isEditing,
onChange: setPiercings
})}
{renderURLField()} {renderURLField()}
{TableUtils.renderInputGroup( {TableUtils.renderInputGroup({
{title: "Twitter", value: twitter, isEditing, onChange: setTwitter})} title: "Twitter",
{TableUtils.renderInputGroup( value: twitter,
{title: "Instagram", value: instagram, isEditing, onChange: setInstagram})} isEditing,
onChange: setTwitter
})}
{TableUtils.renderInputGroup({
title: "Instagram",
value: instagram,
isEditing,
onChange: setInstagram
})}
</tbody> </tbody>
</Table> </Table>
</div> </div>

View File

@@ -1,8 +1,11 @@
import _ from "lodash"; import _ from "lodash";
import React from "react"; import React from "react";
import { QueryHookResult } from "react-apollo-hooks"; import { QueryHookResult } from "react-apollo-hooks";
import { useHistory } from 'react-router-dom'; import { useHistory } from "react-router-dom";
import { FindPerformersQuery, FindPerformersVariables } from "src/core/generated-graphql"; import {
FindPerformersQuery,
FindPerformersVariables
} from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { usePerformersList } from "src/hooks"; import { usePerformersList } from "src/hooks";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
@@ -15,41 +18,60 @@ export const PerformerList: React.FC = () => {
const otherOperations = [ const otherOperations = [
{ {
text: "Open Random", text: "Open Random",
onClick: getRandom, onClick: getRandom
} }
]; ];
const listData = usePerformersList({ const listData = usePerformersList({
otherOperations, otherOperations,
renderContent, renderContent
}); });
async function getRandom(result: QueryHookResult<FindPerformersQuery, FindPerformersVariables>, filter: ListFilterModel) { async function getRandom(
result: QueryHookResult<FindPerformersQuery, FindPerformersVariables>,
filter: ListFilterModel
) {
if (result.data && result.data.findPerformers) { if (result.data && result.data.findPerformers) {
const {count} = result.data.findPerformers; const { count } = result.data.findPerformers;
const index = Math.floor(Math.random() * count); const index = Math.floor(Math.random() * count);
const filterCopy = _.cloneDeep(filter); const filterCopy = _.cloneDeep(filter);
filterCopy.itemsPerPage = 1; filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1; filterCopy.currentPage = index + 1;
const singleResult = await StashService.queryFindPerformers(filterCopy); const singleResult = await StashService.queryFindPerformers(filterCopy);
if (singleResult && singleResult.data && singleResult.data.findPerformers && singleResult.data.findPerformers.performers.length === 1) { if (
const {id} = singleResult!.data!.findPerformers!.performers[0]!; singleResult &&
history.push(`/performers/${ id}`); singleResult.data &&
singleResult.data.findPerformers &&
singleResult.data.findPerformers.performers.length === 1
) {
const { id } = singleResult!.data!.findPerformers!.performers[0]!;
history.push(`/performers/${id}`);
} }
} }
} }
function renderContent( function renderContent(
result: QueryHookResult<FindPerformersQuery, FindPerformersVariables>, filter: ListFilterModel) { result: QueryHookResult<FindPerformersQuery, FindPerformersVariables>,
if (!result.data || !result.data.findPerformers) { return; } filter: ListFilterModel
) {
if (!result.data || !result.data.findPerformers) {
return;
}
if (filter.displayMode === DisplayMode.Grid) { if (filter.displayMode === DisplayMode.Grid) {
return ( return (
<div className="grid"> <div className="grid">
{result.data.findPerformers.performers.map((p) => (<PerformerCard key={p.id} performer={p} />))} {result.data.findPerformers.performers.map(p => (
<PerformerCard key={p.id} performer={p} />
))}
</div> </div>
); );
} if (filter.displayMode === DisplayMode.List) { }
return <PerformerListTable performers={result.data.findPerformers.performers}/>; if (filter.displayMode === DisplayMode.List) {
return (
<PerformerListTable
performers={result.data.findPerformers.performers}
/>
);
} }
} }

View File

@@ -1,20 +1,23 @@
/* eslint-disable jsx-a11y/control-has-associated-label */ /* eslint-disable jsx-a11y/control-has-associated-label */
import React from "react"; import React from "react";
import { Button, Table } from 'react-bootstrap'; import { Button, Table } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Icon } from 'src/components/Shared'; import { Icon } from "src/components/Shared";
import { NavUtils } from "src/utils"; import { NavUtils } from "src/utils";
interface IPerformerListTableProps { interface IPerformerListTableProps {
performers: GQL.PerformerDataFragment[]; performers: GQL.PerformerDataFragment[];
} }
export const PerformerListTable: React.FC<IPerformerListTableProps> = (props: IPerformerListTableProps) => { export const PerformerListTable: React.FC<IPerformerListTableProps> = (
props: IPerformerListTableProps
function maybeRenderFavoriteHeart(performer : GQL.PerformerDataFragment) { ) => {
if (!performer.favorite) { return; } function maybeRenderFavoriteHeart(performer: GQL.PerformerDataFragment) {
if (!performer.favorite) {
return;
}
return ( return (
<Button disabled className="favorite"> <Button disabled className="favorite">
<Icon icon="heart" /> <Icon icon="heart" />
@@ -22,81 +25,67 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (props: IP
); );
} }
function renderPerformerImage(performer : GQL.PerformerDataFragment) { function renderPerformerImage(performer: GQL.PerformerDataFragment) {
const style: React.CSSProperties = { const style: React.CSSProperties = {
backgroundImage: `url('${performer.image_path}')`, backgroundImage: `url('${performer.image_path}')`,
lineHeight: 5, lineHeight: 5,
backgroundSize: "contain", backgroundSize: "contain",
display: "inline-block", display: "inline-block",
backgroundPosition: "center", backgroundPosition: "center",
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat"
}; };
return ( return (
<Link <Link
className="performer-list-thumbnail" className="performer-list-thumbnail"
to={`/performers/${performer.id}`} to={`/performers/${performer.id}`}
style={style}/> style={style}
) />
);
} }
function renderPerformerRow(performer : GQL.PerformerDataFragment) { function renderPerformerRow(performer: GQL.PerformerDataFragment) {
return ( return (
<> <>
<tr> <tr>
<td> <td>{renderPerformerImage(performer)}</td>
{renderPerformerImage(performer)} <td style={{ textAlign: "left" }}>
</td> <Link to={`/performers/${performer.id}`}>
<td style={{textAlign: "left"}}> <h5 className="text-truncate">{performer.name}</h5>
<Link to={`/performers/${performer.id}`}> </Link>
<h5 className="text-truncate"> </td>
{performer.name} <td>{performer.aliases ? performer.aliases : ""}</td>
</h5> <td>{maybeRenderFavoriteHeart(performer)}</td>
</Link> <td>
</td> <Link to={NavUtils.makePerformerScenesUrl(performer)}>
<td> <h6>{performer.scene_count}</h6>
{performer.aliases ? performer.aliases : ''} </Link>
</td> </td>
<td> <td>{performer.birthdate}</td>
{maybeRenderFavoriteHeart(performer)} <td>{performer.height}</td>
</td> </tr>
<td>
<Link to={NavUtils.makePerformerScenesUrl(performer)}>
<h6>{performer.scene_count}</h6>
</Link>
</td>
<td>
{performer.birthdate}
</td>
<td>
{performer.height}
</td>
</tr>
</> </>
) );
} }
return ( return (
<> <>
<div className="grid"> <div className="grid">
<Table bordered striped> <Table bordered striped>
<thead> <thead>
<tr> <tr>
<th /> <th />
<th>Name</th> <th>Name</th>
<th>Aliases</th> <th>Aliases</th>
<th>Favourite</th> <th>Favourite</th>
<th>Scene Count</th> <th>Scene Count</th>
<th>Birthdate</th> <th>Birthdate</th>
<th>Height</th> <th>Height</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>{props.performers.map(renderPerformerRow)}</tbody>
{props.performers.map(renderPerformerRow)} </Table>
</tbody> </div>
</Table>
</div>
</> </>
); );
}; };

View File

@@ -1,31 +1,44 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, ButtonGroup, Card, Form } from 'react-bootstrap'; import { Button, ButtonGroup, Card, Form } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import cx from 'classnames'; import cx from "classnames";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { VideoHoverHook } from "src/hooks"; import { VideoHoverHook } from "src/hooks";
import { Icon, TagLink, HoverPopover } from 'src/components/Shared'; import { Icon, TagLink, HoverPopover } from "src/components/Shared";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
interface ISceneCardProps { interface ISceneCardProps {
scene: GQL.SlimSceneDataFragment; scene: GQL.SlimSceneDataFragment;
selected: boolean | undefined; selected: boolean | undefined;
zoomIndex: number; zoomIndex: number;
onSelectedChanged: (selected : boolean, shiftKey : boolean) => void; onSelectedChanged: (selected: boolean, shiftKey: boolean) => void;
} }
export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) => { export const SceneCard: React.FC<ISceneCardProps> = (
props: ISceneCardProps
) => {
const [previewPath, setPreviewPath] = useState<string | undefined>(undefined); const [previewPath, setPreviewPath] = useState<string | undefined>(undefined);
const videoHoverHook = VideoHoverHook.useVideoHover({resetOnMouseLeave: false}); const videoHoverHook = VideoHoverHook.useVideoHover({
resetOnMouseLeave: false
});
const config = StashService.useConfiguration(); const config = StashService.useConfiguration();
const showStudioAsText = !!config.data && !!config.data.configuration ? config.data.configuration.interface.showStudioAsText : false; const showStudioAsText =
!!config.data && !!config.data.configuration
? config.data.configuration.interface.showStudioAsText
: false;
function maybeRenderRatingBanner() { function maybeRenderRatingBanner() {
if (!props.scene.rating) { return; } if (!props.scene.rating) {
return;
}
return ( return (
<div className={`rating-banner ${props.scene.rating ? `rating-${props.scene.rating}` : '' }`}> <div
className={`rating-banner ${
props.scene.rating ? `rating-${props.scene.rating}` : ""
}`}
>
RATING: {props.scene.rating} RATING: {props.scene.rating}
</div> </div>
); );
@@ -34,18 +47,27 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
function maybeRenderSceneSpecsOverlay() { function maybeRenderSceneSpecsOverlay() {
return ( return (
<div className="scene-specs-overlay"> <div className="scene-specs-overlay">
{props.scene.file.height ? <span className="overlay-resolution"> {TextUtils.resolution(props.scene.file.height)}</span> : ''} {props.scene.file.height ? (
{props.scene.file.duration !== undefined && props.scene.file.duration >= 1 ? TextUtils.secondsToTimestamp(props.scene.file.duration) : ''} <span className="overlay-resolution">
{" "}
{TextUtils.resolution(props.scene.file.height)}
</span>
) : (
""
)}
{props.scene.file.duration !== undefined &&
props.scene.file.duration >= 1
? TextUtils.secondsToTimestamp(props.scene.file.duration)
: ""}
</div> </div>
); );
} }
function maybeRenderSceneStudioOverlay() { function maybeRenderSceneStudioOverlay() {
if (!props.scene.studio) if (!props.scene.studio) return;
return;
let style: React.CSSProperties = { let style: React.CSSProperties = {
backgroundImage: `url('${props.scene.studio.image_path}')`, backgroundImage: `url('${props.scene.studio.image_path}')`
}; };
let text = ""; let text = "";
@@ -56,10 +78,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
return ( return (
<div className="scene-studio-overlay"> <div className="scene-studio-overlay">
<Link <Link to={`/studios/${props.scene.studio.id}`} style={style}>
to={`/studios/${props.scene.studio.id}`}
style={style}
>
{text} {text}
</Link> </Link>
</div> </div>
@@ -67,18 +86,14 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
} }
function maybeRenderTagPopoverButton() { function maybeRenderTagPopoverButton() {
if (props.scene.tags.length <= 0) if (props.scene.tags.length <= 0) return;
return;
const popoverContent = props.scene.tags.map((tag) => ( const popoverContent = props.scene.tags.map(tag => (
<TagLink key={tag.id} tag={tag} /> <TagLink key={tag.id} tag={tag} />
)); ));
return ( return (
<HoverPopover <HoverPopover placement="bottom" content={popoverContent}>
placement="bottom"
content={popoverContent}
>
<Button> <Button>
<Icon icon="tag" /> <Icon icon="tag" />
{props.scene.tags.length} {props.scene.tags.length}
@@ -88,25 +103,21 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
} }
function maybeRenderPerformerPopoverButton() { function maybeRenderPerformerPopoverButton() {
if (props.scene.performers.length <= 0) if (props.scene.performers.length <= 0) return;
return;
const popoverContent = props.scene.performers.map((performer) => ( const popoverContent = props.scene.performers.map(performer => (
<div className="performer-tag-container"> <div className="performer-tag-container">
<Link <Link
to={`/performers/${performer.id}`} to={`/performers/${performer.id}`}
className="performer-tag previewable image" className="performer-tag previewable image"
style={{backgroundImage: `url(${performer.image_path})`}} style={{ backgroundImage: `url(${performer.image_path})` }}
/> />
<TagLink key={performer.id} performer={performer} /> <TagLink key={performer.id} performer={performer} />
</div> </div>
)); ));
return ( return (
<HoverPopover <HoverPopover placement="bottom" content={popoverContent}>
placement="bottom"
content={popoverContent}
>
<Button> <Button>
<Icon icon="user" /> <Icon icon="user" />
{props.scene.performers.length} {props.scene.performers.length}
@@ -116,8 +127,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
} }
function maybeRenderSceneMarkerPopoverButton() { function maybeRenderSceneMarkerPopoverButton() {
if (props.scene.scene_markers.length <= 0) if (props.scene.scene_markers.length <= 0) return;
return;
const popoverContent = props.scene.scene_markers.map(marker => { const popoverContent = props.scene.scene_markers.map(marker => {
const markerPopover = { ...marker, scene: { id: props.scene.id } }; const markerPopover = { ...marker, scene: { id: props.scene.id } };
@@ -125,10 +135,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
}); });
return ( return (
<HoverPopover <HoverPopover placement="bottom" content={popoverContent}>
placement="bottom"
content={popoverContent}
>
<Button> <Button>
<Icon icon="tag" /> <Icon icon="tag" />
{props.scene.scene_markers.length} {props.scene.scene_markers.length}
@@ -138,9 +145,11 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
} }
function maybeRenderPopoverButtonGroup() { function maybeRenderPopoverButtonGroup() {
if (props.scene.tags.length > 0 || if (
props.scene.performers.length > 0 || props.scene.tags.length > 0 ||
props.scene.scene_markers.length > 0) { props.scene.performers.length > 0 ||
props.scene.scene_markers.length > 0
) {
return ( return (
<> <>
<hr /> <hr />
@@ -166,7 +175,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
} }
function isPortrait() { function isPortrait() {
const {file} = props.scene; const { file } = props.scene;
const width = file.width ? file.width : 0; const width = file.width ? file.width : 0;
const height = file.height ? file.height : 0; const height = file.height ? file.height : 0;
return height > width; return height > width;
@@ -180,7 +189,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
> >
<Form.Control <Form.Control
type="checkbox" type="checkbox"
className="card-select" className="card-select"
checked={props.selected} checked={props.selected}
@@ -191,14 +200,17 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
event.stopPropagation(); event.stopPropagation();
}} }}
/> />
<Link to={`/scenes/${props.scene.id}`} className={cx('image', 'previewable', {portrait: isPortrait()})}> <Link
to={`/scenes/${props.scene.id}`}
className={cx("image", "previewable", { portrait: isPortrait() })}
>
<div className="video-container"> <div className="video-container">
{maybeRenderRatingBanner()} {maybeRenderRatingBanner()}
{maybeRenderSceneSpecsOverlay()} {maybeRenderSceneSpecsOverlay()}
{maybeRenderSceneStudioOverlay()} {maybeRenderSceneStudioOverlay()}
<video <video
loop loop
className={cx('preview', {portrait: isPortrait()})} className={cx("preview", { portrait: isPortrait() })}
poster={props.scene.paths.screenshot || ""} poster={props.scene.paths.screenshot || ""}
ref={videoHoverHook.videoEl} ref={videoHoverHook.videoEl}
> >
@@ -208,7 +220,9 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
</Link> </Link>
<div className="card-section"> <div className="card-section">
<h4 className="text-truncate"> <h4 className="text-truncate">
{props.scene.title ? props.scene.title : TextUtils.fileNameFromPath(props.scene.path)} {props.scene.title
? props.scene.title
: TextUtils.fileNameFromPath(props.scene.path)}
</h4> </h4>
<span>{props.scene.date}</span> <span>{props.scene.date}</span>
<p>{TextUtils.truncate(props.scene.details, 100, "... (continued)")}</p> <p>{TextUtils.truncate(props.scene.details, 100, "... (continued)")}</p>

View File

@@ -1,7 +1,7 @@
import { Card, Spinner, Tab, Tabs } from 'react-bootstrap'; import { Card, Spinner, Tab, Tabs } from "react-bootstrap";
import queryString from "query-string"; import queryString from "query-string";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useLocation, useHistory } from 'react-router-dom'; import { useParams, useLocation, useHistory } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { GalleryViewer } from "src/components/Galleries/GalleryViewer"; import { GalleryViewer } from "src/components/Galleries/GalleryViewer";
@@ -13,7 +13,7 @@ import { SceneMarkersPanel } from "./SceneMarkersPanel";
import { ScenePerformerPanel } from "./ScenePerformerPanel"; import { ScenePerformerPanel } from "./ScenePerformerPanel";
export const Scene: React.FC = () => { export const Scene: React.FC = () => {
const { id = 'new' } = useParams(); const { id = "new" } = useParams();
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp()); const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp());
@@ -21,16 +21,17 @@ export const Scene: React.FC = () => {
const { data, error, loading } = StashService.useFindScene(id); const { data, error, loading } = StashService.useFindScene(id);
const queryParams = queryString.parse(location.search); const queryParams = queryString.parse(location.search);
const autoplay = queryParams?.autoplay === 'true'; const autoplay = queryParams?.autoplay === "true";
useEffect(() => ( useEffect(() => setScene(data?.findScene ?? {}), [data]);
setScene(data?.findScene ?? {})
), [data]);
function getInitialTimestamp() { function getInitialTimestamp() {
const params = queryString.parse(location.search); const params = queryString.parse(location.search);
const initialTimestamp = params?.t ?? '0'; const initialTimestamp = params?.t ?? "0";
return Number.parseInt(Array.isArray(initialTimestamp) ? initialTimestamp[0] : initialTimestamp, 10); return Number.parseInt(
Array.isArray(initialTimestamp) ? initialTimestamp[0] : initialTimestamp,
10
);
} }
function onClickMarker(marker: GQL.SceneMarkerDataFragment) { function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
@@ -38,54 +39,58 @@ export const Scene: React.FC = () => {
} }
if (!data?.findScene || loading || Object.keys(scene).length === 0) { if (!data?.findScene || loading || Object.keys(scene).length === 0) {
return <Spinner animation="border"/>; return <Spinner animation="border" />;
} }
if (error) if (error) return <div>{error.message}</div>;
return <div>{error.message}</div>
const modifiedScene = const modifiedScene = {
({scene_marker_tags: data.sceneMarkerTags, ...scene}) as GQL.SceneDataFragment; // TODO Hack from angular scene_marker_tags: data.sceneMarkerTags,
...scene
} as GQL.SceneDataFragment; // TODO Hack from angular
return ( return (
<> <>
<ScenePlayer scene={modifiedScene} timestamp={timestamp} autoplay={autoplay}/> <ScenePlayer
scene={modifiedScene}
timestamp={timestamp}
autoplay={autoplay}
/>
<Card id="details-container"> <Card id="details-container">
<Tabs id="scene-tabs" mountOnEnter> <Tabs id="scene-tabs" mountOnEnter>
<Tab eventKey="scene-details-panel" title="Details"> <Tab eventKey="scene-details-panel" title="Details">
<SceneDetailPanel scene={modifiedScene} /> <SceneDetailPanel scene={modifiedScene} />
</Tab>
<Tab eventKey="scene-markers-panel" title="Markers">
<SceneMarkersPanel
scene={modifiedScene}
onClickMarker={onClickMarker}
/>
</Tab>
{modifiedScene.performers.length > 0 ? (
<Tab eventKey="scene-performer-panel" title="Performers">
<ScenePerformerPanel scene={modifiedScene} />
</Tab> </Tab>
<Tab ) : (
eventKey="scene-markers-panel" ""
title="Markers"> )}
<SceneMarkersPanel scene={modifiedScene} onClickMarker={onClickMarker} /> {modifiedScene.gallery ? (
</Tab> <Tab eventKey="scene-gallery-panel" title="Gallery">
{modifiedScene.performers.length > 0 ? <GalleryViewer gallery={modifiedScene.gallery} />
<Tab
eventKey="scene-performer-panel"
title="Performers">
<ScenePerformerPanel scene={modifiedScene} />
</Tab> : ''
}
{modifiedScene.gallery ?
<Tab
eventKey="scene-gallery-panel"
title="Gallery">
<GalleryViewer gallery={modifiedScene.gallery} />
</Tab> : ''
}
<Tab eventKey="scene-file-info-panel" title="File Info">
<SceneFileInfoPanel scene={modifiedScene} />
</Tab>
<Tab
eventKey="scene-edit-panel"
title="Edit">
<SceneEditPanel
scene={modifiedScene}
onUpdate={(newScene) => setScene(newScene)}
onDelete={() => history.push("/scenes")}
/>
</Tab> </Tab>
) : (
""
)}
<Tab eventKey="scene-file-info-panel" title="File Info">
<SceneFileInfoPanel scene={modifiedScene} />
</Tab>
<Tab eventKey="scene-edit-panel" title="Edit">
<SceneEditPanel
scene={modifiedScene}
onUpdate={newScene => setScene(newScene)}
onDelete={() => history.push("/scenes")}
/>
</Tab>
</Tabs> </Tabs>
</Card> </Card>
</> </>

View File

@@ -8,9 +8,11 @@ interface ISceneDetailProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
} }
export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => { export const SceneDetailPanel: React.FC<ISceneDetailProps> = props => {
function renderDetails() { function renderDetails() {
if (!props.scene.details || props.scene.details === "") { return; } if (!props.scene.details || props.scene.details === "") {
return;
}
return ( return (
<> <>
<h6>Details</h6> <h6>Details</h6>
@@ -20,8 +22,10 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
} }
function renderTags() { function renderTags() {
if (props.scene.tags.length === 0) { return; } if (props.scene.tags.length === 0) {
const tags = props.scene.tags.map((tag) => ( return;
}
const tags = props.scene.tags.map(tag => (
<TagLink key={tag.id} tag={tag} /> <TagLink key={tag.id} tag={tag} />
)); ));
return ( return (
@@ -34,13 +38,19 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
return ( return (
<> <>
{SceneHelpers.maybeRenderStudio(props.scene, 70)} {SceneHelpers.maybeRenderStudio(props.scene, 70)}
<h1> <h1>
{props.scene.title ? props.scene.title : TextUtils.fileNameFromPath(props.scene.path)} {props.scene.title
? props.scene.title
: TextUtils.fileNameFromPath(props.scene.path)}
</h1> </h1>
{props.scene.date ? <h4>{props.scene.date}</h4> : ''} {props.scene.date ? <h4>{props.scene.date}</h4> : ""}
{props.scene.rating ? <h6>Rating: {props.scene.rating}</h6> : ''} {props.scene.rating ? <h6>Rating: {props.scene.rating}</h6> : ""}
{props.scene.file.height ? <h6>Resolution: {TextUtils.resolution(props.scene.file.height)}</h6> : ''} {props.scene.file.height ? (
<h6>Resolution: {TextUtils.resolution(props.scene.file.height)}</h6>
) : (
""
)}
{renderDetails()} {renderDetails()}
{renderTags()} {renderTags()}
</> </>

View File

@@ -1,12 +1,25 @@
/* eslint-disable react/no-this-in-sfc */ /* eslint-disable react/no-this-in-sfc */
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Collapse, Dropdown, DropdownButton, Form, Button, Spinner } from 'react-bootstrap'; import {
Collapse,
Dropdown,
DropdownButton,
Form,
Button,
Spinner
} from "react-bootstrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { FilterSelect, StudioSelect, SceneGallerySelect, Modal, Icon } from "src/components/Shared"; import {
import { useToast } from 'src/hooks'; FilterSelect,
import { ImageUtils } from 'src/utils'; StudioSelect,
SceneGallerySelect,
Modal,
Icon
} from "src/components/Shared";
import { useToast } from "src/hooks";
import { ImageUtils } from "src/utils";
interface IProps { interface IProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
@@ -23,19 +36,25 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
const [rating, setRating] = useState<number | undefined>(undefined); const [rating, setRating] = useState<number | undefined>(undefined);
const [galleryId, setGalleryId] = useState<string | undefined>(undefined); const [galleryId, setGalleryId] = useState<string | undefined>(undefined);
const [studioId, setStudioId] = useState<string | undefined>(undefined); const [studioId, setStudioId] = useState<string | undefined>(undefined);
const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined); const [performerIds, setPerformerIds] = useState<string[] | undefined>(
undefined
);
const [tagIds, setTagIds] = useState<string[] | undefined>(undefined); const [tagIds, setTagIds] = useState<string[] | undefined>(undefined);
const [coverImage, setCoverImage] = useState<string | undefined>(undefined); const [coverImage, setCoverImage] = useState<string | undefined>(undefined);
const Scrapers = StashService.useListSceneScrapers(); const Scrapers = StashService.useListSceneScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.ListSceneScrapersListSceneScrapers[]>([]); const [queryableScrapers, setQueryableScrapers] = useState<
GQL.ListSceneScrapersListSceneScrapers[]
>([]);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [deleteFile, setDeleteFile] = useState<boolean>(false); const [deleteFile, setDeleteFile] = useState<boolean>(false);
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true); const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
const [isCoverImageOpen, setIsCoverImageOpen] = useState<boolean>(false); const [isCoverImageOpen, setIsCoverImageOpen] = useState<boolean>(false);
const [coverImagePreview, setCoverImagePreview] = useState<string | undefined>(undefined); const [coverImagePreview, setCoverImagePreview] = useState<
string | undefined
>(undefined);
// Network state // Network state
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -44,21 +63,24 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
const deleteScene = StashService.useSceneDestroy(getSceneDeleteInput()); const deleteScene = StashService.useSceneDestroy(getSceneDeleteInput());
useEffect(() => { useEffect(() => {
let newQueryableScrapers : GQL.ListSceneScrapersListSceneScrapers[] = []; let newQueryableScrapers: GQL.ListSceneScrapersListSceneScrapers[] = [];
if (!!Scrapers.data && Scrapers.data.listSceneScrapers) { if (!!Scrapers.data && Scrapers.data.listSceneScrapers) {
newQueryableScrapers = Scrapers.data.listSceneScrapers.filter((s) => { newQueryableScrapers = Scrapers.data.listSceneScrapers.filter(s => {
return s.scene && s.scene.supported_scrapes.includes(GQL.ScrapeType.Fragment); return (
s.scene && s.scene.supported_scrapes.includes(GQL.ScrapeType.Fragment)
);
}); });
} }
setQueryableScrapers(newQueryableScrapers); setQueryableScrapers(newQueryableScrapers);
}, [Scrapers.data]);
}, [Scrapers.data])
function updateSceneEditState(state: Partial<GQL.SceneDataFragment>) { function updateSceneEditState(state: Partial<GQL.SceneDataFragment>) {
const perfIds = state.performers ? state.performers.map((performer) => performer.id) : undefined; const perfIds = state.performers
const tIds = state.tags ? state.tags.map((tag) => tag.id) : undefined; ? state.performers.map(performer => performer.id)
: undefined;
const tIds = state.tags ? state.tags.map(tag => tag.id) : undefined;
setTitle(state.title); setTitle(state.title);
setDetails(state.details); setDetails(state.details);
@@ -90,7 +112,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
studio_id: studioId, studio_id: studioId,
performer_ids: performerIds, performer_ids: performerIds,
tag_ids: tagIds, tag_ids: tagIds,
cover_image: coverImage, cover_image: coverImage
}; };
} }
@@ -128,16 +150,23 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
props.onDelete(); props.onDelete();
} }
function renderMultiSelect(type: "performers" | "tags", initialIds: string[] = []) { function renderMultiSelect(
type: "performers" | "tags",
initialIds: string[] = []
) {
return ( return (
<FilterSelect <FilterSelect
type={type} type={type}
isMulti isMulti
onSelect={(items) => { onSelect={items => {
const ids = items.map((i) => i.id); const ids = items.map(i => i.id);
switch (type) { switch (type) {
case "performers": setPerformerIds(ids); break; case "performers":
case "tags": setTagIds(ids); break; setPerformerIds(ids);
break;
case "tags":
setTagIds(ids);
break;
} }
}} }}
initialIds={initialIds} initialIds={initialIds}
@@ -151,16 +180,25 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
show={isDeleteAlertOpen} show={isDeleteAlertOpen}
icon="trash-alt" icon="trash-alt"
header="Delete Scene?" header="Delete Scene?"
accept={{ variant: 'danger', onClick: onDelete, text: "Delete" }} accept={{ variant: "danger", onClick: onDelete, text: "Delete" }}
cancel={{ onClick: () => setIsDeleteAlertOpen(false), text: "Cancel" }} cancel={{ onClick: () => setIsDeleteAlertOpen(false), text: "Cancel" }}
> >
<p> <p>
Are you sure you want to delete this scene? Unless the file is also deleted, this scene will be re-added when scan is performed. Are you sure you want to delete this scene? Unless the file is also
deleted, this scene will be re-added when scan is performed.
</p> </p>
<Form> <Form>
<Form.Check checked={deleteFile} label="Delete file" onChange={() => setDeleteFile(!deleteFile)} /> <Form.Check
<Form.Check checked={deleteGenerated} label="Delete generated supporting files" onChange={() => setDeleteGenerated(!deleteGenerated)} /> checked={deleteFile}
</Form> label="Delete file"
onChange={() => setDeleteFile(!deleteFile)}
/>
<Form.Check
checked={deleteGenerated}
label="Delete generated supporting files"
onChange={() => setDeleteGenerated(!deleteGenerated)}
/>
</Form>
</Modal> </Modal>
); );
} }
@@ -174,11 +212,18 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
ImageUtils.onImageChange(event, onImageLoad); ImageUtils.onImageChange(event, onImageLoad);
} }
async function onScrapeClicked(scraper : GQL.ListSceneScrapersListSceneScrapers) { async function onScrapeClicked(
scraper: GQL.ListSceneScrapersListSceneScrapers
) {
setIsLoading(true); setIsLoading(true);
try { try {
const result = await StashService.queryScrapeScene(scraper.id, getSceneInput()); const result = await StashService.queryScrapeScene(
if (!result.data || !result.data.scrapeScene) { return; } scraper.id,
getSceneInput()
);
if (!result.data || !result.data.scrapeScene) {
return;
}
updateSceneFromScrapedScene(result.data.scrapeScene); updateSceneFromScrapedScene(result.data.scrapeScene);
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -194,21 +239,22 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
return ( return (
<DropdownButton id="scene-scrape" title="Scrape with..."> <DropdownButton id="scene-scrape" title="Scrape with...">
{ queryableScrapers.map(s => ( {queryableScrapers.map(s => (
<Dropdown.Item onClick={() => onScrapeClicked(s)}>{s.name}</Dropdown.Item> <Dropdown.Item onClick={() => onScrapeClicked(s)}>
)) {s.name}
} </Dropdown.Item>
))}
</DropdownButton> </DropdownButton>
); );
} }
function urlScrapable(scrapedUrl: string) : boolean { function urlScrapable(scrapedUrl: string): boolean {
return (Scrapers?.data?.listSceneScrapers ?? []).some(s => ( return (Scrapers?.data?.listSceneScrapers ?? []).some(s =>
(s?.scene?.urls ?? []).some(u => scrapedUrl.includes(u)) (s?.scene?.urls ?? []).some(u => scrapedUrl.includes(u))
)); );
} }
function updateSceneFromScrapedScene(scene : GQL.ScrapedSceneDataFragment) { function updateSceneFromScrapedScene(scene: GQL.ScrapedSceneDataFragment) {
if (!title && scene.title) { if (!title && scene.title) {
setTitle(scene.title); setTitle(scene.title);
} }
@@ -229,35 +275,47 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
setStudioId(scene.studio.id); setStudioId(scene.studio.id);
} }
if ((!performerIds || performerIds.length === 0) && scene.performers && scene.performers.length > 0) { if (
const idPerfs = scene.performers.filter((p) => { (!performerIds || performerIds.length === 0) &&
scene.performers &&
scene.performers.length > 0
) {
const idPerfs = scene.performers.filter(p => {
return p.id !== undefined && p.id !== null; return p.id !== undefined && p.id !== null;
}); });
if (idPerfs.length > 0) { if (idPerfs.length > 0) {
const newIds = idPerfs.map((p) => p.id); const newIds = idPerfs.map(p => p.id);
setPerformerIds(newIds as string[]); setPerformerIds(newIds as string[]);
} }
} }
if ((!tagIds || tagIds.length === 0) && scene.tags && scene.tags.length > 0) { if (
const idTags = scene.tags.filter((p) => { (!tagIds || tagIds.length === 0) &&
scene.tags &&
scene.tags.length > 0
) {
const idTags = scene.tags.filter(p => {
return p.id !== undefined && p.id !== null; return p.id !== undefined && p.id !== null;
}); });
if (idTags.length > 0) { if (idTags.length > 0) {
const newIds = idTags.map((p) => p.id); const newIds = idTags.map(p => p.id);
setTagIds(newIds as string[]); setTagIds(newIds as string[]);
} }
} }
} }
async function onScrapeSceneURL() { async function onScrapeSceneURL() {
if (!url) { return; } if (!url) {
return;
}
setIsLoading(true); setIsLoading(true);
try { try {
const result = await StashService.queryScrapeSceneURL(url); const result = await StashService.queryScrapeSceneURL(url);
if (!result.data || !result.data.scrapeSceneURL) { return; } if (!result.data || !result.data.scrapeSceneURL) {
return;
}
updateSceneFromScrapedScene(result.data.scrapeSceneURL); updateSceneFromScrapedScene(result.data.scrapeSceneURL);
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -271,19 +329,17 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
return undefined; return undefined;
} }
return ( return (
<Button <Button id="scrape-url-button" onClick={onScrapeSceneURL}>
id="scrape-url-button"
onClick={onScrapeSceneURL}>
<Icon icon="file-download" /> <Icon icon="file-download" />
</Button> </Button>
) );
} }
return ( return (
<> <>
{renderDeleteAlert()} {renderDeleteAlert()}
{isLoading ? <Spinner animation="border" variant="light" /> : undefined} {isLoading ? <Spinner animation="border" variant="light" /> : undefined}
<div className="form-container " style={{width: "50%"}}> <div className="form-container " style={{ width: "50%" }}>
<Form.Group controlId="title"> <Form.Group controlId="title">
<Form.Label>Title</Form.Label> <Form.Label>Title</Form.Label>
<Form.Control <Form.Control
@@ -294,7 +350,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
<Form.Group controlId="details"> <Form.Group controlId="details">
<Form.Label>Details</Form.Label> <Form.Label>Details</Form.Label>
<Form.Control <Form.Control
as="textarea" as="textarea"
onChange={(newValue: any) => setDetails(newValue.target.value)} onChange={(newValue: any) => setDetails(newValue.target.value)}
value={details} value={details}
@@ -323,10 +379,15 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
<Form.Label>Rating</Form.Label> <Form.Label>Rating</Form.Label>
<Form.Control <Form.Control
as="select" as="select"
onChange={(event: any) => setRating(parseInt(event.target.value, 10))}> onChange={(event: any) =>
{ ["", 1, 2, 3, 4, 5].map(opt => ( setRating(parseInt(event.target.value, 10))
<option selected={opt === rating} value={opt}>{opt}</option> }
)) } >
{["", 1, 2, 3, 4, 5].map(opt => (
<option selected={opt === rating} value={opt}>
{opt}
</option>
))}
</Form.Control> </Form.Control>
</Form.Group> </Form.Group>
@@ -335,14 +396,14 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
<SceneGallerySelect <SceneGallerySelect
sceneId={props.scene.id} sceneId={props.scene.id}
initialId={galleryId} initialId={galleryId}
onSelect={(item) => setGalleryId(item ? item.id : undefined)} onSelect={item => setGalleryId(item ? item.id : undefined)}
/> />
</Form.Group> </Form.Group>
<Form.Group controlId="studio"> <Form.Group controlId="studio">
<Form.Label>Studio</Form.Label> <Form.Label>Studio</Form.Label>
<StudioSelect <StudioSelect
onSelect={(items) => items.length && setStudioId(items[0]?.id)} onSelect={items => items.length && setStudioId(items[0]?.id)}
initialIds={studioId ? [studioId] : []} initialIds={studioId ? [studioId] : []}
/> />
</Form.Group> </Form.Group>
@@ -358,7 +419,10 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
</Form.Group> </Form.Group>
<div> <div>
<Button variant="link" onClick={() => setIsCoverImageOpen(!isCoverImageOpen)}> <Button
variant="link"
onClick={() => setIsCoverImageOpen(!isCoverImageOpen)}
>
<Icon icon={isCoverImageOpen ? "chevron-down" : "chevron-right"} /> <Icon icon={isCoverImageOpen ? "chevron-down" : "chevron-right"} />
<span>Cover Image</span> <span>Cover Image</span>
</Button> </Button>
@@ -366,15 +430,26 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
<div> <div>
<img className="scene-cover" src={coverImagePreview} alt="" /> <img className="scene-cover" src={coverImagePreview} alt="" />
<Form.Group className="test" controlId="cover"> <Form.Group className="test" controlId="cover">
<Form.Control type="file" onChange={onCoverImageChange} accept=".jpg,.jpeg,.png" /> <Form.Control
type="file"
onChange={onCoverImageChange}
accept=".jpg,.jpeg,.png"
/>
</Form.Group> </Form.Group>
</div> </div>
</Collapse> </Collapse>
</div> </div>
</div> </div>
<Button className="edit-button" variant="primary" onClick={onSave}>Save</Button> <Button className="edit-button" variant="primary" onClick={onSave}>
<Button className="edit-button" variant="danger" onClick={() => setIsDeleteAlertOpen(true)}>Delete</Button> Save
</Button>
<Button
className="edit-button"
variant="danger"
onClick={() => setIsDeleteAlertOpen(true)}
>
Delete
</Button>
{renderScraperMenu()} {renderScraperMenu()}
</> </>
); );

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Table } from 'react-bootstrap'; import { Table } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
@@ -7,7 +7,9 @@ interface ISceneFileInfoPanelProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
} }
export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (props: ISceneFileInfoPanelProps) => { export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
props: ISceneFileInfoPanelProps
) => {
function renderChecksum() { function renderChecksum() {
return ( return (
<tr> <tr>
@@ -18,11 +20,15 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (props: IS
} }
function renderPath() { function renderPath() {
const { scene: { path } } = props; const {
scene: { path }
} = props;
return ( return (
<tr> <tr>
<td>Path</td> <td>Path</td>
<td><a href={`file://${path}`}>{`file://${props.scene.path}`}</a> </td> <td>
<a href={`file://${path}`}>{`file://${props.scene.path}`}</a>{" "}
</td>
</tr> </tr>
); );
} }
@@ -31,13 +37,17 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (props: IS
return ( return (
<tr> <tr>
<td>Stream</td> <td>Stream</td>
<td><a href={props.scene.paths.stream}>{props.scene.paths.stream}</a> </td> <td>
<a href={props.scene.paths.stream}>{props.scene.paths.stream}</a>{" "}
</td>
</tr> </tr>
); );
} }
function renderFileSize() { function renderFileSize() {
if (props.scene.file.size === undefined) { return; } if (props.scene.file.size === undefined) {
return;
}
return ( return (
<tr> <tr>
<td>File Size</td> <td>File Size</td>
@@ -47,7 +57,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (props: IS
} }
function renderDuration() { function renderDuration() {
if (props.scene.file.duration === undefined) { return; } if (props.scene.file.duration === undefined) {
return;
}
return ( return (
<tr> <tr>
<td>Duration</td> <td>Duration</td>
@@ -57,17 +69,23 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (props: IS
} }
function renderDimensions() { function renderDimensions() {
if (props.scene.file.duration === undefined) { return; } if (props.scene.file.duration === undefined) {
return;
}
return ( return (
<tr> <tr>
<td>Dimensions</td> <td>Dimensions</td>
<td>{props.scene.file.width} x {props.scene.file.height}</td> <td>
{props.scene.file.width} x {props.scene.file.height}
</td>
</tr> </tr>
); );
} }
function renderFrameRate() { function renderFrameRate() {
if (props.scene.file.framerate === undefined) { return; } if (props.scene.file.framerate === undefined) {
return;
}
return ( return (
<tr> <tr>
<td>Frame Rate</td> <td>Frame Rate</td>
@@ -77,7 +95,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (props: IS
} }
function renderBitRate() { function renderBitRate() {
if (props.scene.file.bitrate === undefined) { return; } if (props.scene.file.bitrate === undefined) {
return;
}
return ( return (
<tr> <tr>
<td>Bit Rate</td> <td>Bit Rate</td>
@@ -87,7 +107,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (props: IS
} }
function renderVideoCodec() { function renderVideoCodec() {
if (props.scene.file.video_codec === undefined) { return; } if (props.scene.file.video_codec === undefined) {
return;
}
return ( return (
<tr> <tr>
<td>Video Codec</td> <td>Video Codec</td>
@@ -97,7 +119,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (props: IS
} }
function renderAudioCodec() { function renderAudioCodec() {
if (props.scene.file.audio_codec === undefined) { return; } if (props.scene.file.audio_codec === undefined) {
return;
}
return ( return (
<tr> <tr>
<td>Audio Codec</td> <td>Audio Codec</td>
@@ -107,7 +131,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (props: IS
} }
function renderUrl() { function renderUrl() {
if (!props.scene.url || props.scene.url === "") { return; } if (!props.scene.url || props.scene.url === "") {
return;
}
return ( return (
<tr> <tr>
<td>Downloaded From</td> <td>Downloaded From</td>

View File

@@ -1,11 +1,21 @@
import React, { CSSProperties, useState } from "react"; import React, { CSSProperties, useState } from "react";
import { Badge, Button, Card, Collapse, Form as BootstrapForm } from 'react-bootstrap'; import {
Badge,
Button,
Card,
Collapse,
Form as BootstrapForm
} from "react-bootstrap";
import { Field, FieldProps, Form, Formik } from "formik"; import { Field, FieldProps, Form, Formik } from "formik";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import { useToast } from 'src/hooks'; import { useToast } from "src/hooks";
import { DurationInput, TagSelect, MarkerTitleSuggest } from "src/components/Shared"; import {
DurationInput,
TagSelect,
MarkerTitleSuggest
} from "src/components/Shared";
import { WallPanel } from "src/components/Wall/WallPanel"; import { WallPanel } from "src/components/Wall/WallPanel";
import { SceneHelpers } from "../helpers"; import { SceneHelpers } from "../helpers";
@@ -21,10 +31,15 @@ interface IFormFields {
tagIds: string[]; tagIds: string[];
} }
export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (props: ISceneMarkersPanelProps) => { export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
props: ISceneMarkersPanelProps
) => {
const Toast = useToast(); const Toast = useToast();
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false); const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
const [editingMarker, setEditingMarker] = useState<GQL.SceneMarkerDataFragment | null>(null); const [
editingMarker,
setEditingMarker
] = useState<GQL.SceneMarkerDataFragment | null>(null);
const sceneMarkerCreate = StashService.useSceneMarkerCreate(); const sceneMarkerCreate = StashService.useSceneMarkerCreate();
const sceneMarkerUpdate = StashService.useSceneMarkerUpdate(); const sceneMarkerUpdate = StashService.useSceneMarkerUpdate();
@@ -43,24 +58,34 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (props: ISce
function renderTags() { function renderTags() {
function renderMarkers(primaryTag: GQL.FindSceneSceneMarkerTags) { function renderMarkers(primaryTag: GQL.FindSceneSceneMarkerTags) {
const markers = primaryTag.scene_markers.map((marker) => { const markers = primaryTag.scene_markers.map(marker => {
const markerTags = marker.tags.map((tag) => ( const markerTags = marker.tags.map(tag => (
<Badge key={tag.id} variant="secondary" className="tag-item">{tag.name}</Badge> <Badge key={tag.id} variant="secondary" className="tag-item">
{tag.name}
</Badge>
)); ));
return ( return (
<div key={marker.id}> <div key={marker.id}>
<hr /> <hr />
<div> <div>
<Button variant="link" onClick={() => onClickMarker(marker)}>{marker.title}</Button> <Button variant="link" onClick={() => onClickMarker(marker)}>
{!isEditorOpen ? <Button variant="link" style={{float: "right"}} onClick={() => onOpenEditor(marker)}>Edit</Button> : ''} {marker.title}
</div> </Button>
<div> {!isEditorOpen ? (
{TextUtils.secondsToTimestamp(marker.seconds)} <Button
</div> variant="link"
<div className="card-section centered"> style={{ float: "right" }}
{markerTags} onClick={() => onOpenEditor(marker)}
>
Edit
</Button>
) : (
""
)}
</div> </div>
<div>{TextUtils.secondsToTimestamp(marker.seconds)}</div>
<div className="card-section centered">{markerTags}</div>
</div> </div>
); );
}); });
@@ -74,52 +99,61 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (props: ISce
display: "inline-block", display: "inline-block",
margin: "5px", margin: "5px",
width: "300px", width: "300px",
flex: "0 0 auto", flex: "0 0 auto"
}; };
const tags = (props.scene as any).scene_marker_tags.map((primaryTag: GQL.FindSceneSceneMarkerTags) => { const tags = (props.scene as any).scene_marker_tags.map(
(primaryTag: GQL.FindSceneSceneMarkerTags) => {
return ( return (
<div key={primaryTag.tag.id} style={{padding: "1px"}}> <div key={primaryTag.tag.id} style={{ padding: "1px" }}>
<Card style={style}> <Card style={style}>
<div className="content" style={{whiteSpace: "normal"}}> <div className="content" style={{ whiteSpace: "normal" }}>
<h3>{primaryTag.tag.name}</h3> <h3>{primaryTag.tag.name}</h3>
{renderMarkers(primaryTag)} {renderMarkers(primaryTag)}
</div> </div>
</Card> </Card>
</div> </div>
); );
}); }
);
return tags; return tags;
} }
function renderForm() { function renderForm() {
function onSubmit(values: IFormFields) { function onSubmit(values: IFormFields) {
const isEditing = !!editingMarker; const isEditing = !!editingMarker;
const variables: GQL.SceneMarkerCreateVariables | GQL.SceneMarkerUpdateVariables = { const variables:
| GQL.SceneMarkerCreateVariables
| GQL.SceneMarkerUpdateVariables = {
title: values.title, title: values.title,
seconds: parseFloat(values.seconds), seconds: parseFloat(values.seconds),
scene_id: props.scene.id, scene_id: props.scene.id,
primary_tag_id: values.primaryTagId, primary_tag_id: values.primaryTagId,
tag_ids: values.tagIds, tag_ids: values.tagIds
}; };
if (!isEditing) { if (!isEditing) {
sceneMarkerCreate({ variables }).then(() => { sceneMarkerCreate({ variables })
setIsEditorOpen(false); .then(() => {
setEditingMarker(null); setIsEditorOpen(false);
}).catch((err) => Toast.error(err)); setEditingMarker(null);
})
.catch(err => Toast.error(err));
} else { } else {
const updateVariables = variables as GQL.SceneMarkerUpdateVariables; const updateVariables = variables as GQL.SceneMarkerUpdateVariables;
updateVariables.id = editingMarker!.id; updateVariables.id = editingMarker!.id;
sceneMarkerUpdate({ variables: updateVariables }).then(() => { sceneMarkerUpdate({ variables: updateVariables })
setIsEditorOpen(false); .then(() => {
setEditingMarker(null); setIsEditorOpen(false);
}).catch((err) => Toast.error(err)); setEditingMarker(null);
})
.catch(err => Toast.error(err));
} }
} }
function onDelete() { function onDelete() {
if (!editingMarker) { return; } if (!editingMarker) {
sceneMarkerDestroy({variables: {id: editingMarker.id}}) return;
// eslint-disable-next-line no-console }
sceneMarkerDestroy({ variables: { id: editingMarker.id } })
// eslint-disable-next-line no-console
.catch(err => console.error(err)); .catch(err => console.error(err));
setIsEditorOpen(false); setIsEditorOpen(false);
setEditingMarker(null); setEditingMarker(null);
@@ -128,15 +162,22 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (props: ISce
return ( return (
<MarkerTitleSuggest <MarkerTitleSuggest
initialMarkerTitle={editingMarker?.title} initialMarkerTitle={editingMarker?.title}
onChange={(query:string) => fieldProps.form.setFieldValue("title", query)} onChange={(query: string) =>
fieldProps.form.setFieldValue("title", query)
}
/> />
); );
} }
function renderSecondsField(fieldProps: FieldProps<IFormFields>) { function renderSecondsField(fieldProps: FieldProps<IFormFields>) {
return ( return (
<DurationInput <DurationInput
onValueChange={(s) => fieldProps.form.setFieldValue("seconds", s)} onValueChange={s => fieldProps.form.setFieldValue("seconds", s)}
onReset={() => fieldProps.form.setFieldValue("seconds", Math.round(jwplayer.getPosition()))} onReset={() =>
fieldProps.form.setFieldValue(
"seconds",
Math.round(jwplayer.getPosition())
)
}
numericValue={fieldProps.field.value} numericValue={fieldProps.field.value}
/> />
); );
@@ -144,7 +185,9 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (props: ISce
function renderPrimaryTagField(fieldProps: FieldProps<IFormFields>) { function renderPrimaryTagField(fieldProps: FieldProps<IFormFields>) {
return ( return (
<TagSelect <TagSelect
onSelect={(tags) => fieldProps.form.setFieldValue("primaryTagId", tags[0]?.id)} onSelect={tags =>
fieldProps.form.setFieldValue("primaryTagId", tags[0]?.id)
}
initialIds={editingMarker ? [editingMarker.primary_tag.id] : []} initialIds={editingMarker ? [editingMarker.primary_tag.id] : []}
/> />
); );
@@ -153,7 +196,12 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (props: ISce
return ( return (
<TagSelect <TagSelect
isMulti isMulti
onSelect={(tags) => fieldProps.form.setFieldValue("tagIds", tags.map((tag) => tag.id))} onSelect={tags =>
fieldProps.form.setFieldValue(
"tagIds",
tags.map(tag => tag.id)
)
}
initialIds={editingMarker ? fieldProps.form.values.tagIds : []} initialIds={editingMarker ? fieldProps.form.values.tagIds : []}
/> />
); );
@@ -164,7 +212,7 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (props: ISce
deleteButton = ( deleteButton = (
<Button <Button
variant="danger" variant="danger"
style={{float: "right", marginRight: "10px"}} style={{ float: "right", marginRight: "10px" }}
onClick={() => onDelete()} onClick={() => onDelete()}
> >
Delete Delete
@@ -172,10 +220,12 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (props: ISce
); );
} }
return ( return (
<Form style={{marginTop: "10px"}}> <Form style={{ marginTop: "10px" }}>
<div className="columns is-multiline is-gapless"> <div className="columns is-multiline is-gapless">
<BootstrapForm.Group> <BootstrapForm.Group>
<BootstrapForm.Label htmlFor="title">Scene Marker Title</BootstrapForm.Label> <BootstrapForm.Label htmlFor="title">
Scene Marker Title
</BootstrapForm.Label>
<Field name="title" render={renderTitleField} /> <Field name="title" render={renderTitleField} />
</BootstrapForm.Group> </BootstrapForm.Group>
<BootstrapForm.Group> <BootstrapForm.Group>
@@ -183,7 +233,9 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (props: ISce
<Field name="seconds" render={renderSecondsField} /> <Field name="seconds" render={renderSecondsField} />
</BootstrapForm.Group> </BootstrapForm.Group>
<BootstrapForm.Group> <BootstrapForm.Group>
<BootstrapForm.Label htmlFor="primaryTagId">Primary Tag</BootstrapForm.Label> <BootstrapForm.Label htmlFor="primaryTagId">
Primary Tag
</BootstrapForm.Label>
<Field name="primaryTagId" render={renderPrimaryTagField} /> <Field name="primaryTagId" render={renderPrimaryTagField} />
</BootstrapForm.Group> </BootstrapForm.Group>
<BootstrapForm.Group> <BootstrapForm.Group>
@@ -192,8 +244,12 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (props: ISce
</BootstrapForm.Group> </BootstrapForm.Group>
</div> </div>
<div className="buttons-container"> <div className="buttons-container">
<Button variant="primary" type="submit">Submit</Button> <Button variant="primary" type="submit">
<Button type="button" onClick={() => setIsEditorOpen(false)}>Cancel</Button> Submit
</Button>
<Button type="button" onClick={() => setIsEditorOpen(false)}>
Cancel
</Button>
{deleteButton} {deleteButton}
</div> </div>
</Form> </Form>
@@ -205,10 +261,15 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (props: ISce
title: editingMarker.title, title: editingMarker.title,
seconds: editingMarker.seconds, seconds: editingMarker.seconds,
primaryTagId: editingMarker.primary_tag.id, primaryTagId: editingMarker.primary_tag.id,
tagIds: editingMarker.tags.map((tag) => tag.id), tagIds: editingMarker.tags.map(tag => tag.id)
}; };
} else { } else {
initialValues = {title: "", seconds: Math.round(jwplayer.getPosition()), primaryTagId: "", tagIds: []}; initialValues = {
title: "",
seconds: Math.round(jwplayer.getPosition()),
primaryTagId: "",
tagIds: []
};
} }
return ( return (
<Collapse in={isEditorOpen}> <Collapse in={isEditorOpen}>
@@ -225,7 +286,7 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (props: ISce
function render() { function render() {
const newMarkerForm = ( const newMarkerForm = (
<div style={{margin: "5px"}}> <div style={{ margin: "5px" }}>
<Button onClick={() => onOpenEditor()}>Create</Button> <Button onClick={() => onOpenEditor()}>Create</Button>
{renderForm()} {renderForm()}
</div> </div>
@@ -240,17 +301,18 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (props: ISce
whiteSpace: "nowrap", whiteSpace: "nowrap",
display: "flex", display: "flex",
flexWrap: "nowrap", flexWrap: "nowrap",
marginBottom: "20px", marginBottom: "20px"
}; };
return ( return (
<> <>
{newMarkerForm} {newMarkerForm}
<div style={containerStyle}> <div style={containerStyle}>{renderTags()}</div>
{renderTags()}
</div>
<WallPanel <WallPanel
sceneMarkers={props.scene.scene_markers} sceneMarkers={props.scene.scene_markers}
clickHandler={(marker) => { window.scrollTo(0, 0); onClickMarker(marker as any); }} clickHandler={marker => {
window.scrollTo(0, 0);
onClickMarker(marker as any);
}}
/> />
</> </>
); );

View File

@@ -6,16 +6,20 @@ interface IScenePerformerPanelProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
} }
export const ScenePerformerPanel: FunctionComponent<IScenePerformerPanelProps> = (props: IScenePerformerPanelProps) => { export const ScenePerformerPanel: FunctionComponent<IScenePerformerPanelProps> = (
const cards = props.scene.performers.map((performer) => ( props: IScenePerformerPanelProps
<PerformerCard key={performer.id} performer={performer} ageFromDate={props.scene.date} /> ) => {
const cards = props.scene.performers.map(performer => (
<PerformerCard
key={performer.id}
performer={performer}
ageFromDate={props.scene.date}
/>
)); ));
return ( return (
<> <>
<div className="grid"> <div className="grid">{cards}</div>
{cards}
</div>
</> </>
); );
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,12 @@
import React from "react"; import React from "react";
import _ from "lodash"; import _ from "lodash";
import { QueryHookResult } from "react-apollo-hooks"; import { QueryHookResult } from "react-apollo-hooks";
import { useHistory } from 'react-router-dom'; import { useHistory } from "react-router-dom";
import { FindScenesQuery, FindScenesVariables, SlimSceneDataFragment } from "src/core/generated-graphql"; import {
FindScenesQuery,
FindScenesVariables,
SlimSceneDataFragment
} from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { useScenesList } from "src/hooks"; import { useScenesList } from "src/hooks";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
@@ -17,7 +21,7 @@ export const SceneList: React.FC = () => {
const otherOperations = [ const otherOperations = [
{ {
text: "Play Random", text: "Play Random",
onClick: playRandom, onClick: playRandom
} }
]; ];
@@ -28,32 +32,45 @@ export const SceneList: React.FC = () => {
renderSelectedOptions renderSelectedOptions
}); });
async function playRandom(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel) { async function playRandom(
result: QueryHookResult<FindScenesQuery, FindScenesVariables>,
filter: ListFilterModel
) {
// query for a random scene // query for a random scene
if (result.data && result.data.findScenes) { if (result.data && result.data.findScenes) {
const {count} = result.data.findScenes; const { count } = result.data.findScenes;
const index = Math.floor(Math.random() * count); const index = Math.floor(Math.random() * count);
const filterCopy = _.cloneDeep(filter); const filterCopy = _.cloneDeep(filter);
filterCopy.itemsPerPage = 1; filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1; filterCopy.currentPage = index + 1;
const singleResult = await StashService.queryFindScenes(filterCopy); const singleResult = await StashService.queryFindScenes(filterCopy);
if (singleResult && singleResult.data && singleResult.data.findScenes && singleResult.data.findScenes.scenes.length === 1) { if (
const {id} = singleResult!.data!.findScenes!.scenes[0]; singleResult &&
singleResult.data &&
singleResult.data.findScenes &&
singleResult.data.findScenes.scenes.length === 1
) {
const { id } = singleResult!.data!.findScenes!.scenes[0];
// navigate to the scene player page // navigate to the scene player page
history.push(`/scenes/${ id }?autoplay=true`); history.push(`/scenes/${id}?autoplay=true`);
} }
} }
} }
function renderSelectedOptions(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, selectedIds: Set<string>) { function renderSelectedOptions(
result: QueryHookResult<FindScenesQuery, FindScenesVariables>,
selectedIds: Set<string>
) {
// find the selected items from the ids // find the selected items from the ids
if (!result.data || !result.data.findScenes) { return undefined; } if (!result.data || !result.data.findScenes) {
return undefined;
}
const {scenes} = result.data.findScenes; const { scenes } = result.data.findScenes;
const selectedScenes : SlimSceneDataFragment[] = []; const selectedScenes: SlimSceneDataFragment[] = [];
selectedIds.forEach((id) => { selectedIds.forEach(id => {
const scene = scenes.find(s => s.id === id); const scene = scenes.find(s => s.id === id);
if (scene) { if (scene) {
@@ -63,34 +80,54 @@ export const SceneList: React.FC = () => {
return ( return (
<> <>
<SceneSelectedOptions selected={selectedScenes} onScenesUpdated={() => { }}/> <SceneSelectedOptions
selected={selectedScenes}
onScenesUpdated={() => {}}
/>
</> </>
); );
} }
function renderSceneCard(scene : SlimSceneDataFragment, selectedIds: Set<string>, zoomIndex: number) { function renderSceneCard(
scene: SlimSceneDataFragment,
selectedIds: Set<string>,
zoomIndex: number
) {
return ( return (
<SceneCard <SceneCard
key={scene.id} key={scene.id}
scene={scene} scene={scene}
zoomIndex={zoomIndex} zoomIndex={zoomIndex}
selected={selectedIds.has(scene.id)} selected={selectedIds.has(scene.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) => listData.onSelectChange(scene.id, selected, shiftKey)} onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
listData.onSelectChange(scene.id, selected, shiftKey)
}
/> />
) );
} }
function renderContent(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel, selectedIds: Set<string>, zoomIndex: number) { function renderContent(
if (!result.data || !result.data.findScenes) { return; } result: QueryHookResult<FindScenesQuery, FindScenesVariables>,
filter: ListFilterModel,
selectedIds: Set<string>,
zoomIndex: number
) {
if (!result.data || !result.data.findScenes) {
return;
}
if (filter.displayMode === DisplayMode.Grid) { if (filter.displayMode === DisplayMode.Grid) {
return ( return (
<div className="grid"> <div className="grid">
{result.data.findScenes.scenes.map((scene) => renderSceneCard(scene, selectedIds, zoomIndex))} {result.data.findScenes.scenes.map(scene =>
renderSceneCard(scene, selectedIds, zoomIndex)
)}
</div> </div>
); );
} if (filter.displayMode === DisplayMode.List) { }
return <SceneListTable scenes={result.data.findScenes.scenes}/>; if (filter.displayMode === DisplayMode.List) {
} if (filter.displayMode === DisplayMode.Wall) { return <SceneListTable scenes={result.data.findScenes.scenes} />;
}
if (filter.displayMode === DisplayMode.Wall) {
return <WallPanel scenes={result.data.findScenes.scenes} />; return <WallPanel scenes={result.data.findScenes.scenes} />;
} }
} }

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Table } from 'react-bootstrap'; import { Table } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { NavUtils, TextUtils } from "src/utils"; import { NavUtils, TextUtils } from "src/utils";
@@ -8,48 +8,52 @@ interface ISceneListTableProps {
scenes: GQL.SlimSceneDataFragment[]; scenes: GQL.SlimSceneDataFragment[];
} }
export const SceneListTable: React.FC<ISceneListTableProps> = (props: ISceneListTableProps) => { export const SceneListTable: React.FC<ISceneListTableProps> = (
props: ISceneListTableProps
function renderSceneImage(scene : GQL.SlimSceneDataFragment) { ) => {
function renderSceneImage(scene: GQL.SlimSceneDataFragment) {
const style: React.CSSProperties = { const style: React.CSSProperties = {
backgroundImage: `url('${scene.paths.screenshot}')`, backgroundImage: `url('${scene.paths.screenshot}')`,
lineHeight: 5, lineHeight: 5,
backgroundSize: "contain", backgroundSize: "contain",
display: "inline-block", display: "inline-block",
backgroundPosition: "center", backgroundPosition: "center",
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat"
}; };
return ( return (
<Link <Link
className="scene-list-thumbnail" className="scene-list-thumbnail"
to={`/scenes/${scene.id}`} to={`/scenes/${scene.id}`}
style={style}/> style={style}
) />
);
} }
function renderDuration(scene : GQL.SlimSceneDataFragment) { function renderDuration(scene: GQL.SlimSceneDataFragment) {
if (scene.file.duration === undefined) { return; } if (scene.file.duration === undefined) {
return;
}
return TextUtils.secondsToTimestamp(scene.file.duration); return TextUtils.secondsToTimestamp(scene.file.duration);
} }
function renderTags(tags : GQL.SlimSceneDataTags[]) { function renderTags(tags: GQL.SlimSceneDataTags[]) {
return tags.map((tag) => ( return tags.map(tag => (
<Link to={NavUtils.makeTagScenesUrl(tag)}> <Link to={NavUtils.makeTagScenesUrl(tag)}>
<h6>{tag.name}</h6> <h6>{tag.name}</h6>
</Link> </Link>
)); ));
} }
function renderPerformers(performers : GQL.SlimSceneDataPerformers[]) { function renderPerformers(performers: GQL.SlimSceneDataPerformers[]) {
return performers.map((performer) => ( return performers.map(performer => (
<Link to={NavUtils.makePerformerScenesUrl(performer)}> <Link to={NavUtils.makePerformerScenesUrl(performer)}>
<h6>{performer.name}</h6> <h6>{performer.name}</h6>
</Link> </Link>
)); ));
} }
function renderStudio(studio : GQL.SlimSceneDataStudio | undefined) { function renderStudio(studio: GQL.SlimSceneDataStudio | undefined) {
if (studio) { if (studio) {
return ( return (
<Link to={NavUtils.makeStudioScenesUrl(studio)}> <Link to={NavUtils.makeStudioScenesUrl(studio)}>
@@ -59,36 +63,24 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (props: ISceneList
} }
} }
function renderSceneRow(scene : GQL.SlimSceneDataFragment) { function renderSceneRow(scene: GQL.SlimSceneDataFragment) {
return ( return (
<tr> <tr>
<td> <td>{renderSceneImage(scene)}</td>
{renderSceneImage(scene)} <td style={{ textAlign: "left" }}>
</td>
<td style={{textAlign: "left"}}>
<Link to={`/scenes/${scene.id}`}> <Link to={`/scenes/${scene.id}`}>
<h5 className="text-truncate"> <h5 className="text-truncate">
{scene.title ?? TextUtils.fileNameFromPath(scene.path)} {scene.title ?? TextUtils.fileNameFromPath(scene.path)}
</h5> </h5>
</Link> </Link>
</td> </td>
<td> <td>{scene.rating ? scene.rating : ""}</td>
{scene.rating ? scene.rating : ''} <td>{renderDuration(scene)}</td>
</td> <td>{renderTags(scene.tags)}</td>
<td> <td>{renderPerformers(scene.performers)}</td>
{renderDuration(scene)} <td>{renderStudio(scene.studio)}</td>
</td>
<td>
{renderTags(scene.tags)}
</td>
<td>
{renderPerformers(scene.performers)}
</td>
<td>
{renderStudio(scene.studio)}
</td>
</tr> </tr>
) );
} }
return ( return (
@@ -104,11 +96,8 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (props: ISceneList
<th>Studio</th> <th>Studio</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>{props.scenes.map(renderSceneRow)}</tbody>
{props.scenes.map(renderSceneRow)}
</tbody>
</Table> </Table>
</div> </div>
); );
}; };

View File

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

View File

@@ -20,22 +20,33 @@ interface IScenePlayerState {
} }
const KeyMap = { const KeyMap = {
NUM0: "0", NUM0: "0",
NUM1: "1", NUM1: "1",
NUM2: "2", NUM2: "2",
SPACE: " " SPACE: " "
} };
export class ScenePlayerImpl extends React.Component<IScenePlayerProps, IScenePlayerState> { export class ScenePlayerImpl extends React.Component<
IScenePlayerProps,
IScenePlayerState
> {
private player: any; private player: any;
private lastTime = 0; private lastTime = 0;
private KeyHandlers = { private KeyHandlers = {
NUM0: () => {this.onReset()}, NUM0: () => {
NUM1: () => {this.onDecrease()}, this.onReset();
NUM2: () => {this.onIncrease()}, },
SPACE: () => {this.onPause()} NUM1: () => {
} this.onDecrease();
},
NUM2: () => {
this.onIncrease();
},
SPACE: () => {
this.onPause();
}
};
constructor(props: IScenePlayerProps) { constructor(props: IScenePlayerProps) {
super(props); super(props);
@@ -46,7 +57,7 @@ export class ScenePlayerImpl extends React.Component<IScenePlayerProps, IScenePl
this.onScrubberSeek = this.onScrubberSeek.bind(this); this.onScrubberSeek = this.onScrubberSeek.bind(this);
this.onScrubberScrolled = this.onScrubberScrolled.bind(this); this.onScrubberScrolled = this.onScrubberScrolled.bind(this);
this.state = {scrubberPosition: 0}; this.state = { scrubberPosition: 0 };
} }
public componentDidUpdate(prevProps: IScenePlayerProps) { public componentDidUpdate(prevProps: IScenePlayerProps) {
@@ -58,19 +69,19 @@ export class ScenePlayerImpl extends React.Component<IScenePlayerProps, IScenePl
onIncrease() { onIncrease() {
const currentPlaybackRate = this.player ? this.player.getPlaybackRate() : 1; const currentPlaybackRate = this.player ? this.player.getPlaybackRate() : 1;
this.player.setPlaybackRate(currentPlaybackRate + 0.5); this.player.setPlaybackRate(currentPlaybackRate + 0.5);
}; }
onDecrease() { onDecrease() {
const currentPlaybackRate = this.player ? this.player.getPlaybackRate() : 1; const currentPlaybackRate = this.player ? this.player.getPlaybackRate() : 1;
this.player.setPlaybackRate(currentPlaybackRate - 0.5); this.player.setPlaybackRate(currentPlaybackRate - 0.5);
}; }
onReset() { this.player.setPlaybackRate(1); }; onReset() {
this.player.setPlaybackRate(1);
}
onPause() { onPause() {
if (this.player.getState().paused) if (this.player.getState().paused) this.player.play();
this.player.play(); else this.player.pause();
else }
this.player.pause();
};
private onReady() { private onReady() {
this.player = SceneHelpers.getPlayer(); this.player = SceneHelpers.getPlayer();
@@ -81,7 +92,7 @@ export class ScenePlayerImpl extends React.Component<IScenePlayerProps, IScenePl
private onSeeked() { private onSeeked() {
const position = this.player.getPosition(); const position = this.player.getPosition();
this.setState({scrubberPosition: position}); this.setState({ scrubberPosition: position });
this.player.play(); this.player.play();
} }
@@ -90,7 +101,7 @@ export class ScenePlayerImpl extends React.Component<IScenePlayerProps, IScenePl
const difference = Math.abs(position - this.lastTime); const difference = Math.abs(position - this.lastTime);
if (difference > 1) { if (difference > 1) {
this.lastTime = position; this.lastTime = position;
this.setState({scrubberPosition: position}); this.setState({ scrubberPosition: position });
} }
} }
@@ -103,16 +114,26 @@ export class ScenePlayerImpl extends React.Component<IScenePlayerProps, IScenePl
} }
private shouldRepeat(scene: GQL.SceneDataFragment) { private shouldRepeat(scene: GQL.SceneDataFragment) {
const maxLoopDuration = this.props.config ? this.props.config.maximumLoopDuration : 0; const maxLoopDuration = this.props.config
return !!scene.file.duration && !!maxLoopDuration && scene.file.duration < maxLoopDuration; ? this.props.config.maximumLoopDuration
: 0;
return (
!!scene.file.duration &&
!!maxLoopDuration &&
scene.file.duration < maxLoopDuration
);
} }
private makeJWPlayerConfig(scene: GQL.SceneDataFragment) { private makeJWPlayerConfig(scene: GQL.SceneDataFragment) {
if (!scene.paths.stream) { return {}; } if (!scene.paths.stream) {
return {};
}
const repeat = this.shouldRepeat(scene); const repeat = this.shouldRepeat(scene);
let getDurationHook: (() => GQL.Maybe<number>) | undefined; let getDurationHook: (() => GQL.Maybe<number>) | undefined;
let seekHook: ((seekToPosition: number, _videoTag: any) => void) | undefined; let seekHook:
| ((seekToPosition: number, _videoTag: any) => void)
| undefined;
let getCurrentTimeHook: ((_videoTag: any) => number) | undefined; let getCurrentTimeHook: ((_videoTag: any) => number) | undefined;
if (!this.props.scene.is_streamable) { if (!this.props.scene.is_streamable) {
@@ -124,14 +145,14 @@ export class ScenePlayerImpl extends React.Component<IScenePlayerProps, IScenePl
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
_videoTag.start = seekToPosition; _videoTag.start = seekToPosition;
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
_videoTag.src = (`${this.props.scene.paths.stream }?start=${ seekToPosition}`); _videoTag.src = `${this.props.scene.paths.stream}?start=${seekToPosition}`;
_videoTag.play(); _videoTag.play();
}; };
getCurrentTimeHook = (_videoTag: any) => { getCurrentTimeHook = (_videoTag: any) => {
const start = _videoTag.start || 0; const start = _videoTag.start || 0;
return _videoTag.currentTime + start; return _videoTag.currentTime + start;
} };
} }
const ret = { const ret = {
@@ -140,21 +161,23 @@ export class ScenePlayerImpl extends React.Component<IScenePlayerProps, IScenePl
tracks: [ tracks: [
{ {
file: scene.paths.vtt, file: scene.paths.vtt,
kind: "thumbnails", kind: "thumbnails"
}, },
{ {
file: scene.paths.chapters_vtt, file: scene.paths.chapters_vtt,
kind: "chapters", kind: "chapters"
}, }
], ],
aspectratio: "16:9", aspectratio: "16:9",
width: "100%", width: "100%",
floating: { floating: {
dismissible: true, dismissible: true
}, },
cast: {}, cast: {},
primary: "html5", primary: "html5",
autostart: this.props.autoplay || (this.props.config ? this.props.config.autostartVideo : false), autostart:
this.props.autoplay ||
(this.props.config ? this.props.config.autostartVideo : false),
repeat, repeat,
playbackRateControls: true, playbackRateControls: true,
playbackRates: [0.75, 1, 1.5, 2, 3, 4], playbackRates: [0.75, 1, 1.5, 2, 3, 4],
@@ -167,17 +190,17 @@ export class ScenePlayerImpl extends React.Component<IScenePlayerProps, IScenePl
} }
renderPlayer() { renderPlayer() {
const config = this.makeJWPlayerConfig(this.props.scene); const config = this.makeJWPlayerConfig(this.props.scene);
return ( return (
<ReactJWPlayer <ReactJWPlayer
playerId={SceneHelpers.getJWPlayerId()} playerId={SceneHelpers.getJWPlayerId()}
playerScript="/jwplayer/jwplayer.js" playerScript="/jwplayer/jwplayer.js"
customProps={config} customProps={config}
onReady={this.onReady} onReady={this.onReady}
onSeeked={this.onSeeked} onSeeked={this.onSeeked}
onTime={this.onTime} onTime={this.onTime}
/> />
); );
} }
public render() { public render() {
@@ -197,8 +220,19 @@ export class ScenePlayerImpl extends React.Component<IScenePlayerProps, IScenePl
} }
} }
export const ScenePlayer: React.FC<IScenePlayerProps> = (props: IScenePlayerProps) => { export const ScenePlayer: React.FC<IScenePlayerProps> = (
const config = StashService.useConfiguration(); props: IScenePlayerProps
) => {
const config = StashService.useConfiguration();
return <ScenePlayerImpl {...props} config={config.data && config.data.configuration ? config.data.configuration.interface : undefined}/> return (
} <ScenePlayerImpl
{...props}
config={
config.data && config.data.configuration
? config.data.configuration.interface
: undefined
}
/>
);
};

View File

@@ -1,7 +1,13 @@
/* eslint-disable react/no-array-index-key */ /* eslint-disable react/no-array-index-key */
import React, { CSSProperties, useEffect, useRef, useState, useCallback } from "react"; import React, {
import { Button } from 'react-bootstrap'; CSSProperties,
useEffect,
useRef,
useState,
useCallback
} from "react";
import { Button } from "react-bootstrap";
import axios from "axios"; import axios from "axios";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
@@ -23,36 +29,42 @@ interface ISceneSpriteItem {
h: number; h: number;
} }
async function fetchSpriteInfo(vttPath: string) { async function fetchSpriteInfo(vttPath: string) {
const response = await axios.get<string>(vttPath, {responseType: "text"}); const response = await axios.get<string>(vttPath, { responseType: "text" });
// TODO: This is gnarly // TODO: This is gnarly
const lines = response.data.split("\n"); const lines = response.data.split("\n");
if (lines.shift() !== "WEBVTT") { return; } if (lines.shift() !== "WEBVTT") {
if (lines.shift() !== "") { return; } return;
let item: ISceneSpriteItem = {start: 0, end: 0, x: 0, y: 0, w: 0, h: 0}; }
if (lines.shift() !== "") {
return;
}
let item: ISceneSpriteItem = { start: 0, end: 0, x: 0, y: 0, w: 0, h: 0 };
const newSpriteItems: ISceneSpriteItem[] = []; const newSpriteItems: ISceneSpriteItem[] = [];
while (lines.length) { while (lines.length) {
const line = lines.shift(); const line = lines.shift();
if (line !== undefined) { if (line !== undefined) {
if (line.includes("#") && line.includes("=") && line.includes(",")) { if (line.includes("#") && line.includes("=") && line.includes(",")) {
const size = line.split("#")[1].split("=")[1].split(","); const size = line
.split("#")[1]
.split("=")[1]
.split(",");
item.x = Number(size[0]); item.x = Number(size[0]);
item.y = Number(size[1]); item.y = Number(size[1]);
item.w = Number(size[2]); item.w = Number(size[2]);
item.h = Number(size[3]); item.h = Number(size[3]);
newSpriteItems.push(item); newSpriteItems.push(item);
item = {start: 0, end: 0, x: 0, y: 0, w: 0, h: 0}; item = { start: 0, end: 0, x: 0, y: 0, w: 0, h: 0 };
} else if (line.includes(" --> ")) { } else if (line.includes(" --> ")) {
const times = line.split(" --> "); const times = line.split(" --> ");
const start = times[0].split(":"); const start = times[0].split(":");
item.start = (+start[0]) * 60 * 60 + (+start[1]) * 60 + (+start[2]); item.start = +start[0] * 60 * 60 + +start[1] * 60 + +start[2];
const end = times[1].split(":"); const end = times[1].split(":");
item.end = (+end[0]) * 60 * 60 + (+end[1]) * 60 + (+end[2]); item.end = +end[0] * 60 * 60 + +end[1] * 60 + +end[2];
} }
} }
} }
@@ -60,8 +72,9 @@ async function fetchSpriteInfo(vttPath: string) {
return newSpriteItems; return newSpriteItems;
} }
export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (
export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (props: IScenePlayerScrubberProps) => { props: IScenePlayerScrubberProps
) => {
const contentEl = useRef<HTMLDivElement>(null); const contentEl = useRef<HTMLDivElement>(null);
const positionIndicatorEl = useRef<HTMLDivElement>(null); const positionIndicatorEl = useRef<HTMLDivElement>(null);
const scrubberSliderEl = useRef<HTMLDivElement>(null); const scrubberSliderEl = useRef<HTMLDivElement>(null);
@@ -72,52 +85,63 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (props:
const _position = useRef(0); const _position = useRef(0);
const getPosition = useCallback(() => _position.current, []); const getPosition = useCallback(() => _position.current, []);
const setPosition = useCallback((newPostion: number, shouldEmit: boolean = true) => { const setPosition = useCallback(
if (!scrubberSliderEl.current || !positionIndicatorEl.current) { return; } (newPostion: number, shouldEmit: boolean = true) => {
if (shouldEmit) { props.onScrolled(); } if (!scrubberSliderEl.current || !positionIndicatorEl.current) {
return;
}
if (shouldEmit) {
props.onScrolled();
}
const midpointOffset = scrubberSliderEl.current.clientWidth / 2; const midpointOffset = scrubberSliderEl.current.clientWidth / 2;
const bounds = getBounds() * -1; const bounds = getBounds() * -1;
if (newPostion > midpointOffset) { if (newPostion > midpointOffset) {
_position.current = midpointOffset; _position.current = midpointOffset;
} else if (newPostion < bounds - midpointOffset) { } else if (newPostion < bounds - midpointOffset) {
_position.current = bounds - midpointOffset; _position.current = bounds - midpointOffset;
} else { } else {
_position.current = newPostion; _position.current = newPostion;
} }
scrubberSliderEl.current.style.transform = `translateX(${_position.current}px)`; scrubberSliderEl.current.style.transform = `translateX(${_position.current}px)`;
const indicatorPosition = ( const indicatorPosition =
(newPostion - midpointOffset) / (bounds - (midpointOffset * 2)) * scrubberSliderEl.current.clientWidth ((newPostion - midpointOffset) / (bounds - midpointOffset * 2)) *
); scrubberSliderEl.current.clientWidth;
positionIndicatorEl.current.style.transform = `translateX(${indicatorPosition}px)`; positionIndicatorEl.current.style.transform = `translateX(${indicatorPosition}px)`;
}, [props]); },
[props]
);
const [spriteItems, setSpriteItems] = useState<ISceneSpriteItem[]>([]); const [spriteItems, setSpriteItems] = useState<ISceneSpriteItem[]>([]);
useEffect(() => { useEffect(() => {
if (!scrubberSliderEl.current) { return; } if (!scrubberSliderEl.current) {
scrubberSliderEl.current.style.transform = `translateX(${scrubberSliderEl.current.clientWidth / 2}px)`; return;
}
scrubberSliderEl.current.style.transform = `translateX(${scrubberSliderEl
.current.clientWidth / 2}px)`;
}, [scrubberSliderEl]); }, [scrubberSliderEl]);
useEffect(() => { useEffect(() => {
if (!props.scene.paths.vtt) if (!props.scene.paths.vtt) return;
return; fetchSpriteInfo(props.scene.paths.vtt).then(sprites => {
fetchSpriteInfo(props.scene.paths.vtt).then((sprites) => { if (sprites) setSpriteItems(sprites);
if(sprites)
setSpriteItems(sprites);
}); });
}, [props.scene]); }, [props.scene]);
useEffect(() => { useEffect(() => {
if (!scrubberSliderEl.current) { return; } if (!scrubberSliderEl.current) {
return;
}
const duration = Number(props.scene.file.duration); const duration = Number(props.scene.file.duration);
const percentage = props.position / duration; const percentage = props.position / duration;
const position = ( const position =
(scrubberSliderEl.current.scrollWidth * percentage) - (scrubberSliderEl.current.clientWidth / 2) (scrubberSliderEl.current.scrollWidth * percentage -
) * -1; scrubberSliderEl.current.clientWidth / 2) *
-1;
setPosition(position, false); setPosition(position, false);
}, [props.position, props.scene.file.duration, setPosition]); }, [props.position, props.scene.file.duration, setPosition]);
@@ -129,37 +153,48 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (props:
}); });
useEffect(() => { useEffect(() => {
if (!contentEl.current) { return; } if (!contentEl.current) {
return;
}
const el = contentEl.current; const el = contentEl.current;
el.addEventListener("mousedown", onMouseDown, false); el.addEventListener("mousedown", onMouseDown, false);
return () => { return () => {
if (!el) { return; } if (!el) {
return;
}
el.removeEventListener("mousedown", onMouseDown); el.removeEventListener("mousedown", onMouseDown);
}; };
}); });
useEffect(() => { useEffect(() => {
if (!contentEl.current) { return; } if (!contentEl.current) {
return;
}
const el = contentEl.current; const el = contentEl.current;
el.addEventListener("mousemove", onMouseMove, false); el.addEventListener("mousemove", onMouseMove, false);
return () => { return () => {
if (!el) { return; } if (!el) {
return;
}
el.removeEventListener("mousemove", onMouseMove); el.removeEventListener("mousemove", onMouseMove);
}; };
}); });
function onMouseUp(this: Window, event: MouseEvent) { function onMouseUp(this: Window, event: MouseEvent) {
if (!startMouseEvent.current || !scrubberSliderEl.current) { return; } if (!startMouseEvent.current || !scrubberSliderEl.current) {
return;
}
mouseDown.current = false; mouseDown.current = false;
const delta = Math.abs(event.clientX - startMouseEvent.current.clientX); const delta = Math.abs(event.clientX - startMouseEvent.current.clientX);
if (delta < 1 && event.target instanceof HTMLDivElement) { if (delta < 1 && event.target instanceof HTMLDivElement) {
const {target} = event; const { target } = event;
let seekSeconds: number | undefined; let seekSeconds: number | undefined;
const spriteIdString = target.getAttribute("data-sprite-item-id"); const spriteIdString = target.getAttribute("data-sprite-item-id");
if (spriteIdString != null) { if (spriteIdString != null) {
const spritePercentage = event.offsetX / target.clientWidth; const spritePercentage = event.offsetX / target.clientWidth;
const offset = target.offsetLeft + (target.clientWidth * spritePercentage); const offset =
target.offsetLeft + target.clientWidth * spritePercentage;
const percentage = offset / scrubberSliderEl.current.scrollWidth; const percentage = offset / scrubberSliderEl.current.scrollWidth;
seekSeconds = percentage * (props.scene.file.duration || 0); seekSeconds = percentage * (props.scene.file.duration || 0);
} }
@@ -170,9 +205,11 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (props:
seekSeconds = marker.seconds; seekSeconds = marker.seconds;
} }
if (seekSeconds) { props.onSeek(seekSeconds); } if (seekSeconds) {
props.onSeek(seekSeconds);
}
} else if (Math.abs(velocity.current) > 25) { } else if (Math.abs(velocity.current) > 25) {
const newPosition = getPosition() + (velocity.current * 10); const newPosition = getPosition() + velocity.current * 10;
setPosition(newPosition); setPosition(newPosition);
velocity.current = 0; velocity.current = 0;
} }
@@ -187,7 +224,9 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (props:
} }
function onMouseMove(this: HTMLDivElement, event: MouseEvent) { function onMouseMove(this: HTMLDivElement, event: MouseEvent) {
if (!mouseDown.current) { return; } if (!mouseDown.current) {
return;
}
// negative dragging right (past), positive left (future) // negative dragging right (past), positive left (future)
const delta = event.clientX - lastMouseEvent.current.clientX; const delta = event.clientX - lastMouseEvent.current.clientX;
@@ -201,30 +240,45 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (props:
} }
function getBounds(): number { function getBounds(): number {
if (!scrubberSliderEl.current || !positionIndicatorEl.current) { return 0; } if (!scrubberSliderEl.current || !positionIndicatorEl.current) {
return scrubberSliderEl.current.scrollWidth - scrubberSliderEl.current.clientWidth; return 0;
}
return (
scrubberSliderEl.current.scrollWidth -
scrubberSliderEl.current.clientWidth
);
} }
function goBack() { function goBack() {
if (!scrubberSliderEl.current) { return; } if (!scrubberSliderEl.current) {
return;
}
const newPosition = getPosition() + scrubberSliderEl.current.clientWidth; const newPosition = getPosition() + scrubberSliderEl.current.clientWidth;
setPosition(newPosition); setPosition(newPosition);
} }
function goForward() { function goForward() {
if (!scrubberSliderEl.current) { return; } if (!scrubberSliderEl.current) {
return;
}
const newPosition = getPosition() - scrubberSliderEl.current.clientWidth; const newPosition = getPosition() - scrubberSliderEl.current.clientWidth;
setPosition(newPosition); setPosition(newPosition);
} }
function renderTags() { function renderTags() {
function getTagStyle(i: number): CSSProperties { function getTagStyle(i: number): CSSProperties {
if (!scrubberSliderEl.current || if (
spriteItems.length === 0 || !scrubberSliderEl.current ||
getBounds() === 0) { return {}; } spriteItems.length === 0 ||
getBounds() === 0
) {
return {};
}
const tags = window.document.getElementsByClassName("scrubber-tag"); const tags = window.document.getElementsByClassName("scrubber-tag");
if (tags.length === 0) { return {}; } if (tags.length === 0) {
return {};
}
let tag: any; let tag: any;
for (let index = 0; index < tags.length; index++) { for (let index = 0; index < tags.length; index++) {
@@ -239,16 +293,17 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (props:
const duration = Number(props.scene.file.duration); const duration = Number(props.scene.file.duration);
const percentage = marker.seconds / duration; const percentage = marker.seconds / duration;
const left = (scrubberSliderEl.current.scrollWidth * percentage) - (tag.clientWidth / 2); const left =
scrubberSliderEl.current.scrollWidth * percentage - tag.clientWidth / 2;
return { return {
left: `${left}px`, left: `${left}px`,
height: 20, height: 20
}; };
} }
return props.scene.scene_markers.map((marker, index) => { return props.scene.scene_markers.map((marker, index) => {
const dataAttrs = { const dataAttrs = {
"data-marker-id": index, "data-marker-id": index
}; };
return ( return (
<div <div
@@ -265,7 +320,9 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (props:
function renderSprites() { function renderSprites() {
function getStyleForSprite(index: number): CSSProperties { function getStyleForSprite(index: number): CSSProperties {
if (!props.scene.paths.vtt) { return {}; } if (!props.scene.paths.vtt) {
return {};
}
const sprite = spriteItems[index]; const sprite = spriteItems[index];
const left = sprite.w * index; const left = sprite.w * index;
const path = props.scene.paths.vtt.replace("_thumbs.vtt", "_sprite.jpg"); // TODO: Gnarly const path = props.scene.paths.vtt.replace("_thumbs.vtt", "_sprite.jpg"); // TODO: Gnarly
@@ -273,15 +330,15 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (props:
width: `${sprite.w}px`, width: `${sprite.w}px`,
height: `${sprite.h}px`, height: `${sprite.h}px`,
margin: "0px auto", margin: "0px auto",
backgroundPosition: `${-sprite.x }px ${ -sprite.y }px`, backgroundPosition: `${-sprite.x}px ${-sprite.y}px`,
backgroundImage: `url(${path})`, backgroundImage: `url(${path})`,
left: `${left}px`, left: `${left}px`
}; };
} }
return spriteItems.map((spriteItem, index) => { return spriteItems.map((spriteItem, index) => {
const dataAttrs = { const dataAttrs = {
"data-sprite-item-id": index, "data-sprite-item-id": index
}; };
return ( return (
<div <div
@@ -290,7 +347,10 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (props:
style={getStyleForSprite(index)} style={getStyleForSprite(index)}
{...dataAttrs} {...dataAttrs}
> >
<span>{TextUtils.secondsToTimestamp(spriteItem.start)} - {TextUtils.secondsToTimestamp(spriteItem.end)}</span> <span>
{TextUtils.secondsToTimestamp(spriteItem.start)} -{" "}
{TextUtils.secondsToTimestamp(spriteItem.end)}
</span>
</div> </div>
); );
}); });
@@ -298,21 +358,32 @@ export const ScenePlayerScrubber: React.FC<IScenePlayerScrubberProps> = (props:
return ( return (
<div className="scrubber-wrapper"> <div className="scrubber-wrapper">
<Button variant="link" className="scrubber-button" id="scrubber-back" onClick={() => goBack()}>&lt;</Button> <Button
variant="link"
className="scrubber-button"
id="scrubber-back"
onClick={() => goBack()}
>
&lt;
</Button>
<div ref={contentEl} className="scrubber-content"> <div ref={contentEl} className="scrubber-content">
<div className="scrubber-tags-background" /> <div className="scrubber-tags-background" />
<div ref={positionIndicatorEl} id="scrubber-position-indicator" /> <div ref={positionIndicatorEl} id="scrubber-position-indicator" />
<div id="scrubber-current-position" /> <div id="scrubber-current-position" />
<div className="scrubber-viewport"> <div className="scrubber-viewport">
<div ref={scrubberSliderEl} className="scrubber-slider"> <div ref={scrubberSliderEl} className="scrubber-slider">
<div className="scrubber-tags"> <div className="scrubber-tags">{renderTags()}</div>
{renderTags()}
</div>
{renderSprites()} {renderSprites()}
</div> </div>
</div> </div>
</div> </div>
<Button className="scrubber-button" id="scrubber-forward" onClick={() => goForward()}>&gt;</Button> <Button
className="scrubber-button"
id="scrubber-forward"
onClick={() => goForward()}
>
&gt;
</Button>
</div> </div>
); );
}; };

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, ButtonGroup, Form, Spinner } from 'react-bootstrap'; import { Button, ButtonGroup, Form, Spinner } from "react-bootstrap";
import _ from "lodash"; import _ from "lodash";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -7,15 +7,19 @@ import { FilterSelect, StudioSelect } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
interface IListOperationProps { interface IListOperationProps {
selected: GQL.SlimSceneDataFragment[], selected: GQL.SlimSceneDataFragment[];
onScenesUpdated: () => void; onScenesUpdated: () => void;
} }
export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IListOperationProps) => { export const SceneSelectedOptions: React.FC<IListOperationProps> = (
props: IListOperationProps
) => {
const Toast = useToast(); const Toast = useToast();
const [rating, setRating] = useState<string>(""); const [rating, setRating] = useState<string>("");
const [studioId, setStudioId] = useState<string | undefined>(undefined); const [studioId, setStudioId] = useState<string | undefined>(undefined);
const [performerIds, setPerformerIds] = useState<string[] | undefined>(undefined); const [performerIds, setPerformerIds] = useState<string[] | undefined>(
undefined
);
const [tagIds, setTagIds] = useState<string[] | undefined>(undefined); const [tagIds, setTagIds] = useState<string[] | undefined>(undefined);
const updateScenes = StashService.useBulkSceneUpdate(getSceneInput()); const updateScenes = StashService.useBulkSceneUpdate(getSceneInput());
@@ -23,15 +27,15 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
// Network state // Network state
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
function getSceneInput() : GQL.BulkSceneUpdateInput { function getSceneInput(): GQL.BulkSceneUpdateInput {
// need to determine what we are actually setting on each scene // need to determine what we are actually setting on each scene
const aggregateRating = getRating(props.selected); const aggregateRating = getRating(props.selected);
const aggregateStudioId = getStudioId(props.selected); const aggregateStudioId = getStudioId(props.selected);
const aggregatePerformerIds = getPerformerIds(props.selected); const aggregatePerformerIds = getPerformerIds(props.selected);
const aggregateTagIds = getTagIds(props.selected); const aggregateTagIds = getTagIds(props.selected);
const sceneInput : GQL.BulkSceneUpdateInput = { const sceneInput: GQL.BulkSceneUpdateInput = {
ids: props.selected.map((scene) => { ids: props.selected.map(scene => {
return scene.id; return scene.id;
}) })
}; };
@@ -39,7 +43,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
// if rating is undefined // if rating is undefined
if (rating === "") { if (rating === "") {
// and all scenes have the same rating, then we are unsetting the rating. // and all scenes have the same rating, then we are unsetting the rating.
if(aggregateRating) { if (aggregateRating) {
// an undefined rating is ignored in the server, so set it to 0 instead // an undefined rating is ignored in the server, so set it to 0 instead
sceneInput.rating = 0; sceneInput.rating = 0;
} }
@@ -102,26 +106,26 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
} }
function getRating(state: GQL.SlimSceneDataFragment[]) { function getRating(state: GQL.SlimSceneDataFragment[]) {
let ret : number | undefined; let ret: number | undefined;
let first = true; let first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => { state.forEach((scene: GQL.SlimSceneDataFragment) => {
if (first) { if (first) {
ret = scene.rating; ret = scene.rating;
first = false; first = false;
} else if (ret !== scene.rating) { } else if (ret !== scene.rating) {
ret = undefined; ret = undefined;
} }
}); });
return ret; return ret;
} }
function getStudioId(state: GQL.SlimSceneDataFragment[]) { function getStudioId(state: GQL.SlimSceneDataFragment[]) {
let ret : string | undefined; let ret: string | undefined;
let first = true; let first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => { state.forEach((scene: GQL.SlimSceneDataFragment) => {
if (first) { if (first) {
ret = scene?.studio?.id; ret = scene?.studio?.id;
first = false; first = false;
@@ -137,15 +141,17 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
} }
function getPerformerIds(state: GQL.SlimSceneDataFragment[]) { function getPerformerIds(state: GQL.SlimSceneDataFragment[]) {
let ret : string[] = []; let ret: string[] = [];
let first = true; let first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => { state.forEach((scene: GQL.SlimSceneDataFragment) => {
if (first) { if (first) {
ret = scene.performers ? scene.performers.map(p => p.id).sort() : []; ret = scene.performers ? scene.performers.map(p => p.id).sort() : [];
first = false; first = false;
} else { } else {
const perfIds = scene.performers ? scene.performers.map(p => p.id).sort() : []; const perfIds = scene.performers
? scene.performers.map(p => p.id).sort()
: [];
if (!_.isEqual(ret, perfIds)) { if (!_.isEqual(ret, perfIds)) {
ret = []; ret = [];
@@ -157,10 +163,10 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
} }
function getTagIds(state: GQL.SlimSceneDataFragment[]) { function getTagIds(state: GQL.SlimSceneDataFragment[]) {
let ret : string[] = []; let ret: string[] = [];
let first = true; let first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => { state.forEach((scene: GQL.SlimSceneDataFragment) => {
if (first) { if (first) {
ret = scene.tags ? scene.tags.map(t => t.id).sort() : []; ret = scene.tags ? scene.tags.map(t => t.id).sort() : [];
first = false; first = false;
@@ -178,19 +184,21 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
function updateScenesEditState(state: GQL.SlimSceneDataFragment[]) { function updateScenesEditState(state: GQL.SlimSceneDataFragment[]) {
let updateRating = ""; let updateRating = "";
let updateStudioId : string | undefined; let updateStudioId: string | undefined;
let updatePerformerIds : string[] = []; let updatePerformerIds: string[] = [];
let updateTagIds : string[] = []; let updateTagIds: string[] = [];
let first = true; let first = true;
state.forEach((scene : GQL.SlimSceneDataFragment) => { state.forEach((scene: GQL.SlimSceneDataFragment) => {
const thisRating = scene.rating ? scene.rating.toString() : ""; const thisRating = scene.rating ? scene.rating.toString() : "";
const thisStudio = scene.studio ? scene.studio.id : undefined; const thisStudio = scene.studio ? scene.studio.id : undefined;
if (first) { if (first) {
updateRating = thisRating; updateRating = thisRating;
updateStudioId = thisStudio; updateStudioId = thisStudio;
updatePerformerIds = scene.performers ? scene.performers.map(p => p.id).sort() : []; updatePerformerIds = scene.performers
? scene.performers.map(p => p.id).sort()
: [];
updateTagIds = scene.tags ? scene.tags.map(p => p.id).sort() : []; updateTagIds = scene.tags ? scene.tags.map(p => p.id).sort() : [];
first = false; first = false;
} else { } else {
@@ -200,7 +208,9 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
if (studioId !== thisStudio) { if (studioId !== thisStudio) {
updateStudioId = undefined; updateStudioId = undefined;
} }
const perfIds = scene.performers ? scene.performers.map(p => p.id).sort() : []; const perfIds = scene.performers
? scene.performers.map(p => p.id).sort()
: [];
const tIds = scene.tags ? scene.tags.map(t => t.id).sort() : []; const tIds = scene.tags ? scene.tags.map(t => t.id).sort() : [];
if (!_.isEqual(performerIds, perfIds)) { if (!_.isEqual(performerIds, perfIds)) {
@@ -223,16 +233,23 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
updateScenesEditState(props.selected); updateScenesEditState(props.selected);
}, [props.selected]); }, [props.selected]);
function renderMultiSelect(type: "performers" | "tags", initialIds: string[] | undefined) { function renderMultiSelect(
type: "performers" | "tags",
initialIds: string[] | undefined
) {
return ( return (
<FilterSelect <FilterSelect
type={type} type={type}
isMulti isMulti
onSelect={(items) => { onSelect={items => {
const ids = items.map((i) => i.id); const ids = items.map(i => i.id);
switch (type) { switch (type) {
case "performers": setPerformerIds(ids); break; case "performers":
case "tags": setTagIds(ids); break; setPerformerIds(ids);
break;
case "tags":
setTagIds(ids);
break;
} }
}} }}
initialIds={initialIds ?? []} initialIds={initialIds ?? []}
@@ -249,17 +266,20 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
<Form.Label>Rating</Form.Label> <Form.Label>Rating</Form.Label>
<Form.Control <Form.Control
as="select" as="select"
onChange={(event: any) => setRating(event.target.value)}> onChange={(event: any) => setRating(event.target.value)}
{ ["", 1, 2, 3, 4, 5].map(opt => ( >
<option selected={opt === rating} value={opt}>{opt}</option> {["", 1, 2, 3, 4, 5].map(opt => (
)) } <option selected={opt === rating} value={opt}>
{opt}
</option>
))}
</Form.Control> </Form.Control>
</Form.Group> </Form.Group>
<Form.Group controlId="studio" className="operation-item"> <Form.Group controlId="studio" className="operation-item">
<Form.Label>Studio</Form.Label> <Form.Label>Studio</Form.Label>
<StudioSelect <StudioSelect
onSelect={(items) => setStudioId(items[0]?.id)} onSelect={items => setStudioId(items[0]?.id)}
initialIds={studioId ? [studioId] : []} initialIds={studioId ? [studioId] : []}
/> />
</Form.Group> </Form.Group>
@@ -275,10 +295,8 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
</Form.Group> </Form.Group>
<ButtonGroup className="operation-item"> <ButtonGroup className="operation-item">
<Button <Button variant="primary" onClick={onSave}>
variant="primary" Apply
onClick={onSave}>
Apply
</Button> </Button>
</ButtonGroup> </ButtonGroup>
</div> </div>

View File

@@ -1,4 +1,4 @@
import React, { } from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -7,8 +7,7 @@ export class SceneHelpers {
scene: GQL.SceneDataFragment | GQL.SlimSceneDataFragment, scene: GQL.SceneDataFragment | GQL.SlimSceneDataFragment,
height: number height: number
) { ) {
if (!scene.studio) if (!scene.studio) return;
return;
const style: React.CSSProperties = { const style: React.CSSProperties = {
backgroundImage: `url('${scene.studio.image_path}')`, backgroundImage: `url('${scene.studio.image_path}')`,
width: "100%", width: "100%",
@@ -17,17 +16,14 @@ export class SceneHelpers {
backgroundSize: "contain", backgroundSize: "contain",
display: "inline-block", display: "inline-block",
backgroundPosition: "center", backgroundPosition: "center",
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat"
}; };
return ( return <Link to={`/studios/${scene.studio.id}`} style={style} />;
<Link
to={`/studios/${scene.studio.id}`}
style={style}
/>
);
} }
public static getJWPlayerId(): string { return "main-jwplayer"; } public static getJWPlayerId(): string {
return "main-jwplayer";
}
public static getPlayer(): any { public static getPlayer(): any {
return (window as any).jwplayer("main-jwplayer"); return (window as any).jwplayer("main-jwplayer");
} }

View File

@@ -1,6 +1,6 @@
import ApolloClient from "apollo-client"; import ApolloClient from "apollo-client";
import { WebSocketLink } from 'apollo-link-ws'; import { WebSocketLink } from "apollo-link-ws";
import { InMemoryCache } from 'apollo-cache-inmemory'; import { InMemoryCache } from "apollo-cache-inmemory";
import { HttpLink, split } from "apollo-boost"; import { HttpLink, split } from "apollo-boost";
import { getMainDefinition } from "apollo-utilities"; import { getMainDefinition } from "apollo-utilities";
import { ListFilterModel } from "../models/list-filter/filter"; import { ListFilterModel } from "../models/list-filter/filter";
@@ -27,27 +27,27 @@ export class StashService {
if (platformUrl.protocol === "https:") { if (platformUrl.protocol === "https:") {
wsPlatformUrl.protocol = "wss:"; wsPlatformUrl.protocol = "wss:";
} }
const url = `${platformUrl.toString().slice(0, -1) }/graphql`; const url = `${platformUrl.toString().slice(0, -1)}/graphql`;
const wsUrl = `${wsPlatformUrl.toString().slice(0, -1) }/graphql`; const wsUrl = `${wsPlatformUrl.toString().slice(0, -1)}/graphql`;
const httpLink = new HttpLink({ const httpLink = new HttpLink({
uri: url, uri: url
}); });
const wsLink = new WebSocketLink({ const wsLink = new WebSocketLink({
uri: wsUrl, uri: wsUrl,
options: { options: {
reconnect: true reconnect: true
}, }
}); });
const link = split( const link = split(
({ query }) => { ({ query }) => {
const { kind, operation } = getMainDefinition(query); const { kind, operation } = getMainDefinition(query);
return kind === 'OperationDefinition' && operation === 'subscription'; return kind === "OperationDefinition" && operation === "subscription";
}, },
wsLink, wsLink,
httpLink, httpLink
); );
StashService.cache = new InMemoryCache(); StashService.cache = new InMemoryCache();
@@ -64,18 +64,20 @@ export class StashService {
StashService.client.resetStore(); StashService.client.resetStore();
} }
private static invalidateQueries(queries : string[]) { private static invalidateQueries(queries: string[]) {
if (StashService.cache) { if (StashService.cache) {
const cache = StashService.cache as any; const cache = StashService.cache as any;
const keyMatchers = queries.map(query => { const keyMatchers = queries.map(query => {
return new RegExp(`^${ query}`); return new RegExp(`^${query}`);
}); });
const rootQuery = cache.data.data.ROOT_QUERY; const rootQuery = cache.data.data.ROOT_QUERY;
Object.keys(rootQuery).forEach(key => { Object.keys(rootQuery).forEach(key => {
if (keyMatchers.some(matcher => { if (
return !!key.match(matcher); keyMatchers.some(matcher => {
})) { return !!key.match(matcher);
})
) {
delete rootQuery[key]; delete rootQuery[key];
} }
}); });
@@ -85,8 +87,8 @@ export class StashService {
public static useFindGalleries(filter: ListFilterModel) { public static useFindGalleries(filter: ListFilterModel) {
return GQL.useFindGalleries({ return GQL.useFindGalleries({
variables: { variables: {
filter: filter.makeFindFilter(), filter: filter.makeFindFilter()
}, }
}); });
} }
@@ -104,8 +106,8 @@ export class StashService {
return GQL.useFindScenes({ return GQL.useFindScenes({
variables: { variables: {
filter: filter.makeFindFilter(), filter: filter.makeFindFilter(),
scene_filter: sceneFilter, scene_filter: sceneFilter
}, }
}); });
} }
@@ -117,7 +119,7 @@ export class StashService {
query: GQL.FindScenesDocument, query: GQL.FindScenesDocument,
variables: { variables: {
filter: filter.makeFindFilter(), filter: filter.makeFindFilter(),
scene_filter: sceneFilter, scene_filter: sceneFilter
} }
}); });
} }
@@ -136,8 +138,8 @@ export class StashService {
return GQL.useFindSceneMarkers({ return GQL.useFindSceneMarkers({
variables: { variables: {
filter: filter.makeFindFilter(), filter: filter.makeFindFilter(),
scene_marker_filter: sceneMarkerFilter, scene_marker_filter: sceneMarkerFilter
}, }
}); });
} }
@@ -149,7 +151,7 @@ export class StashService {
query: GQL.FindSceneMarkersDocument, query: GQL.FindSceneMarkersDocument,
variables: { variables: {
filter: filter.makeFindFilter(), filter: filter.makeFindFilter(),
scene_marker_filter: sceneMarkerFilter, scene_marker_filter: sceneMarkerFilter
} }
}); });
} }
@@ -157,8 +159,8 @@ export class StashService {
public static useFindStudios(filter: ListFilterModel) { public static useFindStudios(filter: ListFilterModel) {
return GQL.useFindStudios({ return GQL.useFindStudios({
variables: { variables: {
filter: filter.makeFindFilter(), filter: filter.makeFindFilter()
}, }
}); });
} }
@@ -176,8 +178,8 @@ export class StashService {
return GQL.useFindPerformers({ return GQL.useFindPerformers({
variables: { variables: {
filter: filter.makeFindFilter(), filter: filter.makeFindFilter(),
performer_filter: performerFilter, performer_filter: performerFilter
}, }
}); });
} }
@@ -189,20 +191,24 @@ export class StashService {
query: GQL.FindPerformersDocument, query: GQL.FindPerformersDocument,
variables: { variables: {
filter: filter.makeFindFilter(), filter: filter.makeFindFilter(),
performer_filter: performerFilter, performer_filter: performerFilter
} }
}); });
} }
public static useFindGallery(id: string) { return GQL.useFindGallery({variables: {id}}); } public static useFindGallery(id: string) {
public static useFindScene(id: string) { return GQL.useFindScene({variables: {id}}); } return GQL.useFindGallery({ variables: { id } });
}
public static useFindScene(id: string) {
return GQL.useFindScene({ variables: { id } });
}
public static useFindPerformer(id: string) { public static useFindPerformer(id: string) {
const skip = id === "new"; const skip = id === "new";
return GQL.useFindPerformer({variables: {id}, skip}); return GQL.useFindPerformer({ variables: { id }, skip });
} }
public static useFindStudio(id: string) { public static useFindStudio(id: string) {
const skip = id === "new"; const skip = id === "new";
return GQL.useFindStudio({variables: {id}, skip}); return GQL.useFindStudio({ variables: { id }, skip });
} }
// TODO - scene marker manipulation functions are handled differently // TODO - scene marker manipulation functions are handled differently
@@ -226,31 +232,59 @@ export class StashService {
public static useListPerformerScrapers() { public static useListPerformerScrapers() {
return GQL.useListPerformerScrapers(); return GQL.useListPerformerScrapers();
} }
public static useScrapePerformerList(scraperId: string, q : string) { public static useScrapePerformerList(scraperId: string, q: string) {
return GQL.useScrapePerformerList({ variables: { scraper_id: scraperId, query: q }, skip: q === ''}); return GQL.useScrapePerformerList({
variables: { scraper_id: scraperId, query: q },
skip: q === ""
});
} }
public static useScrapePerformer(scraperId: string, scrapedPerformer : GQL.ScrapedPerformerInput) { public static useScrapePerformer(
return GQL.useScrapePerformer({ variables: { scraper_id: scraperId, scraped_performer: scrapedPerformer }}); scraperId: string,
scrapedPerformer: GQL.ScrapedPerformerInput
) {
return GQL.useScrapePerformer({
variables: { scraper_id: scraperId, scraped_performer: scrapedPerformer }
});
} }
public static useListSceneScrapers() { public static useListSceneScrapers() {
return GQL.useListSceneScrapers(); return GQL.useListSceneScrapers();
} }
public static useScrapeFreeonesPerformers(q: string) { return GQL.useScrapeFreeonesPerformers({ variables: { q } }); } public static useScrapeFreeonesPerformers(q: string) {
public static useMarkerStrings() { return GQL.useMarkerStrings(); } return GQL.useScrapeFreeonesPerformers({ variables: { q } });
public static useAllTags() { return GQL.useAllTags(); } }
public static useAllTagsForFilter() { return GQL.useAllTagsForFilter(); } public static useMarkerStrings() {
public static useAllPerformersForFilter() { return GQL.useAllPerformersForFilter(); } return GQL.useMarkerStrings();
public static useAllStudiosForFilter() { return GQL.useAllStudiosForFilter(); } }
public static useValidGalleriesForScene(sceneId: string) { public static useAllTags() {
return GQL.useValidGalleriesForScene({variables: {scene_id: sceneId}}); 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 useVersion() {
return GQL.useVersion();
} }
public static useStats() { return GQL.useStats(); }
public static useVersion() { return GQL.useVersion(); }
public static useConfiguration() { return GQL.useConfiguration(); } public static useConfiguration() {
public static useDirectories(path?: string) { return GQL.useDirectories({ variables: { path }}); } return GQL.useConfiguration();
}
public static useDirectories(path?: string) {
return GQL.useDirectories({ variables: { path } });
}
private static performerMutationImpactedQueries = [ private static performerMutationImpactedQueries = [
"findPerformers", "findPerformers",
@@ -262,19 +296,28 @@ export class StashService {
public static usePerformerCreate(input: GQL.PerformerCreateInput) { public static usePerformerCreate(input: GQL.PerformerCreateInput) {
return GQL.usePerformerCreate({ return GQL.usePerformerCreate({
variables: input, variables: input,
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries) update: () =>
StashService.invalidateQueries(
StashService.performerMutationImpactedQueries
)
}); });
} }
public static usePerformerUpdate(input: GQL.PerformerUpdateInput) { public static usePerformerUpdate(input: GQL.PerformerUpdateInput) {
return GQL.usePerformerUpdate({ return GQL.usePerformerUpdate({
variables: input, variables: input,
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries) update: () =>
StashService.invalidateQueries(
StashService.performerMutationImpactedQueries
)
}); });
} }
public static usePerformerDestroy(input: GQL.PerformerDestroyInput) { public static usePerformerDestroy(input: GQL.PerformerDestroyInput) {
return GQL.usePerformerDestroy({ return GQL.usePerformerDestroy({
variables: input, variables: input,
update: () => StashService.invalidateQueries(StashService.performerMutationImpactedQueries) update: () =>
StashService.invalidateQueries(
StashService.performerMutationImpactedQueries
)
}); });
} }
@@ -290,7 +333,10 @@ export class StashService {
public static useSceneUpdate(input: GQL.SceneUpdateInput) { public static useSceneUpdate(input: GQL.SceneUpdateInput) {
return GQL.useSceneUpdate({ return GQL.useSceneUpdate({
variables: input, variables: input,
update: () => StashService.invalidateQueries(StashService.sceneMutationImpactedQueries), update: () =>
StashService.invalidateQueries(
StashService.sceneMutationImpactedQueries
),
refetchQueries: ["AllTagsForFilter"] refetchQueries: ["AllTagsForFilter"]
}); });
} }
@@ -307,18 +353,24 @@ export class StashService {
public static useBulkSceneUpdate(input: GQL.BulkSceneUpdateInput) { public static useBulkSceneUpdate(input: GQL.BulkSceneUpdateInput) {
return GQL.useBulkSceneUpdate({ return GQL.useBulkSceneUpdate({
variables: input, variables: input,
update: () => StashService.invalidateQueries(StashService.sceneBulkMutationImpactedQueries) update: () =>
StashService.invalidateQueries(
StashService.sceneBulkMutationImpactedQueries
)
}); });
} }
public static useScenesUpdate(input: GQL.SceneUpdateInput[]) { public static useScenesUpdate(input: GQL.SceneUpdateInput[]) {
return GQL.useScenesUpdate({ variables: { input }}); return GQL.useScenesUpdate({ variables: { input } });
} }
public static useSceneDestroy(input: GQL.SceneDestroyInput) { public static useSceneDestroy(input: GQL.SceneDestroyInput) {
return GQL.useSceneDestroy({ return GQL.useSceneDestroy({
variables: input, variables: input,
update: () => StashService.invalidateQueries(StashService.sceneMutationImpactedQueries) update: () =>
StashService.invalidateQueries(
StashService.sceneMutationImpactedQueries
)
}); });
} }
@@ -331,21 +383,30 @@ export class StashService {
public static useStudioCreate(input: GQL.StudioCreateInput) { public static useStudioCreate(input: GQL.StudioCreateInput) {
return GQL.useStudioCreate({ return GQL.useStudioCreate({
variables: input, variables: input,
update: () => StashService.invalidateQueries(StashService.studioMutationImpactedQueries) update: () =>
StashService.invalidateQueries(
StashService.studioMutationImpactedQueries
)
}); });
} }
public static useStudioUpdate(input: GQL.StudioUpdateInput) { public static useStudioUpdate(input: GQL.StudioUpdateInput) {
return GQL.useStudioUpdate({ return GQL.useStudioUpdate({
variables: input, variables: input,
update: () => StashService.invalidateQueries(StashService.studioMutationImpactedQueries) update: () =>
StashService.invalidateQueries(
StashService.studioMutationImpactedQueries
)
}); });
} }
public static useStudioDestroy(input: GQL.StudioDestroyInput) { public static useStudioDestroy(input: GQL.StudioDestroyInput) {
return GQL.useStudioDestroy({ return GQL.useStudioDestroy({
variables: input, variables: input,
update: () => StashService.invalidateQueries(StashService.studioMutationImpactedQueries) update: () =>
StashService.invalidateQueries(
StashService.studioMutationImpactedQueries
)
}); });
} }
@@ -359,30 +420,37 @@ export class StashService {
public static useTagCreate(input: GQL.TagCreateInput) { public static useTagCreate(input: GQL.TagCreateInput) {
return GQL.useTagCreate({ return GQL.useTagCreate({
variables: input, variables: input,
refetchQueries: ["AllTags", "AllTagsForFilter"], refetchQueries: ["AllTags", "AllTagsForFilter"]
// update: () => StashService.invalidateQueries(StashService.tagMutationImpactedQueries) // update: () => StashService.invalidateQueries(StashService.tagMutationImpactedQueries)
}); });
} }
public static useTagUpdate(input: GQL.TagUpdateInput) { public static useTagUpdate(input: GQL.TagUpdateInput) {
return GQL.useTagUpdate({ return GQL.useTagUpdate({
variables: input, variables: input,
refetchQueries: ["AllTags", "AllTagsForFilter"], refetchQueries: ["AllTags", "AllTagsForFilter"]
}); });
} }
public static useTagDestroy(input: GQL.TagDestroyInput) { public static useTagDestroy(input: GQL.TagDestroyInput) {
return GQL.useTagDestroy({ return GQL.useTagDestroy({
variables: input, variables: input,
refetchQueries: ["AllTags", "AllTagsForFilter"], refetchQueries: ["AllTags", "AllTagsForFilter"],
update: () => StashService.invalidateQueries(StashService.tagMutationImpactedQueries) update: () =>
StashService.invalidateQueries(StashService.tagMutationImpactedQueries)
}); });
} }
public static useConfigureGeneral(input: GQL.ConfigGeneralInput) { public static useConfigureGeneral(input: GQL.ConfigGeneralInput) {
return GQL.useConfigureGeneral({ variables: { input }, refetchQueries: ["Configuration"] }); return GQL.useConfigureGeneral({
variables: { input },
refetchQueries: ["Configuration"]
});
} }
public static useConfigureInterface(input: GQL.ConfigInterfaceInput) { public static useConfigureInterface(input: GQL.ConfigInterfaceInput) {
return GQL.useConfigureInterface({ variables: { input }, refetchQueries: ["Configuration"] }); return GQL.useConfigureInterface({
variables: { input },
refetchQueries: ["Configuration"]
});
} }
public static useMetadataUpdate() { public static useMetadataUpdate() {
@@ -395,20 +463,20 @@ export class StashService {
public static useLogs() { public static useLogs() {
return GQL.useLogs({ return GQL.useLogs({
fetchPolicy: 'no-cache' fetchPolicy: "no-cache"
}); });
} }
public static useJobStatus() { public static useJobStatus() {
return GQL.useJobStatus({ return GQL.useJobStatus({
fetchPolicy: 'no-cache' fetchPolicy: "no-cache"
}); });
} }
public static queryStopJob() { public static queryStopJob() {
return StashService.client.query<GQL.StopJobQuery>({ return StashService.client.query<GQL.StopJobQuery>({
query: GQL.StopJobDocument, query: GQL.StopJobDocument,
fetchPolicy: "network-only", fetchPolicy: "network-only"
}); });
} }
@@ -416,18 +484,21 @@ export class StashService {
return StashService.client.query<GQL.ScrapeFreeonesQuery>({ return StashService.client.query<GQL.ScrapeFreeonesQuery>({
query: GQL.ScrapeFreeonesDocument, query: GQL.ScrapeFreeonesDocument,
variables: { variables: {
performer_name: performerName, performer_name: performerName
}, }
}); });
} }
public static queryScrapePerformer(scraperId: string, scrapedPerformer: GQL.ScrapedPerformerInput) { public static queryScrapePerformer(
scraperId: string,
scrapedPerformer: GQL.ScrapedPerformerInput
) {
return StashService.client.query<GQL.ScrapePerformerQuery>({ return StashService.client.query<GQL.ScrapePerformerQuery>({
query: GQL.ScrapePerformerDocument, query: GQL.ScrapePerformerDocument,
variables: { variables: {
scraper_id: scraperId, scraper_id: scraperId,
scraped_performer: scrapedPerformer, scraped_performer: scrapedPerformer
}, }
}); });
} }
@@ -435,8 +506,8 @@ export class StashService {
return StashService.client.query<GQL.ScrapePerformerUrlQuery>({ return StashService.client.query<GQL.ScrapePerformerUrlQuery>({
query: GQL.ScrapePerformerUrlDocument, query: GQL.ScrapePerformerUrlDocument,
variables: { variables: {
url, url
}, }
}); });
} }
@@ -444,18 +515,21 @@ export class StashService {
return StashService.client.query<GQL.ScrapeSceneUrlQuery>({ return StashService.client.query<GQL.ScrapeSceneUrlQuery>({
query: GQL.ScrapeSceneUrlDocument, query: GQL.ScrapeSceneUrlDocument,
variables: { variables: {
url, url
}, }
}); });
} }
public static queryScrapeScene(scraperId: string, scene: GQL.SceneUpdateInput) { public static queryScrapeScene(
scraperId: string,
scene: GQL.SceneUpdateInput
) {
return StashService.client.query<GQL.ScrapeSceneQuery>({ return StashService.client.query<GQL.ScrapeSceneQuery>({
query: GQL.ScrapeSceneDocument, query: GQL.ScrapeSceneDocument,
variables: { variables: {
scraper_id: scraperId, scraper_id: scraperId,
scene, scene
}, }
}); });
} }
@@ -463,7 +537,7 @@ export class StashService {
return StashService.client.query<GQL.MetadataScanQuery>({ return StashService.client.query<GQL.MetadataScanQuery>({
query: GQL.MetadataScanDocument, query: GQL.MetadataScanDocument,
variables: { input }, variables: { input },
fetchPolicy: "network-only", fetchPolicy: "network-only"
}); });
} }
@@ -471,7 +545,7 @@ export class StashService {
return StashService.client.query<GQL.MetadataAutoTagQuery>({ return StashService.client.query<GQL.MetadataAutoTagQuery>({
query: GQL.MetadataAutoTagDocument, query: GQL.MetadataAutoTagDocument,
variables: { input }, variables: { input },
fetchPolicy: "network-only", fetchPolicy: "network-only"
}); });
} }
@@ -479,43 +553,46 @@ export class StashService {
return StashService.client.query<GQL.MetadataGenerateQuery>({ return StashService.client.query<GQL.MetadataGenerateQuery>({
query: GQL.MetadataGenerateDocument, query: GQL.MetadataGenerateDocument,
variables: { input }, variables: { input },
fetchPolicy: "network-only", fetchPolicy: "network-only"
}); });
} }
public static queryMetadataClean() { public static queryMetadataClean() {
return StashService.client.query<GQL.MetadataCleanQuery>({ return StashService.client.query<GQL.MetadataCleanQuery>({
query: GQL.MetadataCleanDocument, query: GQL.MetadataCleanDocument,
fetchPolicy: "network-only", fetchPolicy: "network-only"
}); });
} }
public static queryMetadataExport() { public static queryMetadataExport() {
return StashService.client.query<GQL.MetadataExportQuery>({ return StashService.client.query<GQL.MetadataExportQuery>({
query: GQL.MetadataExportDocument, query: GQL.MetadataExportDocument,
fetchPolicy: "network-only", fetchPolicy: "network-only"
}); });
} }
public static queryMetadataImport() { public static queryMetadataImport() {
return StashService.client.query<GQL.MetadataImportQuery>({ return StashService.client.query<GQL.MetadataImportQuery>({
query: GQL.MetadataImportDocument, query: GQL.MetadataImportDocument,
fetchPolicy: "network-only", fetchPolicy: "network-only"
}); });
} }
public static querySceneByPathRegex(filter: GQL.FindFilterType) { public static querySceneByPathRegex(filter: GQL.FindFilterType) {
return StashService.client.query<GQL.FindScenesByPathRegexQuery>({ return StashService.client.query<GQL.FindScenesByPathRegexQuery>({
query: GQL.FindScenesByPathRegexDocument, query: GQL.FindScenesByPathRegexDocument,
variables: {filter}, variables: { filter }
}); });
} }
public static queryParseSceneFilenames(filter: GQL.FindFilterType, config: GQL.SceneParserInput) { public static queryParseSceneFilenames(
filter: GQL.FindFilterType,
config: GQL.SceneParserInput
) {
return StashService.client.query<GQL.ParseSceneFilenamesQuery>({ return StashService.client.query<GQL.ParseSceneFilenamesQuery>({
query: GQL.ParseSceneFilenamesDocument, query: GQL.ParseSceneFilenamesDocument,
variables: {filter, config}, variables: { filter, config },
fetchPolicy: "network-only", fetchPolicy: "network-only"
}); });
} }

View File

@@ -634,7 +634,7 @@ export type ScenesUpdateVariables = {
export type ScenesUpdateMutation = { export type ScenesUpdateMutation = {
__typename?: "Mutation"; __typename?: "Mutation";
scenesUpdate: Maybe<(Maybe<ScenesUpdateScenesUpdate>)[]>; scenesUpdate: Maybe<Maybe<ScenesUpdateScenesUpdate>[]>;
}; };
export type ScenesUpdateScenesUpdate = SceneDataFragment; export type ScenesUpdateScenesUpdate = SceneDataFragment;
@@ -801,7 +801,7 @@ export type MarkerStringsVariables = {
export type MarkerStringsQuery = { export type MarkerStringsQuery = {
__typename?: "Query"; __typename?: "Query";
markerStrings: (Maybe<MarkerStringsMarkerStrings>)[]; markerStrings: Maybe<MarkerStringsMarkerStrings>[];
}; };
export type MarkerStringsMarkerStrings = { export type MarkerStringsMarkerStrings = {

View File

@@ -1,10 +1,10 @@
import _ from "lodash"; import _ from "lodash";
import queryString from "query-string"; import queryString from "query-string";
import React, { useState } from "react"; import React, { useState } from "react";
import { Spinner } from 'react-bootstrap'; import { Spinner } from "react-bootstrap";
import { QueryHookResult } from "react-apollo-hooks"; import { QueryHookResult } from "react-apollo-hooks";
import { ApolloError } from 'apollo-client'; import { ApolloError } from "apollo-client";
import { useHistory } from 'react-router-dom'; import { useHistory } from "react-router-dom";
import { import {
FindScenesQuery, FindScenesQuery,
FindScenesVariables, FindScenesVariables,
@@ -21,7 +21,7 @@ import {
FindPerformersQuery, FindPerformersQuery,
FindPerformersVariables, FindPerformersVariables,
PerformerDataFragment PerformerDataFragment
} from 'src/core/generated-graphql'; } from "src/core/generated-graphql";
import { ListFilter } from "../components/list/ListFilter"; import { ListFilter } from "../components/list/ListFilter";
import { Pagination } from "../components/list/Pagination"; import { Pagination } from "../components/list/Pagination";
import { StashService } from "../core/StashService"; import { StashService } from "../core/StashService";
@@ -32,19 +32,31 @@ import { DisplayMode, FilterMode } from "../models/list-filter/types";
interface IListHookData { interface IListHookData {
filter: ListFilterModel; filter: ListFilterModel;
template: JSX.Element; template: JSX.Element;
onSelectChange: (id: string, selected : boolean, shiftKey: boolean) => void; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
} }
interface IListHookOperation<T> { interface IListHookOperation<T> {
text: string; text: string;
onClick: (result: T, filter: ListFilterModel, selectedIds: Set<string>) => void; onClick: (
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => void;
} }
interface IListHookOptions<T> { interface IListHookOptions<T> {
zoomable?: boolean; zoomable?: boolean;
otherOperations?: IListHookOperation<T>[]; otherOperations?: IListHookOperation<T>[];
renderContent: (result: T, filter: ListFilterModel, selectedIds: Set<string>, zoomIndex: number) => JSX.Element | undefined; renderContent: (
renderSelectedOptions?: (result: T, selectedIds: Set<string>) => JSX.Element | undefined; result: T,
filter: ListFilterModel,
selectedIds: Set<string>,
zoomIndex: number
) => JSX.Element | undefined;
renderSelectedOptions?: (
result: T,
selectedIds: Set<string>
) => JSX.Element | undefined;
} }
interface IDataItem { interface IDataItem {
@@ -63,10 +75,15 @@ interface IQuery<T extends IQueryResult, T2 extends IDataItem> {
} }
const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>( const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
options: (IListHookOptions<QueryResult> & IQuery<QueryResult, QueryData>) options: IListHookOptions<QueryResult> & IQuery<QueryResult, QueryData>
): IListHookData => { ): IListHookData => {
const history = useHistory(); const history = useHistory();
const [filter, setFilter] = useState<ListFilterModel>(new ListFilterModel(options.filterMode, queryString.parse(history.location.search))); const [filter, setFilter] = useState<ListFilterModel>(
new ListFilterModel(
options.filterMode,
queryString.parse(history.location.search)
)
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastClickedId, setLastClickedId] = useState<string | undefined>(); const [lastClickedId, setLastClickedId] = useState<string | undefined>();
const [zoomIndex, setZoomIndex] = useState<number>(1); const [zoomIndex, setZoomIndex] = useState<number>(1);
@@ -75,8 +92,8 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
const totalCount = options.getCount(result); const totalCount = options.getCount(result);
const items = options.getData(result); const items = options.getData(result);
function updateQueryParams(listfilter:ListFilterModel) { function updateQueryParams(listfilter: ListFilterModel) {
const newLocation = { ...history.location}; const newLocation = { ...history.location };
newLocation.search = listfilter.makeQueryParameters(); newLocation.search = listfilter.makeQueryParameters();
history.replace(newLocation); history.replace(newLocation);
} }
@@ -123,7 +140,7 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
const newFilter = _.cloneDeep(filter); const newFilter = _.cloneDeep(filter);
// Find if we are editing an existing criteria, then modify that. Or create a new one. // Find if we are editing an existing criteria, then modify that. Or create a new one.
const existingIndex = newFilter.criteria.findIndex((c) => { const existingIndex = newFilter.criteria.findIndex(c => {
// If we modified an existing criterion, then look for the old id. // If we modified an existing criterion, then look for the old id.
const id = oldId || criterion.getId(); const id = oldId || criterion.getId();
return c.getId() === id; return c.getId() === id;
@@ -136,7 +153,9 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
// Remove duplicate modifiers // Remove duplicate modifiers
newFilter.criteria = newFilter.criteria.filter((obj, pos, arr) => { newFilter.criteria = newFilter.criteria.filter((obj, pos, arr) => {
return arr.map((mapObj: any) => mapObj.getId()).indexOf(obj.getId()) === pos; return (
arr.map((mapObj: any) => mapObj.getId()).indexOf(obj.getId()) === pos
);
}); });
newFilter.currentPage = 1; newFilter.currentPage = 1;
@@ -146,7 +165,9 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
function onRemoveCriterion(removedCriterion: Criterion) { function onRemoveCriterion(removedCriterion: Criterion) {
const newFilter = _.cloneDeep(filter); const newFilter = _.cloneDeep(filter);
newFilter.criteria = newFilter.criteria.filter((criterion) => criterion.getId() !== removedCriterion.getId()); newFilter.criteria = newFilter.criteria.filter(
criterion => criterion.getId() !== removedCriterion.getId()
);
newFilter.currentPage = 1; newFilter.currentPage = 1;
setFilter(newFilter); setFilter(newFilter);
updateQueryParams(newFilter); updateQueryParams(newFilter);
@@ -172,7 +193,7 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
setSelectedIds(newSelectedIds); setSelectedIds(newSelectedIds);
} }
function selectRange(startIndex : number, endIndex : number) { function selectRange(startIndex: number, endIndex: number) {
let start = startIndex; let start = startIndex;
let end = endIndex; let end = endIndex;
if (start > end) { if (start > end) {
@@ -182,9 +203,9 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
} }
const subset = items.slice(start, end + 1); const subset = items.slice(start, end + 1);
const newSelectedIds : Set<string> = new Set(); const newSelectedIds: Set<string> = new Set();
subset.forEach((item) => { subset.forEach(item => {
newSelectedIds.add(item.id); newSelectedIds.add(item.id);
}); });
@@ -196,19 +217,19 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
let thisIndex = -1; let thisIndex = -1;
if (lastClickedId) { if (lastClickedId) {
startIndex = items.findIndex((item) => { startIndex = items.findIndex(item => {
return item.id === lastClickedId; return item.id === lastClickedId;
}); });
} }
thisIndex = items.findIndex((item) => { thisIndex = items.findIndex(item => {
return item.id === id; return item.id === id;
}); });
selectRange(startIndex, thisIndex); selectRange(startIndex, thisIndex);
} }
function onSelectChange(id: string, selected : boolean, shiftKey: boolean) { function onSelectChange(id: string, selected: boolean, shiftKey: boolean) {
if (shiftKey) { if (shiftKey) {
multiSelect(id); multiSelect(id);
} else { } else {
@@ -217,8 +238,8 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
} }
function onSelectAll() { function onSelectAll() {
const newSelectedIds : Set<string> = new Set(); const newSelectedIds: Set<string> = new Set();
items.forEach((item) => { items.forEach(item => {
newSelectedIds.add(item.id); newSelectedIds.add(item.id);
}); });
@@ -227,23 +248,25 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
} }
function onSelectNone() { function onSelectNone() {
const newSelectedIds : Set<string> = new Set(); const newSelectedIds: Set<string> = new Set();
setSelectedIds(newSelectedIds); setSelectedIds(newSelectedIds);
setLastClickedId(undefined); setLastClickedId(undefined);
} }
function onChangeZoom(newZoomIndex : number) { function onChangeZoom(newZoomIndex: number) {
setZoomIndex(newZoomIndex); setZoomIndex(newZoomIndex);
} }
const otherOperations = options.otherOperations ? options.otherOperations.map((o) => { const otherOperations = options.otherOperations
return { ? options.otherOperations.map(o => {
text: o.text, return {
onClick: () => { text: o.text,
o.onClick(result, filter, selectedIds); onClick: () => {
} o.onClick(result, filter, selectedIds);
} }
}) : undefined; };
})
: undefined;
const template = ( const template = (
<div> <div>
@@ -262,8 +285,14 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
otherOperations={otherOperations} otherOperations={otherOperations}
filter={filter} filter={filter}
/> />
{options.renderSelectedOptions && selectedIds.size > 0 ? options.renderSelectedOptions(result, selectedIds) : undefined} {options.renderSelectedOptions && selectedIds.size > 0
{result.loading ? <Spinner animation="border" variant="light" /> : undefined} ? options.renderSelectedOptions(result, selectedIds)
: undefined}
{result.loading ? (
<Spinner animation="border" variant="light" />
) : (
undefined
)}
{result.error ? <h1>{result.error.message}</h1> : undefined} {result.error ? <h1>{result.error.message}</h1> : undefined}
{options.renderContent(result, filter, selectedIds, zoomIndex)} {options.renderContent(result, filter, selectedIds, zoomIndex)}
<Pagination <Pagination
@@ -276,61 +305,71 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
); );
return { filter, template, onSelectChange }; return { filter, template, onSelectChange };
} };
type ScenesQuery = QueryHookResult<FindScenesQuery, FindScenesVariables>; type ScenesQuery = QueryHookResult<FindScenesQuery, FindScenesVariables>;
export const useScenesList = (props:IListHookOptions<ScenesQuery>) => ( export const useScenesList = (props: IListHookOptions<ScenesQuery>) =>
useList<ScenesQuery, SlimSceneDataFragment>({ useList<ScenesQuery, SlimSceneDataFragment>({
...props, ...props,
filterMode: FilterMode.Scenes, filterMode: FilterMode.Scenes,
useData: StashService.useFindScenes, useData: StashService.useFindScenes,
getData: (result:ScenesQuery) => (result?.data?.findScenes?.scenes ?? []), getData: (result: ScenesQuery) => result?.data?.findScenes?.scenes ?? [],
getCount: (result:ScenesQuery) => (result?.data?.findScenes?.count ?? 0) getCount: (result: ScenesQuery) => result?.data?.findScenes?.count ?? 0
}) });
)
type SceneMarkersQuery = QueryHookResult<FindSceneMarkersQuery, FindSceneMarkersVariables>; type SceneMarkersQuery = QueryHookResult<
export const useSceneMarkersList = (props:IListHookOptions<SceneMarkersQuery>) => ( FindSceneMarkersQuery,
FindSceneMarkersVariables
>;
export const useSceneMarkersList = (
props: IListHookOptions<SceneMarkersQuery>
) =>
useList<SceneMarkersQuery, FindSceneMarkersSceneMarkers>({ useList<SceneMarkersQuery, FindSceneMarkersSceneMarkers>({
...props, ...props,
filterMode: FilterMode.SceneMarkers, filterMode: FilterMode.SceneMarkers,
useData: StashService.useFindSceneMarkers, useData: StashService.useFindSceneMarkers,
getData: (result:SceneMarkersQuery) => (result?.data?.findSceneMarkers?.scene_markers?? []), getData: (result: SceneMarkersQuery) =>
getCount: (result:SceneMarkersQuery) => (result?.data?.findSceneMarkers?.count ?? 0) result?.data?.findSceneMarkers?.scene_markers ?? [],
}) getCount: (result: SceneMarkersQuery) =>
) result?.data?.findSceneMarkers?.count ?? 0
});
type GalleriesQuery = QueryHookResult<FindGalleriesQuery, FindGalleriesVariables>; type GalleriesQuery = QueryHookResult<
export const useGalleriesList = (props:IListHookOptions<GalleriesQuery>) => ( FindGalleriesQuery,
FindGalleriesVariables
>;
export const useGalleriesList = (props: IListHookOptions<GalleriesQuery>) =>
useList<GalleriesQuery, GalleryDataFragment>({ useList<GalleriesQuery, GalleryDataFragment>({
...props, ...props,
filterMode: FilterMode.Galleries, filterMode: FilterMode.Galleries,
useData: StashService.useFindGalleries, useData: StashService.useFindGalleries,
getData: (result:GalleriesQuery) => (result?.data?.findGalleries?.galleries ?? []), getData: (result: GalleriesQuery) =>
getCount: (result:GalleriesQuery) => (result?.data?.findGalleries?.count ?? 0) result?.data?.findGalleries?.galleries ?? [],
}) getCount: (result: GalleriesQuery) =>
) result?.data?.findGalleries?.count ?? 0
});
type StudiosQuery = QueryHookResult<FindStudiosQuery, FindStudiosVariables>; type StudiosQuery = QueryHookResult<FindStudiosQuery, FindStudiosVariables>;
export const useStudiosList = (props:IListHookOptions<StudiosQuery>) => ( export const useStudiosList = (props: IListHookOptions<StudiosQuery>) =>
useList<StudiosQuery, StudioDataFragment>({ useList<StudiosQuery, StudioDataFragment>({
...props, ...props,
filterMode: FilterMode.Studios, filterMode: FilterMode.Studios,
useData: StashService.useFindStudios, useData: StashService.useFindStudios,
getData: (result:StudiosQuery) => (result?.data?.findStudios?.studios ?? []), getData: (result: StudiosQuery) => result?.data?.findStudios?.studios ?? [],
getCount: (result:StudiosQuery) => (result?.data?.findStudios?.count ?? 0) getCount: (result: StudiosQuery) => result?.data?.findStudios?.count ?? 0
}) });
)
type PerformersQuery = QueryHookResult<FindPerformersQuery, FindPerformersVariables>; type PerformersQuery = QueryHookResult<
export const usePerformersList = (props:IListHookOptions<PerformersQuery>) => ( FindPerformersQuery,
FindPerformersVariables
>;
export const usePerformersList = (props: IListHookOptions<PerformersQuery>) =>
useList<PerformersQuery, PerformerDataFragment>({ useList<PerformersQuery, PerformerDataFragment>({
...props, ...props,
filterMode: FilterMode.Performers, filterMode: FilterMode.Performers,
useData: StashService.useFindPerformers, useData: StashService.useFindPerformers,
getData: (result:PerformersQuery) => (result?.data?.findPerformers?.performers ?? []), getData: (result: PerformersQuery) =>
getCount: (result:PerformersQuery) => (result?.data?.findPerformers?.count ?? 0) result?.data?.findPerformers?.performers ?? [],
}) getCount: (result: PerformersQuery) =>
) result?.data?.findPerformers?.count ?? 0
});

View File

@@ -2,8 +2,7 @@ import localForage from "localforage";
import _ from "lodash"; import _ from "lodash";
import React from "react"; import React from "react";
interface IInterfaceWallConfig { interface IInterfaceWallConfig {}
}
export interface IInterfaceConfig { export interface IInterfaceConfig {
wall: IInterfaceWallConfig; wall: IInterfaceWallConfig;
} }
@@ -47,10 +46,12 @@ function useLocalForage(item: string): ILocalForage<ValidTypes> {
runAsync(); runAsync();
}); });
return {data: json, setData: setJson, error: err}; return { data: json, setData: setJson, error: err };
} }
export function useInterfaceLocalForage(): ILocalForage<IInterfaceConfig | undefined> { export function useInterfaceLocalForage(): ILocalForage<
IInterfaceConfig | undefined
> {
const result = useLocalForage("interface"); const result = useLocalForage("interface");
// Set defaults // Set defaults
React.useEffect(() => { React.useEffect(() => {
@@ -58,7 +59,7 @@ export function useInterfaceLocalForage(): ILocalForage<IInterfaceConfig | undef
result.setData({ result.setData({
wall: { wall: {
// nothing here currently // nothing here currently
}, }
}); });
} }
}); });

View File

@@ -1,80 +1,72 @@
import React, { useEffect, useState, useContext, createContext } from 'react'; import React, { useEffect, useState, useContext, createContext } from "react";
import { Toast } from 'react-bootstrap'; import { Toast } from "react-bootstrap";
interface IToast { interface IToast {
header?: string; header?: string;
content: JSX.Element|string; content: JSX.Element | string;
delay?: number; delay?: number;
variant?: 'success'|'danger'|'warning'|'info'; variant?: "success" | "danger" | "warning" | "info";
} }
interface IActiveToast extends IToast { interface IActiveToast extends IToast {
id: number; id: number;
} }
let toastID = 0; let toastID = 0;
const ToastContext = createContext<(item:IToast) => void>(() => {}); const ToastContext = createContext<(item: IToast) => void>(() => {});
export const ToastProvider: React.FC = ({children}) => { export const ToastProvider: React.FC = ({ children }) => {
const [toasts, setToasts] = useState<IActiveToast[]>([]); const [toasts, setToasts] = useState<IActiveToast[]>([]);
const removeToast = (id:number) => ( const removeToast = (id: number) =>
setToasts(toasts.filter(item => item.id !== id)) setToasts(toasts.filter(item => item.id !== id));
);
const toastItems = toasts.map(toast => ( const toastItems = toasts.map(toast => (
<Toast <Toast
autohide autohide
key={toast.id} key={toast.id}
onClose={() => removeToast(toast.id)} onClose={() => removeToast(toast.id)}
className={toast.variant ?? 'success'} className={toast.variant ?? "success"}
delay={toast.delay ?? 5000} delay={toast.delay ?? 5000}
> >
<Toast.Header> <Toast.Header>
<span className="mr-auto"> <span className="mr-auto">{toast.header ?? "Stash"}</span>
{ toast.header ?? 'Stash' }
</span>
</Toast.Header> </Toast.Header>
<Toast.Body>{toast.content}</Toast.Body> <Toast.Body>{toast.content}</Toast.Body>
</Toast> </Toast>
)); ));
const addToast = (toast:IToast) => ( const addToast = (toast: IToast) =>
setToasts([...toasts, { ...toast, id: toastID++ }]) setToasts([...toasts, { ...toast, id: toastID++ }]);
);
return ( return (
<ToastContext.Provider value={addToast}> <ToastContext.Provider value={addToast}>
{children} {children}
<div className="toast-container row"> <div className="toast-container row">{toastItems}</div>
{ toastItems }
</div>
</ToastContext.Provider> </ToastContext.Provider>
) );
} };
function createHookObject(toastFunc: (toast:IToast) => void) { function createHookObject(toastFunc: (toast: IToast) => void) {
return { return {
success: toastFunc, success: toastFunc,
error: (error: Error) => { error: (error: Error) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(error.message); console.error(error.message);
toastFunc({ toastFunc({
variant: 'danger', variant: "danger",
header: 'Error', header: "Error",
content: error.message ?? error.toString() content: error.message ?? error.toString()
}); });
} }
} };
} }
const useToasts = () => { const useToasts = () => {
const setToast = useContext(ToastContext); const setToast = useContext(ToastContext);
const [hookObject, setHookObject] = useState(createHookObject(setToast)); const [hookObject, setHookObject] = useState(createHookObject(setToast));
useEffect(() => ( useEffect(() => setHookObject(createHookObject(setToast)), [setToast]);
setHookObject(createHookObject(setToast))
), [setToast]);
return hookObject; return hookObject;
} };
export default useToasts; export default useToasts;

View File

@@ -15,17 +15,24 @@ export interface IVideoHoverHookOptions {
} }
export class VideoHoverHook { export class VideoHoverHook {
public static useVideoHover(options: IVideoHoverHookOptions): IVideoHoverHookData { public static useVideoHover(
options: IVideoHoverHookOptions
): IVideoHoverHookData {
const videoEl = useRef<HTMLVideoElement>(null); const videoEl = useRef<HTMLVideoElement>(null);
const isPlaying = useRef<boolean>(false); const isPlaying = useRef<boolean>(false);
const isHovering = useRef<boolean>(false); const isHovering = useRef<boolean>(false);
const config = StashService.useConfiguration(); const config = StashService.useConfiguration();
const soundEnabled = !!config.data && !!config.data.configuration ? config.data.configuration.interface.soundOnPreview : true; const soundEnabled =
!!config.data && !!config.data.configuration
? config.data.configuration.interface.soundOnPreview
: true;
useEffect(() => { useEffect(() => {
const videoTag = videoEl.current; const videoTag = videoEl.current;
if (!videoTag) { return; } if (!videoTag) {
return;
}
videoTag.onplaying = () => { videoTag.onplaying = () => {
if (isHovering.current === true) { if (isHovering.current === true) {
isPlaying.current = true; isPlaying.current = true;
@@ -33,25 +40,31 @@ export class VideoHoverHook {
videoTag.pause(); videoTag.pause();
} }
}; };
videoTag.onpause = () => { isPlaying.current = false }; videoTag.onpause = () => {
isPlaying.current = false;
};
}, [videoEl]); }, [videoEl]);
useEffect(() => { useEffect(() => {
const videoTag = videoEl.current; const videoTag = videoEl.current;
if (!videoTag) { return; } if (!videoTag) {
return;
}
videoTag.volume = soundEnabled ? 0.05 : 0; videoTag.volume = soundEnabled ? 0.05 : 0;
}, [soundEnabled]); }, [soundEnabled]);
return {videoEl, isPlaying, isHovering, options}; return { videoEl, isPlaying, isHovering, options };
} }
public static onMouseEnter(data: IVideoHoverHookData) { public static onMouseEnter(data: IVideoHoverHookData) {
data.isHovering.current = true; data.isHovering.current = true;
const videoTag = data.videoEl.current; const videoTag = data.videoEl.current;
if (!videoTag) { return; } if (!videoTag) {
return;
}
if (videoTag.paused && !data.isPlaying.current) { if (videoTag.paused && !data.isPlaying.current) {
videoTag.play().catch((error) => { videoTag.play().catch(error => {
console.log(error.message); console.log(error.message);
}); });
} }
@@ -61,7 +74,9 @@ export class VideoHoverHook {
data.isHovering.current = false; data.isHovering.current = false;
const videoTag = data.videoEl.current; const videoTag = data.videoEl.current;
if (!videoTag) { return; } if (!videoTag) {
return;
}
if (!videoTag.paused && data.isPlaying) { if (!videoTag.paused && data.isPlaying) {
videoTag.pause(); videoTag.pause();
if (data.options.resetOnMouseLeave) { if (data.options.resetOnMouseLeave) {

View File

@@ -1,4 +1,10 @@
export { default as useToast } from './Toast'; export { default as useToast } from "./Toast";
export { useInterfaceLocalForage } from './LocalForage'; export { useInterfaceLocalForage } from "./LocalForage";
export { VideoHoverHook } from './VideoHover'; export { VideoHoverHook } from "./VideoHover";
export { useScenesList, useSceneMarkersList, useGalleriesList, useStudiosList, usePerformersList } from './ListHook'; export {
useScenesList,
useSceneMarkersList,
useGalleriesList,
useStudiosList,
usePerformersList
} from "./ListHook";

View File

@@ -7,13 +7,14 @@ import { StashService } from "./core/StashService";
import "./index.scss"; import "./index.scss";
import * as serviceWorker from "./serviceWorker"; import * as serviceWorker from "./serviceWorker";
ReactDOM.render(( ReactDOM.render(
<BrowserRouter> <BrowserRouter>
<ApolloProvider client={StashService.initialize()!}> <ApolloProvider client={StashService.initialize()!}>
<App /> <App />
</ApolloProvider> </ApolloProvider>
</BrowserRouter> </BrowserRouter>,
), document.getElementById("root")); document.getElementById("root")
);
// If you want your app to work offline and load faster, you can change // If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls. // unregister() to register() below. Note this comes with some pitfalls.

View File

@@ -4,68 +4,101 @@ import { CriterionModifier } from "src/core/generated-graphql";
import { ILabeledId, ILabeledValue } from "../types"; import { ILabeledId, ILabeledValue } from "../types";
export type CriterionType = export type CriterionType =
"none" | | "none"
"rating" | | "rating"
"resolution" | | "resolution"
"favorite" | | "favorite"
"hasMarkers" | | "hasMarkers"
"isMissing" | | "isMissing"
"tags" | | "tags"
"sceneTags" | | "sceneTags"
"performers" | | "performers"
"studios" | | "studios"
"birth_year" | | "birth_year"
"age" | | "age"
"ethnicity" | | "ethnicity"
"country" | | "country"
"eye_color" | | "eye_color"
"height" | | "height"
"measurements" | | "measurements"
"fake_tits" | | "fake_tits"
"career_length" | | "career_length"
"tattoos" | | "tattoos"
"piercings" | | "piercings"
"aliases"; | "aliases";
export abstract class Criterion<Option = any, Value = any> { export abstract class Criterion<Option = any, Value = any> {
public static getLabel(type: CriterionType = "none") { public static getLabel(type: CriterionType = "none") {
switch (type) { switch (type) {
case "none": return "None"; case "none":
case "rating": return "Rating"; return "None";
case "resolution": return "Resolution"; case "rating":
case "favorite": return "Favorite"; return "Rating";
case "hasMarkers": return "Has Markers"; case "resolution":
case "isMissing": return "Is Missing"; return "Resolution";
case "tags": return "Tags"; case "favorite":
case "sceneTags": return "Scene Tags"; return "Favorite";
case "performers": return "Performers"; case "hasMarkers":
case "studios": return "Studios"; return "Has Markers";
case "birth_year": return "Birth Year"; case "isMissing":
case "age": return "Age"; return "Is Missing";
case "ethnicity": return "Ethnicity"; case "tags":
case "country": return "Country"; return "Tags";
case "eye_color": return "Eye Color"; case "sceneTags":
case "height": return "Height"; return "Scene Tags";
case "measurements": return "Measurements"; case "performers":
case "fake_tits": return "Fake Tits"; return "Performers";
case "career_length": return "Career Length"; case "studios":
case "tattoos": return "Tattoos"; return "Studios";
case "piercings": return "Piercings"; case "birth_year":
case "aliases": return "Aliases"; return "Birth Year";
case "age":
return "Age";
case "ethnicity":
return "Ethnicity";
case "country":
return "Country";
case "eye_color":
return "Eye Color";
case "height":
return "Height";
case "measurements":
return "Measurements";
case "fake_tits":
return "Fake Tits";
case "career_length":
return "Career Length";
case "tattoos":
return "Tattoos";
case "piercings":
return "Piercings";
case "aliases":
return "Aliases";
} }
} }
public static getModifierOption(modifier: CriterionModifier = CriterionModifier.Equals): ILabeledValue { public static getModifierOption(
modifier: CriterionModifier = CriterionModifier.Equals
): ILabeledValue {
switch (modifier) { switch (modifier) {
case CriterionModifier.Equals: return {value: CriterionModifier.Equals, label: "Equals"}; case CriterionModifier.Equals:
case CriterionModifier.NotEquals: return {value: CriterionModifier.NotEquals, label: "Not Equals"}; return { value: CriterionModifier.Equals, label: "Equals" };
case CriterionModifier.GreaterThan: return {value: CriterionModifier.GreaterThan, label: "Greater Than"}; case CriterionModifier.NotEquals:
case CriterionModifier.LessThan: return {value: CriterionModifier.LessThan, label: "Less Than"}; return { value: CriterionModifier.NotEquals, label: "Not Equals" };
case CriterionModifier.IsNull: return {value: CriterionModifier.IsNull, label: "Is NULL"}; case CriterionModifier.GreaterThan:
case CriterionModifier.NotNull: return {value: CriterionModifier.NotNull, label: "Not NULL"}; return { value: CriterionModifier.GreaterThan, label: "Greater Than" };
case CriterionModifier.IncludesAll: return {value: CriterionModifier.IncludesAll, label: "Includes All"}; case CriterionModifier.LessThan:
case CriterionModifier.Includes: return {value: CriterionModifier.Includes, label: "Includes"}; return { value: CriterionModifier.LessThan, label: "Less Than" };
case CriterionModifier.Excludes: return {value: CriterionModifier.Excludes, label: "Excludes"}; case CriterionModifier.IsNull:
return { value: CriterionModifier.IsNull, label: "Is NULL" };
case CriterionModifier.NotNull:
return { value: CriterionModifier.NotNull, label: "Not NULL" };
case CriterionModifier.IncludesAll:
return { value: CriterionModifier.IncludesAll, label: "Includes All" };
case CriterionModifier.Includes:
return { value: CriterionModifier.Includes, label: "Includes" };
case CriterionModifier.Excludes:
return { value: CriterionModifier.Excludes, label: "Excludes" };
} }
} }
@@ -80,25 +113,47 @@ export abstract class Criterion<Option = any, Value = any> {
public getLabel(): string { public getLabel(): string {
let modifierString: string; let modifierString: string;
switch (this.modifier) { switch (this.modifier) {
case CriterionModifier.Equals: modifierString = "is"; break; case CriterionModifier.Equals:
case CriterionModifier.NotEquals: modifierString = "is not"; break; modifierString = "is";
case CriterionModifier.GreaterThan: modifierString = "is greater than"; break; break;
case CriterionModifier.LessThan: modifierString = "is less than"; break; case CriterionModifier.NotEquals:
case CriterionModifier.IsNull: modifierString = "is null"; break; modifierString = "is not";
case CriterionModifier.NotNull: modifierString = "is not null"; break; break;
case CriterionModifier.Includes: modifierString = "includes"; break; case CriterionModifier.GreaterThan:
case CriterionModifier.IncludesAll: modifierString = "includes all"; break; modifierString = "is greater than";
case CriterionModifier.Excludes: modifierString = "excludes"; break; break;
default: modifierString = ""; case CriterionModifier.LessThan:
modifierString = "is less than";
break;
case CriterionModifier.IsNull:
modifierString = "is null";
break;
case CriterionModifier.NotNull:
modifierString = "is not null";
break;
case CriterionModifier.Includes:
modifierString = "includes";
break;
case CriterionModifier.IncludesAll:
modifierString = "includes all";
break;
case CriterionModifier.Excludes:
modifierString = "excludes";
break;
default:
modifierString = "";
} }
let valueString: string; let valueString: string;
if (this.modifier === CriterionModifier.IsNull || this.modifier === CriterionModifier.NotNull) { if (
this.modifier === CriterionModifier.IsNull ||
this.modifier === CriterionModifier.NotNull
) {
valueString = ""; valueString = "";
} else if (Array.isArray(this.value) && this.value.length > 0) { } else if (Array.isArray(this.value) && this.value.length > 0) {
let items = this.value; let items = this.value;
if ((this.value as ILabeledId[])[0].label) { if ((this.value as ILabeledId[])[0].label) {
items = this.value.map((item) => item.label) as any; items = this.value.map(item => item.label) as any;
} }
valueString = items.join(", "); valueString = items.join(", ");
} else if (typeof this.value === "string") { } else if (typeof this.value === "string") {
@@ -133,7 +188,7 @@ export class CriterionOption implements ICriterionOption {
public label: string; public label: string;
public value: CriterionType; public value: CriterionType;
constructor(label : string, value : CriterionType) { constructor(label: string, value: CriterionType) {
this.label = label; this.label = label;
this.value = value; this.value = value;
} }
@@ -147,12 +202,12 @@ export class StringCriterion extends Criterion<string, string> {
Criterion.getModifierOption(CriterionModifier.Equals), Criterion.getModifierOption(CriterionModifier.Equals),
Criterion.getModifierOption(CriterionModifier.NotEquals), Criterion.getModifierOption(CriterionModifier.NotEquals),
Criterion.getModifierOption(CriterionModifier.IsNull), Criterion.getModifierOption(CriterionModifier.IsNull),
Criterion.getModifierOption(CriterionModifier.NotNull), Criterion.getModifierOption(CriterionModifier.NotNull)
]; ];
public options: string[] | undefined; public options: string[] | undefined;
public value: string = ""; public value: string = "";
constructor(type : CriterionType, parameterName?: string, options? : string[]) { constructor(type: CriterionType, parameterName?: string, options?: string[]) {
super(); super();
this.type = type; this.type = type;
@@ -177,12 +232,12 @@ export class NumberCriterion extends Criterion<number, number> {
Criterion.getModifierOption(CriterionModifier.GreaterThan), Criterion.getModifierOption(CriterionModifier.GreaterThan),
Criterion.getModifierOption(CriterionModifier.LessThan), Criterion.getModifierOption(CriterionModifier.LessThan),
Criterion.getModifierOption(CriterionModifier.IsNull), Criterion.getModifierOption(CriterionModifier.IsNull),
Criterion.getModifierOption(CriterionModifier.NotNull), Criterion.getModifierOption(CriterionModifier.NotNull)
]; ];
public options: number[] | undefined; public options: number[] | undefined;
public value: number = 0; public value: number = 0;
constructor(type : CriterionType, parameterName?: string, options? : number[]) { constructor(type: CriterionType, parameterName?: string, options?: number[]) {
super(); super();
this.type = type; this.type = type;

View File

@@ -1,9 +1,5 @@
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import { Criterion, CriterionType, ICriterionOption } from "./criterion";
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
export class FavoriteCriterion extends Criterion<string, string> { export class FavoriteCriterion extends Criterion<string, string> {
public type: CriterionType = "favorite"; public type: CriterionType = "favorite";

View File

@@ -1,9 +1,5 @@
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import { Criterion, CriterionType, ICriterionOption } from "./criterion";
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
export class HasMarkersCriterion extends Criterion<string, string> { export class HasMarkersCriterion extends Criterion<string, string> {
public type: CriterionType = "hasMarkers"; public type: CriterionType = "hasMarkers";

View File

@@ -1,16 +1,19 @@
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import { Criterion, CriterionType, ICriterionOption } from "./criterion";
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
export class IsMissingCriterion extends Criterion<string, string> { export class IsMissingCriterion extends Criterion<string, string> {
public type: CriterionType = "isMissing"; public type: CriterionType = "isMissing";
public parameterName: string = "is_missing"; public parameterName: string = "is_missing";
public modifier = CriterionModifier.Equals; public modifier = CriterionModifier.Equals;
public modifierOptions = []; public modifierOptions = [];
public options: string[] = ["title", "url", "date", "gallery", "studio", "performers"]; public options: string[] = [
"title",
"url",
"date",
"gallery",
"studio",
"performers"
];
public value: string = ""; public value: string = "";
} }

View File

@@ -1,9 +1,5 @@
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import { Criterion, CriterionType, ICriterionOption } from "./criterion";
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
export class NoneCriterion extends Criterion<any, any> { export class NoneCriterion extends Criterion<any, any> {
public type: CriterionType = "none"; public type: CriterionType = "none";

View File

@@ -1,10 +1,6 @@
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { ILabeledId } from "../types"; import { ILabeledId } from "../types";
import { import { Criterion, CriterionType, ICriterionOption } from "./criterion";
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
interface IOptionType { interface IOptionType {
id: string; id: string;
@@ -19,7 +15,7 @@ export class PerformersCriterion extends Criterion<IOptionType, ILabeledId[]> {
public modifierOptions = [ public modifierOptions = [
Criterion.getModifierOption(CriterionModifier.IncludesAll), Criterion.getModifierOption(CriterionModifier.IncludesAll),
Criterion.getModifierOption(CriterionModifier.Includes), Criterion.getModifierOption(CriterionModifier.Includes),
Criterion.getModifierOption(CriterionModifier.Excludes), Criterion.getModifierOption(CriterionModifier.Excludes)
]; ];
public options: IOptionType[] = []; public options: IOptionType[] = [];
public value: ILabeledId[] = []; public value: ILabeledId[] = [];

View File

@@ -1,11 +1,8 @@
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import { Criterion, CriterionType, ICriterionOption } from "./criterion";
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
export class RatingCriterion extends Criterion<number, number> { // TODO <number, number[]> export class RatingCriterion extends Criterion<number, number> {
// TODO <number, number[]>
public type: CriterionType = "rating"; public type: CriterionType = "rating";
public parameterName: string = "rating"; public parameterName: string = "rating";
public modifier = CriterionModifier.Equals; public modifier = CriterionModifier.Equals;
@@ -15,7 +12,7 @@ export class RatingCriterion extends Criterion<number, number> { // TODO <number
Criterion.getModifierOption(CriterionModifier.GreaterThan), Criterion.getModifierOption(CriterionModifier.GreaterThan),
Criterion.getModifierOption(CriterionModifier.LessThan), Criterion.getModifierOption(CriterionModifier.LessThan),
Criterion.getModifierOption(CriterionModifier.IsNull), Criterion.getModifierOption(CriterionModifier.IsNull),
Criterion.getModifierOption(CriterionModifier.NotNull), Criterion.getModifierOption(CriterionModifier.NotNull)
]; ];
public options: number[] = [1, 2, 3, 4, 5]; public options: number[] = [1, 2, 3, 4, 5];
public value: number = 0; public value: number = 0;

View File

@@ -1,11 +1,8 @@
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import { Criterion, CriterionType, ICriterionOption } from "./criterion";
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
export class ResolutionCriterion extends Criterion<string, string> { // TODO <string, string[]> export class ResolutionCriterion extends Criterion<string, string> {
// TODO <string, string[]>
public type: CriterionType = "resolution"; public type: CriterionType = "resolution";
public parameterName: string = "resolution"; public parameterName: string = "resolution";
public modifier = CriterionModifier.Equals; public modifier = CriterionModifier.Equals;

View File

@@ -1,10 +1,6 @@
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { ILabeledId } from "../types"; import { ILabeledId } from "../types";
import { import { Criterion, CriterionType, ICriterionOption } from "./criterion";
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
interface IOptionType { interface IOptionType {
id: string; id: string;
@@ -18,7 +14,7 @@ export class StudiosCriterion extends Criterion<IOptionType, ILabeledId[]> {
public modifier = CriterionModifier.Includes; public modifier = CriterionModifier.Includes;
public modifierOptions = [ public modifierOptions = [
Criterion.getModifierOption(CriterionModifier.Includes), Criterion.getModifierOption(CriterionModifier.Includes),
Criterion.getModifierOption(CriterionModifier.Excludes), Criterion.getModifierOption(CriterionModifier.Excludes)
]; ];
public options: IOptionType[] = []; public options: IOptionType[] = [];
public value: ILabeledId[] = []; public value: ILabeledId[] = [];

View File

@@ -1,19 +1,18 @@
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { ILabeledId } from "../types"; import { ILabeledId } from "../types";
import { import { Criterion, CriterionType, ICriterionOption } from "./criterion";
Criterion,
CriterionType,
ICriterionOption,
} from "./criterion";
export class TagsCriterion extends Criterion<GQL.AllTagsForFilterAllTags, ILabeledId[]> { export class TagsCriterion extends Criterion<
GQL.AllTagsForFilterAllTags,
ILabeledId[]
> {
public type: CriterionType; public type: CriterionType;
public parameterName: string; public parameterName: string;
public modifier = GQL.CriterionModifier.IncludesAll; public modifier = GQL.CriterionModifier.IncludesAll;
public modifierOptions = [ public modifierOptions = [
Criterion.getModifierOption(GQL.CriterionModifier.IncludesAll), Criterion.getModifierOption(GQL.CriterionModifier.IncludesAll),
Criterion.getModifierOption(GQL.CriterionModifier.Includes), Criterion.getModifierOption(GQL.CriterionModifier.Includes),
Criterion.getModifierOption(GQL.CriterionModifier.Excludes), Criterion.getModifierOption(GQL.CriterionModifier.Excludes)
]; ];
public options: GQL.AllTagsForFilterAllTags[] = []; public options: GQL.AllTagsForFilterAllTags[] = [];
public value: ILabeledId[] = []; public value: ILabeledId[] = [];

View File

@@ -1,8 +1,11 @@
/* eslint-disable consistent-return, default-case */ /* eslint-disable consistent-return, default-case */
import { CriterionModifier } from "src/core/generated-graphql";
import { import {
CriterionModifier, Criterion,
} from "src/core/generated-graphql"; CriterionType,
import { Criterion, CriterionType, StringCriterion, NumberCriterion } from "./criterion"; StringCriterion,
NumberCriterion
} from "./criterion";
import { FavoriteCriterion } from "./favorite"; import { FavoriteCriterion } from "./favorite";
import { HasMarkersCriterion } from "./has-markers"; import { HasMarkersCriterion } from "./has-markers";
import { IsMissingCriterion } from "./is-missing"; import { IsMissingCriterion } from "./is-missing";
@@ -15,28 +18,38 @@ import { TagsCriterion } from "./tags";
export function makeCriteria(type: CriterionType = "none") { export function makeCriteria(type: CriterionType = "none") {
switch (type) { switch (type) {
case "none": return new NoneCriterion(); case "none":
case "rating": return new RatingCriterion(); return new NoneCriterion();
case "resolution": return new ResolutionCriterion(); case "rating":
case "favorite": return new FavoriteCriterion(); return new RatingCriterion();
case "hasMarkers": return new HasMarkersCriterion(); case "resolution":
case "isMissing": return new IsMissingCriterion(); return new ResolutionCriterion();
case "tags": return new TagsCriterion("tags"); case "favorite":
case "sceneTags": return new TagsCriterion("sceneTags"); return new FavoriteCriterion();
case "performers": return new PerformersCriterion(); case "hasMarkers":
case "studios": return new StudiosCriterion(); 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();
case "birth_year": case "birth_year":
case "age": { case "age": {
const ret = new NumberCriterion(type, type); const ret = new NumberCriterion(type, type);
// null/not null doesn't make sense for these criteria // null/not null doesn't make sense for these criteria
ret.modifierOptions = [ ret.modifierOptions = [
Criterion.getModifierOption(CriterionModifier.Equals), Criterion.getModifierOption(CriterionModifier.Equals),
Criterion.getModifierOption(CriterionModifier.NotEquals), Criterion.getModifierOption(CriterionModifier.NotEquals),
Criterion.getModifierOption(CriterionModifier.GreaterThan), Criterion.getModifierOption(CriterionModifier.GreaterThan),
Criterion.getModifierOption(CriterionModifier.LessThan) Criterion.getModifierOption(CriterionModifier.LessThan)
]; ];
return ret; return ret;
} }
case "ethnicity": case "ethnicity":
case "country": case "country":

View File

@@ -5,23 +5,46 @@ import {
ResolutionEnum, ResolutionEnum,
SceneFilterType, SceneFilterType,
SceneMarkerFilterType, SceneMarkerFilterType,
SortDirectionEnum, SortDirectionEnum
} from "src/core/generated-graphql"; } from "src/core/generated-graphql";
import { Criterion, ICriterionOption, CriterionType, CriterionOption, NumberCriterion, StringCriterion } 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 { import {
DisplayMode, Criterion,
FilterMode, ICriterionOption,
} from "./types"; CriterionType,
CriterionOption,
NumberCriterion,
StringCriterion
} 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 { interface IQueryParameters {
sortby?: string; sortby?: string;
@@ -49,12 +72,24 @@ export class ListFilterModel {
public constructor(filterMode: FilterMode, rawParms?: any) { public constructor(filterMode: FilterMode, rawParms?: any) {
switch (filterMode) { switch (filterMode) {
case FilterMode.Scenes: case FilterMode.Scenes:
if (!!this.sortBy === false) { this.sortBy = "date"; } if (!!this.sortBy === false) {
this.sortByOptions = ["title", "path", "rating", "date", "filesize", "duration", "framerate", "bitrate", "random"]; this.sortBy = "date";
}
this.sortByOptions = [
"title",
"path",
"rating",
"date",
"filesize",
"duration",
"framerate",
"bitrate",
"random"
];
this.displayModeOptions = [ this.displayModeOptions = [
DisplayMode.Grid, DisplayMode.Grid,
DisplayMode.List, DisplayMode.List,
DisplayMode.Wall, DisplayMode.Wall
]; ];
this.criterionOptions = [ this.criterionOptions = [
new NoneCriterionOption(), new NoneCriterionOption(),
@@ -64,19 +99,18 @@ export class ListFilterModel {
new IsMissingCriterionOption(), new IsMissingCriterionOption(),
new TagsCriterionOption(), new TagsCriterionOption(),
new PerformersCriterionOption(), new PerformersCriterionOption(),
new StudiosCriterionOption(), new StudiosCriterionOption()
]; ];
break; break;
case FilterMode.Performers: { case FilterMode.Performers: {
if (!!this.sortBy === false) { this.sortBy = "name"; } if (!!this.sortBy === false) {
this.sortBy = "name";
}
this.sortByOptions = ["name", "height", "birthdate", "scenes_count"]; this.sortByOptions = ["name", "height", "birthdate", "scenes_count"];
this.displayModeOptions = [ this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
DisplayMode.Grid,
DisplayMode.List,
];
const numberCriteria : CriterionType[] = ["birth_year", "age"]; const numberCriteria: CriterionType[] = ["birth_year", "age"];
const stringCriteria : CriterionType[] = [ const stringCriteria: CriterionType[] = [
"ethnicity", "ethnicity",
"country", "country",
"eye_color", "eye_color",
@@ -94,56 +128,59 @@ export class ListFilterModel {
new FavoriteCriterionOption() new FavoriteCriterionOption()
]; ];
this.criterionOptions = this.criterionOptions.concat(numberCriteria.concat(stringCriteria).map((c) => { this.criterionOptions = this.criterionOptions.concat(
return new CriterionOption(Criterion.getLabel(c), c); numberCriteria.concat(stringCriteria).map(c => {
})); return new CriterionOption(Criterion.getLabel(c), c);
})
);
break; break;
} }
case FilterMode.Studios: case FilterMode.Studios:
if (!!this.sortBy === false) { this.sortBy = "name"; } if (!!this.sortBy === false) {
this.sortBy = "name";
}
this.sortByOptions = ["name", "scenes_count"]; this.sortByOptions = ["name", "scenes_count"];
this.displayModeOptions = [ this.displayModeOptions = [DisplayMode.Grid];
DisplayMode.Grid, this.criterionOptions = [new NoneCriterionOption()];
];
this.criterionOptions = [
new NoneCriterionOption(),
];
break; break;
case FilterMode.Galleries: case FilterMode.Galleries:
if (!!this.sortBy === false) { this.sortBy = "path"; } if (!!this.sortBy === false) {
this.sortBy = "path";
}
this.sortByOptions = ["path"]; this.sortByOptions = ["path"];
this.displayModeOptions = [ this.displayModeOptions = [DisplayMode.List];
DisplayMode.List, this.criterionOptions = [new NoneCriterionOption()];
];
this.criterionOptions = [
new NoneCriterionOption(),
];
break; break;
case FilterMode.SceneMarkers: case FilterMode.SceneMarkers:
if (!!this.sortBy === false) { this.sortBy = "title"; } if (!!this.sortBy === false) {
this.sortByOptions = ["title", "seconds", "scene_id", "random", "scenes_updated_at"]; this.sortBy = "title";
this.displayModeOptions = [ }
DisplayMode.Wall, this.sortByOptions = [
"title",
"seconds",
"scene_id",
"random",
"scenes_updated_at"
]; ];
this.displayModeOptions = [DisplayMode.Wall];
this.criterionOptions = [ this.criterionOptions = [
new NoneCriterionOption(), new NoneCriterionOption(),
new TagsCriterionOption(), new TagsCriterionOption(),
new SceneTagsCriterionOption(), new SceneTagsCriterionOption(),
new PerformersCriterionOption(), new PerformersCriterionOption()
]; ];
break; break;
default: default:
this.sortByOptions = []; this.sortByOptions = [];
this.displayModeOptions = []; this.displayModeOptions = [];
this.criterionOptions = [ this.criterionOptions = [new NoneCriterionOption()];
new NoneCriterionOption(),
];
break; break;
} }
if (!!this.displayMode === false) { this.displayMode = this.displayModeOptions[0]; } if (!!this.displayMode === false) {
this.displayMode = this.displayModeOptions[0];
}
this.sortByOptions = [...this.sortByOptions, "created_at", "updated_at"]; this.sortByOptions = [...this.sortByOptions, "created_at", "updated_at"];
if(rawParms) if (rawParms) this.configureFromQueryParameters(rawParms);
this.configureFromQueryParameters(rawParms);
} }
public configureFromQueryParameters(rawParms: any) { public configureFromQueryParameters(rawParms: any) {
@@ -186,7 +223,7 @@ export class ListFilterModel {
public makeQueryParameters(): string { public makeQueryParameters(): string {
const encodedCriteria: string[] = []; const encodedCriteria: string[] = [];
this.criteria.forEach((criterion) => { this.criteria.forEach(criterion => {
const encodedCriterion: any = {}; const encodedCriterion: any = {};
encodedCriterion.type = criterion.type; encodedCriterion.type = criterion.type;
encodedCriterion.value = criterion.value; encodedCriterion.value = criterion.value;
@@ -201,9 +238,9 @@ export class ListFilterModel {
disp: this.displayMode, disp: this.displayMode,
q: this.searchTerm, q: this.searchTerm,
p: this.currentPage, p: this.currentPage,
c: encodedCriteria, c: encodedCriteria
}; };
return queryString.stringify(result, {encode: false}); return queryString.stringify(result, { encode: false });
} }
// TODO: These don't support multiple of the same criteria, only the last one set is used. // TODO: These don't support multiple of the same criteria, only the last one set is used.
@@ -214,26 +251,42 @@ export class ListFilterModel {
page: this.currentPage, page: this.currentPage,
per_page: this.itemsPerPage, per_page: this.itemsPerPage,
sort: this.sortBy, sort: this.sortBy,
direction: this.sortDirection === "asc" ? SortDirectionEnum.Asc : SortDirectionEnum.Desc, direction:
this.sortDirection === "asc"
? SortDirectionEnum.Asc
: SortDirectionEnum.Desc
}; };
} }
public makeSceneFilter(): SceneFilterType { public makeSceneFilter(): SceneFilterType {
const result: SceneFilterType = {}; const result: SceneFilterType = {};
this.criteria.forEach((criterion) => { this.criteria.forEach(criterion => {
switch (criterion.type) { switch (criterion.type) {
case "rating": { case "rating": {
const ratingCrit = criterion as RatingCriterion; const ratingCrit = criterion as RatingCriterion;
result.rating = { value: ratingCrit.value, modifier: ratingCrit.modifier }; result.rating = {
value: ratingCrit.value,
modifier: ratingCrit.modifier
};
break; break;
} }
case "resolution": { case "resolution": {
switch ((criterion as ResolutionCriterion).value) { switch ((criterion as ResolutionCriterion).value) {
case "240p": result.resolution = ResolutionEnum.Low; break; case "240p":
case "480p": result.resolution = ResolutionEnum.Standard; break; result.resolution = ResolutionEnum.Low;
case "720p": result.resolution = ResolutionEnum.StandardHd; break; break;
case "1080p": result.resolution = ResolutionEnum.FullHd; break; case "480p":
case "4k": result.resolution = ResolutionEnum.FourK; break; 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;
// no default // no default
} }
break; break;
@@ -246,17 +299,26 @@ export class ListFilterModel {
break; break;
case "tags": { case "tags": {
const tagsCrit = criterion as TagsCriterion; const tagsCrit = criterion as TagsCriterion;
result.tags = { value: tagsCrit.value.map((tag) => tag.id), modifier: tagsCrit.modifier }; result.tags = {
value: tagsCrit.value.map(tag => tag.id),
modifier: tagsCrit.modifier
};
break; break;
} }
case "performers": { case "performers": {
const perfCrit = criterion as PerformersCriterion; const perfCrit = criterion as PerformersCriterion;
result.performers = { value: perfCrit.value.map((perf) => perf.id), modifier: perfCrit.modifier }; result.performers = {
value: perfCrit.value.map(perf => perf.id),
modifier: perfCrit.modifier
};
break; break;
} }
case "studios": { case "studios": {
const studCrit = criterion as StudiosCriterion; const studCrit = criterion as StudiosCriterion;
result.studios = { value: studCrit.value.map((studio) => studio.id), modifier: studCrit.modifier }; result.studios = {
value: studCrit.value.map(studio => studio.id),
modifier: studCrit.modifier
};
break; break;
} }
// no default // no default
@@ -267,14 +329,18 @@ export class ListFilterModel {
public makePerformerFilter(): PerformerFilterType { public makePerformerFilter(): PerformerFilterType {
const result: PerformerFilterType = {}; const result: PerformerFilterType = {};
this.criteria.forEach((criterion) => { this.criteria.forEach(criterion => {
switch (criterion.type) { switch (criterion.type) {
case "favorite": case "favorite":
result.filter_favorites = (criterion as FavoriteCriterion).value === "true"; result.filter_favorites =
(criterion as FavoriteCriterion).value === "true";
break; break;
case "birth_year": { case "birth_year": {
const byCrit = criterion as NumberCriterion; const byCrit = criterion as NumberCriterion;
result.birth_year = { value: byCrit.value, modifier: byCrit.modifier }; result.birth_year = {
value: byCrit.value,
modifier: byCrit.modifier
};
break; break;
} }
case "age": { case "age": {
@@ -284,12 +350,18 @@ export class ListFilterModel {
} }
case "ethnicity": { case "ethnicity": {
const ethCrit = criterion as StringCriterion; const ethCrit = criterion as StringCriterion;
result.ethnicity = { value: ethCrit.value, modifier: ethCrit.modifier }; result.ethnicity = {
value: ethCrit.value,
modifier: ethCrit.modifier
};
break; break;
} }
case "country": { case "country": {
const cntryCrit = criterion as StringCriterion; const cntryCrit = criterion as StringCriterion;
result.country = { value: cntryCrit.value, modifier: cntryCrit.modifier }; result.country = {
value: cntryCrit.value,
modifier: cntryCrit.modifier
};
break; break;
} }
case "eye_color": { case "eye_color": {
@@ -304,7 +376,10 @@ export class ListFilterModel {
} }
case "measurements": { case "measurements": {
const mCrit = criterion as StringCriterion; const mCrit = criterion as StringCriterion;
result.measurements = { value: mCrit.value, modifier: mCrit.modifier }; result.measurements = {
value: mCrit.value,
modifier: mCrit.modifier
};
break; break;
} }
case "fake_tits": { case "fake_tits": {
@@ -314,7 +389,10 @@ export class ListFilterModel {
} }
case "career_length": { case "career_length": {
const clCrit = criterion as StringCriterion; const clCrit = criterion as StringCriterion;
result.career_length = { value: clCrit.value, modifier: clCrit.modifier }; result.career_length = {
value: clCrit.value,
modifier: clCrit.modifier
};
break; break;
} }
case "tattoos": { case "tattoos": {
@@ -340,21 +418,30 @@ export class ListFilterModel {
public makeSceneMarkerFilter(): SceneMarkerFilterType { public makeSceneMarkerFilter(): SceneMarkerFilterType {
const result: SceneMarkerFilterType = {}; const result: SceneMarkerFilterType = {};
this.criteria.forEach((criterion) => { this.criteria.forEach(criterion => {
switch (criterion.type) { switch (criterion.type) {
case "tags": { case "tags": {
const tagsCrit = criterion as TagsCriterion; const tagsCrit = criterion as TagsCriterion;
result.tags = { value: tagsCrit.value.map((tag) => tag.id), modifier: tagsCrit.modifier }; result.tags = {
value: tagsCrit.value.map(tag => tag.id),
modifier: tagsCrit.modifier
};
break; break;
} }
case "sceneTags": { case "sceneTags": {
const sceneTagsCrit = criterion as TagsCriterion; const sceneTagsCrit = criterion as TagsCriterion;
result.scene_tags = { value: sceneTagsCrit.value.map((tag) => tag.id), modifier: sceneTagsCrit.modifier }; result.scene_tags = {
value: sceneTagsCrit.value.map(tag => tag.id),
modifier: sceneTagsCrit.modifier
};
break; break;
} }
case "performers": { case "performers": {
const performersCrit = criterion as PerformersCriterion; const performersCrit = criterion as PerformersCriterion;
result.performers = { value: performersCrit.value.map((performer) => performer.id), modifier: performersCrit.modifier }; result.performers = {
value: performersCrit.value.map(performer => performer.id),
modifier: performersCrit.modifier
};
break; break;
} }
// no default // no default

View File

@@ -1,7 +1,7 @@
export enum DisplayMode { export enum DisplayMode {
Grid, Grid,
List, List,
Wall, Wall
} }
export enum FilterMode { export enum FilterMode {
@@ -9,7 +9,7 @@ export enum FilterMode {
Performers, Performers,
Studios, Studios,
Galleries, Galleries,
SceneMarkers, SceneMarkers
} }
export interface ILabeledId { export interface ILabeledId {

View File

@@ -18,8 +18,8 @@ const isLocalhost = Boolean(
window.location.hostname === "[::1]" || window.location.hostname === "[::1]" ||
// 127.0.0.1/8 is considered localhost for IPv4. // 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match( window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
), )
); );
interface IConfig { interface IConfig {
@@ -30,7 +30,7 @@ interface IConfig {
function registerValidSW(swUrl: string, config?: IConfig) { function registerValidSW(swUrl: string, config?: IConfig) {
navigator.serviceWorker navigator.serviceWorker
.register(swUrl) .register(swUrl)
.then((registration) => { .then(registration => {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
registration.onupdatefound = () => { registration.onupdatefound = () => {
const installingWorker = registration.installing; const installingWorker = registration.installing;
@@ -45,7 +45,7 @@ function registerValidSW(swUrl: string, config?: IConfig) {
// content until all client tabs are closed. // content until all client tabs are closed.
console.log( console.log(
"New content is available and will be used when all " + "New content is available and will be used when all " +
"tabs for this page are closed. See http://bit.ly/CRA-PWA.", "tabs for this page are closed. See http://bit.ly/CRA-PWA."
); );
// Execute callback // Execute callback
@@ -67,7 +67,7 @@ function registerValidSW(swUrl: string, config?: IConfig) {
}; };
}; };
}) })
.catch((error) => { .catch(error => {
console.error("Error during service worker registration:", error); console.error("Error during service worker registration:", error);
}); });
} }
@@ -75,7 +75,7 @@ function registerValidSW(swUrl: string, config?: IConfig) {
function checkValidServiceWorker(swUrl: string, config?: IConfig) { function checkValidServiceWorker(swUrl: string, config?: IConfig) {
// Check if the service worker can be found. If it can't reload the page. // Check if the service worker can be found. If it can't reload the page.
fetch(swUrl) fetch(swUrl)
.then((response) => { .then(response => {
// Ensure service worker exists, and that we really are getting a JS file. // Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get("content-type"); const contentType = response.headers.get("content-type");
if ( if (
@@ -83,7 +83,7 @@ function checkValidServiceWorker(swUrl: string, config?: IConfig) {
(contentType != null && contentType.indexOf("javascript") === -1) (contentType != null && contentType.indexOf("javascript") === -1)
) { ) {
// No service worker found. Probably a different app. Reload the page. // No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => { navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => { registration.unregister().then(() => {
window.location.reload(); window.location.reload();
}); });
@@ -95,7 +95,7 @@ function checkValidServiceWorker(swUrl: string, config?: IConfig) {
}) })
.catch(() => { .catch(() => {
console.log( console.log(
"No internet connection found. App is running in offline mode.", "No internet connection found. App is running in offline mode."
); );
}); });
} }
@@ -105,7 +105,7 @@ export function register(config?: IConfig) {
// The URL constructor is available in all browsers that support SW. // The URL constructor is available in all browsers that support SW.
const publicUrl = new URL( const publicUrl = new URL(
(process as { env: { [key: string]: string } }).env.PUBLIC_URL, (process as { env: { [key: string]: string } }).env.PUBLIC_URL,
window.location.href, window.location.href
); );
if (publicUrl.origin !== window.location.origin) { if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin // Our service worker won't work if PUBLIC_URL is on a different origin
@@ -126,7 +126,7 @@ export function register(config?: IConfig) {
navigator.serviceWorker.ready.then(() => { navigator.serviceWorker.ready.then(() => {
console.log( console.log(
"This web app is being served cache-first by a service " + "This web app is being served cache-first by a service " +
"worker. To learn more, visit http://bit.ly/CRA-PWA", "worker. To learn more, visit http://bit.ly/CRA-PWA"
); );
}); });
} else { } else {
@@ -137,10 +137,9 @@ export function register(config?: IConfig) {
} }
} }
export function unregister() { export function unregister() {
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready.then((registration) => { navigator.serviceWorker.ready.then(registration => {
registration.unregister(); registration.unregister();
}); });
} }

View File

@@ -4,34 +4,38 @@ const readImage = (file: File, onLoadEnd: (this: FileReader) => void) => {
const reader: FileReader = new FileReader(); const reader: FileReader = new FileReader();
reader.onloadend = onLoadEnd; reader.onloadend = onLoadEnd;
reader.readAsDataURL(file); reader.readAsDataURL(file);
} };
const pasteImage = (event: ClipboardEvent, onLoadEnd: (this: FileReader) => void) => { const pasteImage = (
event: ClipboardEvent,
onLoadEnd: (this: FileReader) => void
) => {
const files = event?.clipboardData?.files; const files = event?.clipboardData?.files;
if(!files?.length) if (!files?.length) return;
return;
const file = files[0]; const file = files[0];
readImage(file, onLoadEnd); readImage(file, onLoadEnd);
} };
const onImageChange = (event: React.FormEvent<HTMLInputElement>, onLoadEnd: (this: FileReader) => void) => { const onImageChange = (
event: React.FormEvent<HTMLInputElement>,
onLoadEnd: (this: FileReader) => void
) => {
const file = event?.currentTarget?.files?.[0]; const file = event?.currentTarget?.files?.[0];
if(file) if (file) readImage(file, onLoadEnd);
readImage(file, onLoadEnd); };
}
const usePasteImage = (onLoadEnd: (this: FileReader) => void) => { const usePasteImage = (onLoadEnd: (this: FileReader) => void) => {
useEffect(() => { useEffect(() => {
const paste = (event: ClipboardEvent) => ( pasteImage(event, onLoadEnd) ); const paste = (event: ClipboardEvent) => pasteImage(event, onLoadEnd);
document.addEventListener("paste", paste); document.addEventListener("paste", paste);
return () => document.removeEventListener("paste", paste); return () => document.removeEventListener("paste", paste);
}); });
} };
const Image = { const Image = {
onImageChange, onImageChange,
usePasteImage usePasteImage
} };
export default Image; export default Image;

View File

@@ -1,4 +1,4 @@
export { default as ImageUtils } from './image'; export { default as ImageUtils } from "./image";
export { default as NavUtils } from './navigation'; export { default as NavUtils } from "./navigation";
export { default as TableUtils } from './table'; export { default as TableUtils } from "./table";
export { default as TextUtils } from './text'; export { default as TextUtils } from "./text";

View File

@@ -5,51 +5,54 @@ import { TagsCriterion } from "../models/list-filter/criteria/tags";
import { ListFilterModel } from "../models/list-filter/filter"; import { ListFilterModel } from "../models/list-filter/filter";
import { FilterMode } from "../models/list-filter/types"; import { FilterMode } from "../models/list-filter/types";
const makePerformerScenesUrl = (performer: Partial<GQL.PerformerDataFragment>) => { const makePerformerScenesUrl = (
if (!performer.id) performer: Partial<GQL.PerformerDataFragment>
return "#"; ) => {
if (!performer.id) return "#";
const filter = new ListFilterModel(FilterMode.Scenes); const filter = new ListFilterModel(FilterMode.Scenes);
const criterion = new PerformersCriterion(); const criterion = new PerformersCriterion();
criterion.value = [{ id: performer.id, label: performer.name || `Performer ${performer.id}` }]; criterion.value = [
{ id: performer.id, label: performer.name || `Performer ${performer.id}` }
];
filter.criteria.push(criterion); filter.criteria.push(criterion);
return `/scenes?${filter.makeQueryParameters()}`; return `/scenes?${filter.makeQueryParameters()}`;
} };
const makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => { const makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (!studio.id) if (!studio.id) return "#";
return "#";
const filter = new ListFilterModel(FilterMode.Scenes); const filter = new ListFilterModel(FilterMode.Scenes);
const criterion = new StudiosCriterion(); const criterion = new StudiosCriterion();
criterion.value = [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }]; criterion.value = [
{ id: studio.id, label: studio.name || `Studio ${studio.id}` }
];
filter.criteria.push(criterion); filter.criteria.push(criterion);
return `/scenes?${filter.makeQueryParameters()}`; return `/scenes?${filter.makeQueryParameters()}`;
} };
const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => { const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (!tag.id) if (!tag.id) return "#";
return "#";
const filter = new ListFilterModel(FilterMode.Scenes); const filter = new ListFilterModel(FilterMode.Scenes);
const criterion = new TagsCriterion("tags"); const criterion = new TagsCriterion("tags");
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
filter.criteria.push(criterion); filter.criteria.push(criterion);
return `/scenes?${filter.makeQueryParameters()}`; return `/scenes?${filter.makeQueryParameters()}`;
} };
const makeTagSceneMarkersUrl = (tag: Partial<GQL.TagDataFragment>) => { const makeTagSceneMarkersUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (!tag.id) if (!tag.id) return "#";
return "#";
const filter = new ListFilterModel(FilterMode.SceneMarkers); const filter = new ListFilterModel(FilterMode.SceneMarkers);
const criterion = new TagsCriterion("tags"); const criterion = new TagsCriterion("tags");
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
filter.criteria.push(criterion); filter.criteria.push(criterion);
return `/scenes/markers?${filter.makeQueryParameters()}`; return `/scenes/markers?${filter.makeQueryParameters()}`;
} };
const makeSceneMarkerUrl = (sceneMarker: Partial<GQL.SceneMarkerDataFragment>) => { const makeSceneMarkerUrl = (
if (!sceneMarker.id || !sceneMarker.scene) sceneMarker: Partial<GQL.SceneMarkerDataFragment>
return "#"; ) => {
if (!sceneMarker.id || !sceneMarker.scene) return "#";
return `/scenes/${sceneMarker.scene.id}?t=${sceneMarker.seconds}`; return `/scenes/${sceneMarker.scene.id}?t=${sceneMarker.seconds}`;
} };
const Nav = { const Nav = {
makePerformerScenesUrl, makePerformerScenesUrl,
@@ -57,5 +60,5 @@ const Nav = {
makeTagSceneMarkersUrl, makeTagSceneMarkersUrl,
makeTagScenesUrl, makeTagScenesUrl,
makeSceneMarkerUrl makeSceneMarkerUrl
} };
export default Nav; export default Nav;

View File

@@ -1,12 +1,12 @@
import React from "react"; import React from "react";
import { Form } from 'react-bootstrap'; import { Form } from "react-bootstrap";
import { FilterSelect } from "src/components/Shared"; import { FilterSelect } from "src/components/Shared";
const renderEditableTextTableRow = (options: { const renderEditableTextTableRow = (options: {
title: string; title: string;
value?: string | number; value?: string | number;
isEditing: boolean; isEditing: boolean;
onChange: ((value: string) => void); onChange: (value: string) => void;
}) => ( }) => (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
@@ -14,19 +14,25 @@ const renderEditableTextTableRow = (options: {
<Form.Control <Form.Control
readOnly={!options.isEditing} readOnly={!options.isEditing}
plaintext={!options.isEditing} plaintext={!options.isEditing}
onChange={(event: React.FormEvent<HTMLInputElement>) => ( options.onChange(event.currentTarget.value) )} onChange={(event: React.FormEvent<HTMLInputElement>) =>
value={typeof options.value === 'number' ? options.value.toString() : options.value} options.onChange(event.currentTarget.value)
}
value={
typeof options.value === "number"
? options.value.toString()
: options.value
}
placeholder={options.title} placeholder={options.title}
/> />
</td> </td>
</tr> </tr>
) );
const renderTextArea = (options: { const renderTextArea = (options: {
title: string, title: string;
value: string | undefined, value: string | undefined;
isEditing: boolean, isEditing: boolean;
onChange: ((value: string) => void), onChange: (value: string) => void;
}) => ( }) => (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
@@ -35,19 +41,21 @@ const renderTextArea = (options: {
as="textarea" as="textarea"
readOnly={!options.isEditing} readOnly={!options.isEditing}
plaintext={!options.isEditing} plaintext={!options.isEditing}
onChange={(event: React.FormEvent<HTMLTextAreaElement>) => ( options.onChange(event.currentTarget.value) )} onChange={(event: React.FormEvent<HTMLTextAreaElement>) =>
options.onChange(event.currentTarget.value)
}
value={options.value} value={options.value}
/> />
</td> </td>
</tr> </tr>
) );
const renderInputGroup = (options: { const renderInputGroup = (options: {
title: string, title: string;
placeholder?: string, placeholder?: string;
value: string | undefined, value: string | undefined;
isEditing: boolean, isEditing: boolean;
onChange: ((value: string) => void), onChange: (value: string) => void;
}) => ( }) => (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
@@ -56,19 +64,21 @@ const renderInputGroup = (options: {
readOnly={!options.isEditing} readOnly={!options.isEditing}
plaintext={!options.isEditing} plaintext={!options.isEditing}
defaultValue={options.value} defaultValue={options.value}
placeholder={options.placeholder ?? options.title} placeholder={options.placeholder ?? options.title}
onChange={(event: React.FormEvent<HTMLInputElement>) => ( options.onChange(event.currentTarget.value) )} onChange={(event: React.FormEvent<HTMLInputElement>) =>
options.onChange(event.currentTarget.value)
}
/> />
</td> </td>
</tr> </tr>
) );
const renderHtmlSelect = (options: { const renderHtmlSelect = (options: {
title: string, title: string;
value?: string | number, value?: string | number;
isEditing: boolean, isEditing: boolean;
onChange: ((value: string) => void), onChange: (value: string) => void;
selectOptions: Array<string | number>, selectOptions: Array<string | number>;
}) => ( }) => (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
@@ -77,37 +87,39 @@ const renderHtmlSelect = (options: {
as="select" as="select"
readOnly={!options.isEditing} readOnly={!options.isEditing}
plaintext={!options.isEditing} plaintext={!options.isEditing}
onChange={(event: React.FormEvent<HTMLSelectElement>) => ( options.onChange(event.currentTarget.value) )} onChange={(event: React.FormEvent<HTMLSelectElement>) =>
options.onChange(event.currentTarget.value)
}
/> />
</td> </td>
</tr> </tr>
) );
// TODO: isediting // TODO: isediting
const renderFilterSelect = (options: { const renderFilterSelect = (options: {
title: string, title: string;
type: "performers" | "studios" | "tags", type: "performers" | "studios" | "tags";
initialId: string | undefined, initialId: string | undefined;
onChange: ((id: string | undefined) => void), onChange: (id: string | undefined) => void;
}) => ( }) => (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
<td> <td>
<FilterSelect <FilterSelect
type={options.type} type={options.type}
onSelect={(items) => options.onChange(items[0]?.id)} onSelect={items => options.onChange(items[0]?.id)}
initialIds={options.initialId ? [options.initialId] : []} initialIds={options.initialId ? [options.initialId] : []}
/> />
</td> </td>
</tr> </tr>
) );
// TODO: isediting // TODO: isediting
const renderMultiSelect = (options: { const renderMultiSelect = (options: {
title: string, title: string;
type: "performers" | "studios" | "tags", type: "performers" | "studios" | "tags";
initialIds: string[] | undefined, initialIds: string[] | undefined;
onChange: ((ids: string[]) => void), onChange: (ids: string[]) => void;
}) => ( }) => (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
@@ -115,12 +127,12 @@ const renderMultiSelect = (options: {
<FilterSelect <FilterSelect
type={options.type} type={options.type}
isMulti isMulti
onSelect={(items) => options.onChange(items.map((i) => i.id))} onSelect={items => options.onChange(items.map(i => i.id))}
initialIds={options.initialIds ?? []} initialIds={options.initialIds ?? []}
/> />
</td> </td>
</tr> </tr>
) );
const Table = { const Table = {
renderEditableTextTableRow, renderEditableTextTableRow,
@@ -129,5 +141,5 @@ const Table = {
renderHtmlSelect, renderHtmlSelect,
renderFilterSelect, renderFilterSelect,
renderMultiSelect renderMultiSelect
} };
export default Table; export default Table;

View File

@@ -1,19 +1,13 @@
const Units = [ const Units = ["bytes", "kB", "MB", "GB", "TB", "PB"];
"bytes",
"kB",
"MB",
"GB",
"TB",
"PB",
];
const truncate = (value?: string, limit: number = 100, tail: string = "...") => { const truncate = (
if (!value) value?: string,
return ""; limit: number = 100,
return value.length > limit tail: string = "..."
? value.substring(0, limit) + tail ) => {
: value; if (!value) return "";
} return value.length > limit ? value.substring(0, limit) + tail : value;
};
const fileSize = (bytes: number = 0, precision: number = 2) => { const fileSize = (bytes: number = 0, precision: number = 2) => {
if (Number.isNaN(parseFloat(String(bytes))) || !Number.isFinite(bytes)) if (Number.isNaN(parseFloat(String(bytes))) || !Number.isFinite(bytes))
@@ -21,13 +15,13 @@ const fileSize = (bytes: number = 0, precision: number = 2) => {
let unit = 0; let unit = 0;
let count = bytes; let count = bytes;
while ( count >= 1024 ) { while (count >= 1024) {
count /= 1024; count /= 1024;
unit++; unit++;
} }
return `${bytes.toFixed(+precision)} ${Units[unit]}`; return `${bytes.toFixed(+precision)} ${Units[unit]}`;
} };
const secondsToTimestamp = (seconds: number) => { const secondsToTimestamp = (seconds: number) => {
let ret = new Date(seconds * 1000).toISOString().substr(11, 8); let ret = new Date(seconds * 1000).toISOString().substr(11, 8);
@@ -41,34 +35,35 @@ const secondsToTimestamp = (seconds: number) => {
ret = ret.substr(1); ret = ret.substr(1);
} }
return ret; return ret;
} };
const fileNameFromPath = (path: string) => { const fileNameFromPath = (path: string) => {
if (!!path === false) if (!!path === false) return "No File Name";
return "No File Name";
return path.replace(/^.*[\\/]/, ""); return path.replace(/^.*[\\/]/, "");
} };
const getAge = (dateString?: string, fromDateString?: string) => { const getAge = (dateString?: string, fromDateString?: string) => {
if (!dateString) if (!dateString) return 0;
return 0;
const birthdate = new Date(dateString); const birthdate = new Date(dateString);
const fromDate = fromDateString ? new Date(fromDateString) : new Date(); const fromDate = fromDateString ? new Date(fromDateString) : new Date();
let age = fromDate.getFullYear() - birthdate.getFullYear(); let age = fromDate.getFullYear() - birthdate.getFullYear();
if (birthdate.getMonth() > fromDate.getMonth() || if (
(birthdate.getMonth() >= fromDate.getMonth() && birthdate.getDay() > fromDate.getDay())) { birthdate.getMonth() > fromDate.getMonth() ||
(birthdate.getMonth() >= fromDate.getMonth() &&
birthdate.getDay() > fromDate.getDay())
) {
age -= 1; age -= 1;
} }
return age; return age;
} };
const bitRate = (bitrate: number) => { const bitRate = (bitrate: number) => {
const megabits = bitrate / 1000000; const megabits = bitrate / 1000000;
return `${megabits.toFixed(2)} megabits per second`; return `${megabits.toFixed(2)} megabits per second`;
} };
const resolution = (height: number) => { const resolution = (height: number) => {
if (height >= 240 && height < 480) { if (height >= 240 && height < 480) {
@@ -86,7 +81,7 @@ const resolution = (height: number) => {
if (height >= 2160) { if (height >= 2160) {
return "4K"; return "4K";
} }
} };
const TextUtils = { const TextUtils = {
truncate, truncate,
@@ -96,6 +91,6 @@ const TextUtils = {
age: getAge, age: getAge,
bitRate, bitRate,
resolution resolution
} };
export default TextUtils; export default TextUtils;

View File

@@ -4613,13 +4613,6 @@ eslint-plugin-jsx-a11y@6.2.3, eslint-plugin-jsx-a11y@^6.2.3:
has "^1.0.3" has "^1.0.3"
jsx-ast-utils "^2.2.1" jsx-ast-utils "^2.2.1"
eslint-plugin-prettier@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.2.tgz#432e5a667666ab84ce72f945c72f77d996a5c9ba"
integrity sha512-GlolCC9y3XZfv3RQfwGew7NnuFDKsfI4lbvRK+PIIo23SFH+LemGs4cKwzAaRa+Mdb+lQO/STaIayno8T5sJJA==
dependencies:
prettier-linter-helpers "^1.0.0"
eslint-plugin-react-hooks@^1.6.1: eslint-plugin-react-hooks@^1.6.1:
version "1.7.0" version "1.7.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-1.7.0.tgz#6210b6d5a37205f0b92858f895a4e827020a7d04" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-1.7.0.tgz#6210b6d5a37205f0b92858f895a4e827020a7d04"
@@ -4943,11 +4936,6 @@ fast-deep-equal@^2.0.1:
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=
fast-diff@^1.1.2:
version "1.2.0"
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
fast-glob@^2.0.2: fast-glob@^2.0.2:
version "2.2.7" version "2.2.7"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d"
@@ -9734,19 +9722,12 @@ prepend-http@^1.0.0:
resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
prettier-linter-helpers@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b"
integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==
dependencies:
fast-diff "^1.1.2"
prettier@1.16.4: prettier@1.16.4:
version "1.16.4" version "1.16.4"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.16.4.tgz#73e37e73e018ad2db9c76742e2647e21790c9717" resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.16.4.tgz#73e37e73e018ad2db9c76742e2647e21790c9717"
integrity sha512-ZzWuos7TI5CKUeQAtFd6Zhm2s6EpAD/ZLApIhsF9pRvRtM1RFo61dM/4MSRUA0SuLugA/zgrZD8m0BaY46Og7g== integrity sha512-ZzWuos7TI5CKUeQAtFd6Zhm2s6EpAD/ZLApIhsF9pRvRtM1RFo61dM/4MSRUA0SuLugA/zgrZD8m0BaY46Og7g==
prettier@^1.19.1: prettier@1.19.1:
version "1.19.1" version "1.19.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb"
integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==