Blueprint removed

This commit is contained in:
Infinite
2020-01-11 23:14:20 +01:00
parent e18e67b512
commit 129dcecdef
85 changed files with 1429 additions and 1996 deletions

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

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

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
{ {
"extends": [ "extends": [
"react-app" "react-app"
], ],
"rules": { "rules": {
"jsx-a11y/anchor-is-valid": "off" "jsx-a11y/anchor-is-valid": "off"
} }
} }

View File

@@ -3,8 +3,6 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@blueprintjs/core": "3.22.1",
"@blueprintjs/select": "3.11.2",
"@fortawesome/fontawesome-svg-core": "^1.2.26", "@fortawesome/fontawesome-svg-core": "^1.2.26",
"@fortawesome/free-solid-svg-icons": "^5.12.0", "@fortawesome/free-solid-svg-icons": "^5.12.0",
"@fortawesome/react-fontawesome": "^0.1.8", "@fortawesome/react-fontawesome": "^0.1.8",
@@ -12,11 +10,13 @@
"apollo-link-ws": "^1.0.19", "apollo-link-ws": "^1.0.19",
"axios": "0.18.1", "axios": "0.18.1",
"bootstrap": "^4.4.1", "bootstrap": "^4.4.1",
"classnames": "^2.2.6",
"formik": "1.5.7", "formik": "1.5.7",
"graphql": "14.3.1", "graphql": "14.3.1",
"localforage": "1.7.3", "localforage": "1.7.3",
"lodash": "4.17.13", "lodash": "4.17.13",
"node-sass": "4.12.0", "node-sass": "4.12.0",
"normalize.css": "^8.0.1",
"query-string": "6.5.0", "query-string": "6.5.0",
"react": "~16.12.0", "react": "~16.12.0",
"react-apollo": "2.5.6", "react-apollo": "2.5.6",
@@ -53,6 +53,7 @@
"not op_mini all" "not op_mini all"
], ],
"devDependencies": { "devDependencies": {
"@types/classnames": "^2.2.9",
"@types/jest": "24.0.13", "@types/jest": "24.0.13",
"@types/lodash": "4.14.132", "@types/lodash": "4.14.132",
"@types/node": "11.13.0", "@types/node": "11.13.0",

View File

@@ -11,7 +11,7 @@ import { Stats } from "./components/Stats";
import Studios from "./components/Studios/Studios"; 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 { ToastProvider } from './components/Shared/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'

View File

@@ -1,26 +1,20 @@
import React, { useEffect, useState } from "react"; import React from "react";
import { Spinner } from 'react-bootstrap'; import { Spinner } from 'react-bootstrap';
import * as GQL from "../../core/generated-graphql"; import { useParams } from 'react-router-dom';
import { StashService } from "../../core/StashService"; import { StashService } from "src/core/StashService";
import { IBaseProps } from "../../models";
import { GalleryViewer } from "./GalleryViewer"; import { GalleryViewer } from "./GalleryViewer";
interface IProps extends IBaseProps {} export const Gallery: React.FC = () => {
const { id = '' } = useParams();
export const Gallery: React.FC<IProps> = (props: IProps) => { const { data, error, loading } = StashService.useFindGallery(id);
const [gallery, setGallery] = useState<Partial<GQL.GalleryDataFragment>>({}); const gallery = data?.findGallery;
const [isLoading, setIsLoading] = useState(false);
const { data, error, loading } = StashService.useFindGallery(props.match.params.id); if (loading || !gallery)
return <Spinner animation="border" variant="light" />;
if (error)
return <div>{error.message}</div>;
useEffect(() => {
setIsLoading(loading);
if (!data || !data.findGallery || !!error) { return; }
setGallery(data.findGallery);
}, [data]);
if (!data || !data.findGallery || isLoading) { return <Spinner animation="border" variant="light" />; }
if (!!error) { return <>{error.message}</>; }
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} />

View File

@@ -2,11 +2,11 @@ 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 "../../core/generated-graphql"; import { FindGalleriesQuery, FindGalleriesVariables } from "src/core/generated-graphql";
import { ListHook } from "../../hooks/ListHook"; import { ListHook } from "src/hooks";
import { IBaseProps } from "../../models/base-props"; import { IBaseProps } from "src/models/base-props";
import { ListFilterModel } from "../../models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode, FilterMode } from "../../models/list-filter/types"; import { DisplayMode, FilterMode } from "src/models/list-filter/types";
interface IProps extends IBaseProps {} interface IProps extends IBaseProps {}

View File

@@ -1,17 +1,17 @@
import React, { FunctionComponent, useState } from "react"; import React, { FunctionComponent, useState } from "react";
import Lightbox from "react-images"; import Lightbox from "react-images";
import Gallery from "react-photo-gallery"; import Gallery from "react-photo-gallery";
import * as GQL from "../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
interface IProps { interface IProps {
gallery: GQL.GalleryDataFragment; gallery: GQL.GalleryDataFragment;
} }
export const GalleryViewer: FunctionComponent<IProps> = (props: IProps) => { 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: any, obj: any) { function openLightbox(_event: React.MouseEvent<Element>, obj: {index: number}) {
setCurrentImage(obj.index); setCurrentImage(obj.index);
setLightboxIsOpen(true); setLightboxIsOpen(true);
} }
@@ -26,8 +26,8 @@ export const GalleryViewer: FunctionComponent<IProps> = (props: IProps) => {
setCurrentImage(currentImage + 1); setCurrentImage(currentImage + 1);
} }
const photos = props.gallery.files.map((file) => ({src: file.path || "", caption: file.name})); const photos = gallery.files.map((file) => ({src: file.path || "", caption: file.name}));
const thumbs = props.gallery.files.map((file) => ({src: `${file.path}?thumb=true` || "", width: 1, height: 1})); 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

@@ -71,6 +71,7 @@ export const MainNavbar: React.FC = () => {
activeClassName="active" activeClassName="active"
exact={true} exact={true}
to={i.href} to={i.href}
key={i.href}
> >
<Button variant="secondary"> <Button variant="secondary">
<FontAwesomeIcon icon={i.icon} /> <FontAwesomeIcon icon={i.icon} />
@@ -82,7 +83,7 @@ export const MainNavbar: React.FC = () => {
<Nav> <Nav>
{newButton} {newButton}
<LinkContainer <LinkContainer
exact={true} exact={true}
to="/settings"> to="/settings">
<Button variant="secondary"> <Button variant="secondary">
<FontAwesomeIcon icon="cog" /> <FontAwesomeIcon icon="cog" />

View File

@@ -1,50 +1,64 @@
import { import React from "react";
Card,
Tab,
Tabs,
} from "@blueprintjs/core";
import queryString from "query-string"; import queryString from "query-string";
import React, { FunctionComponent, useEffect, useState } from "react"; import { Card, Tab, Nav, Row, Col } from 'react-bootstrap';
import { IBaseProps } from "../../models"; 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";
import { SettingsLogsPanel } from "./SettingsLogsPanel"; import { SettingsLogsPanel } from "./SettingsLogsPanel";
import { SettingsTasksPanel } from "./SettingsTasksPanel/SettingsTasksPanel"; import { SettingsTasksPanel } from "./SettingsTasksPanel/SettingsTasksPanel";
interface IProps extends IBaseProps {} export const Settings: React.FC = () => {
const location = useLocation();
const history = useHistory();
const defaultTab = queryString.parse(location.search).tab ?? 'configuration';
type TabId = "configuration" | "tasks" | "logs" | "about"; const onSelect = ((val:string) => history.push(`?tab=${val}`));
export const Settings: FunctionComponent<IProps> = (props: IProps) => {
const [tabId, setTabId] = useState<TabId>(getTabId());
useEffect(() => {
const location = Object.assign({}, props.history.location);
location.search = queryString.stringify({tab: tabId}, {encode: false});
props.history.replace(location);
}, [tabId]);
function getTabId(): TabId {
const queryParams = queryString.parse(props.location.search);
if (!queryParams.tab || typeof queryParams.tab !== "string") { return "tasks"; }
return queryParams.tab as TabId;
}
return ( return (
<Card id="details-container"> <Card id="details-container">
<Tabs <Tab.Container defaultActiveKey={defaultTab} id="configuration-tabs" onSelect={onSelect}>
renderActiveTabPanelOnly={true} <Row>
vertical={true} <Col sm={2}>
onChange={(newId) => setTabId(newId as TabId)} <Nav variant="pills" className="flex-column">
defaultSelectedTabId={getTabId()} <Nav.Item>
> <Nav.Link eventKey="configuration">Configuration</Nav.Link>
<Tab id="configuration" title="Configuration" panel={<SettingsConfigurationPanel />} /> </Nav.Item>
<Tab id="interface" title="Interface Configuration" panel={<SettingsInterfacePanel />} /> <Nav.Item>
<Tab id="tasks" title="Tasks" panel={<SettingsTasksPanel />} /> <Nav.Link eventKey="interface">Interface</Nav.Link>
<Tab id="logs" title="Logs" panel={<SettingsLogsPanel />} /> </Nav.Item>
<Tab id="about" title="About" panel={<SettingsAboutPanel />} /> <Nav.Item>
</Tabs> <Nav.Link eventKey="tasks">Tasks</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="logs">Logs</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="about">About</Nav.Link>
</Nav.Item>
</Nav>
</Col>
<Col sm={10}>
<Tab.Content>
<Tab.Pane eventKey="configuration">
<SettingsConfigurationPanel />
</Tab.Pane>
<Tab.Pane eventKey="interface">
<SettingsInterfacePanel />
</Tab.Pane>
<Tab.Pane eventKey="tasks">
<SettingsTasksPanel />
</Tab.Pane>
<Tab.Pane eventKey="logs">
<SettingsLogsPanel />
</Tab.Pane>
<Tab.Pane eventKey="about">
<SettingsAboutPanel />
</Tab.Pane>
</Tab.Content>
</Col>
</Row>
</Tab.Container>
</Card> </Card>
); );
}; };

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { Table, Spinner } from 'react-bootstrap'; import { Table, Spinner } from 'react-bootstrap';
import { StashService } from "../../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();
@@ -30,7 +30,7 @@ export const SettingsAboutPanel: React.FC = () => {
<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>
</> </>
); );
@@ -38,8 +38,8 @@ export const SettingsAboutPanel: React.FC = () => {
return ( return (
<> <>
<h4>About</h4> <h4>About</h4>
{!data || loading ? <Spinner animation="border" variant="light" /> : undefined} {!data || loading ? <Spinner animation="border" variant="light" /> : ''}
{!!error ? <span>error.message</span> : undefined} {error ? <span>error.message</span> : ''}
{renderVersion()} {renderVersion()}
</> </>
); );

View File

@@ -1,21 +1,13 @@
import {
AnchorButton,
Button,
Divider,
FormGroup,
InputGroup,
Spinner,
Checkbox,
HTMLSelect,
} from "@blueprintjs/core";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import * as GQL from "../../core/generated-graphql"; import { Button, Form, InputGroup, Spinner } from 'react-bootstrap';
import { StashService } from "../../core/StashService"; import * as GQL from "src/core/generated-graphql";
import { ErrorUtils } from "../../utils/errors"; import { StashService } from "src/core/StashService";
import { ToastUtils } from "../../utils/toasts"; import { useToast } from 'src/hooks';
import { FolderSelect } from "../Shared/FolderSelect/FolderSelect"; import { Icon } from 'src/components/Shared';
import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect";
export const SettingsConfigurationPanel: React.FC = () => { export const SettingsConfigurationPanel: React.FC = () => {
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>(undefined);
@@ -48,10 +40,12 @@ export const SettingsConfigurationPanel: React.FC = () => {
}); });
useEffect(() => { useEffect(() => {
if (!data || !data.configuration || !!error) { return; } if (!data?.configuration || error)
const conf = StashService.nullToUndefined(data.configuration) as GQL.ConfigDataFragment; return;
if (!!conf.general) {
setStashes(conf.general.stashes || []); const conf = data.configuration;
if (conf.general) {
setStashes(conf.general.stashes ?? []);
setDatabasePath(conf.general.databasePath); setDatabasePath(conf.general.databasePath);
setGeneratedPath(conf.general.generatedPath); setGeneratedPath(conf.general.generatedPath);
setMaxTranscodeSize(conf.general.maxTranscodeSize); setMaxTranscodeSize(conf.general.maxTranscodeSize);
@@ -64,7 +58,7 @@ export const SettingsConfigurationPanel: React.FC = () => {
setLogAccess(conf.general.logAccess); setLogAccess(conf.general.logAccess);
setExcludes(conf.general.excludes); setExcludes(conf.general.excludes);
} }
}, [data]); }, [data, error]);
function onStashesChanged(directories: string[]) { function onStashesChanged(directories: string[]) {
setStashes(directories); setStashes(directories);
@@ -96,9 +90,9 @@ export const SettingsConfigurationPanel: React.FC = () => {
try { try {
const result = await updateGeneralConfig(); const result = await updateGeneralConfig();
console.log(result); console.log(result);
ToastUtils.success("Updated config"); Toast.success({ content: "Updated config" });
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
} }
@@ -137,149 +131,156 @@ export const SettingsConfigurationPanel: React.FC = () => {
return GQL.StreamingResolutionEnum.Original; return GQL.StreamingResolutionEnum.Original;
} }
if(error)
return <h1>{error.message}</h1>;
if(!data?.configuration || loading)
return <Spinner animation="border" variant="light" />;
return ( return (
<> <>
{!!error ? <h1>{error.message}</h1> : undefined}
{(!data || !data.configuration || loading) ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined}
<h4>Library</h4> <h4>Library</h4>
<FormGroup> <Form.Group>
<FormGroup> <Form.Group id="stashes">
<FormGroup <Form.Label>Stashes</Form.Label>
label="Stashes" <FolderSelect
helperText="Directory locations to your content" directories={stashes}
> onDirectoriesChanged={onStashesChanged}
<FolderSelect />
directories={stashes} <Form.Text className="text-muted">Directory locations to your content</Form.Text>
onDirectoriesChanged={onStashesChanged} </Form.Group>
/>
</FormGroup>
</FormGroup>
<FormGroup
label="Database Path"
helperText="File location for the SQLite database (requires restart)"
>
<InputGroup value={databasePath} onChange={(e: any) => setDatabasePath(e.target.value)} />
</FormGroup>
<FormGroup <Form.Group id="database-path">
label="Generated Path" <Form.Label>Database Path</Form.Label>
helperText="Directory location for the generated files (scene markers, scene previews, sprites, etc)" <Form.Control defaultValue={databasePath} onChange={(e: any) => setDatabasePath(e.target.value)} />
> <Form.Text className="text-muted">File location for the SQLite database (requires restart)</Form.Text>
<InputGroup value={generatedPath} onChange={(e: any) => setGeneratedPath(e.target.value)} /> </Form.Group>
</FormGroup>
<FormGroup <Form.Group id="generated-path">
label="Excluded Patterns" <Form.Label>Generated Path</Form.Label>
> <Form.Control 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>
{ (excludes) ? excludes.map((regexp, i) => { <Form.Group>
return( <Form.Label>Excluded Patterns</Form.Label>
<InputGroup { excludes ? excludes.map((regexp, i) => (
value={regexp} <InputGroup>
onChange={(e: any) => excludeRegexChanged(i, e.target.value)} <Form.Control
rightElement={<Button icon="minus" minimal={true} intent="danger" onClick={(e: any) => excludeRemoveRegex(i)} />} value={regexp}
/> onChange={(e: any) => excludeRegexChanged(i, e.target.value)}
); />
}) : null <InputGroup.Append>
} <Button variant="danger" onClick={() => excludeRemoveRegex(i)}>
<Icon icon="minus" />
</Button>
</InputGroup.Append>
</InputGroup>
)) : ''
}
<Button icon="plus" minimal={true} onClick={(e: any) => excludeAddRegex()} /> <Button variant="danger" onClick={() => excludeAddRegex()}>
<Icon icon="plus" />
</Button>
<div> <div>
<p> <p>
<AnchorButton <a
href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration" href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration"
rightIcon="help" rel="noopener noreferrer"
text="Regexps of files/paths to exclude from Scan and add to Clean" target="_blank"
minimal={true} >
target="_blank" <span>Regexps of files/paths to exclude from Scan and add to Clean</span>
/> <Icon icon="question-circle" />
</a>
</p> </p>
</div> </div>
</FormGroup> </Form.Group>
</FormGroup> </Form.Group>
<Divider />
<FormGroup>
<h4>Video</h4>
<FormGroup
label="Maximum transcode size"
helperText="Maximum size for generated transcodes"
>
<HTMLSelect
options={transcodeQualities}
onChange={(event) => setMaxTranscodeSize(translateQuality(event.target.value))}
value={resolutionToString(maxTranscodeSize)}
/>
</FormGroup>
<FormGroup
label="Maximum streaming transcode size"
helperText="Maximum size for transcoded streams"
>
<HTMLSelect
options={transcodeQualities}
onChange={(event) => setMaxStreamingTranscodeSize(translateQuality(event.target.value))}
value={resolutionToString(maxStreamingTranscodeSize)}
/>
</FormGroup>
</FormGroup>
<Divider />
<FormGroup> <hr />
<Form.Group>
<h4>Video</h4>
<Form.Group id="transcode-size">
<Form.Label>Maximum transcode size</Form.Label>
<Form.Control
as="select">
onChange={(event:React.FormEvent<HTMLSelectElement>) => setMaxTranscodeSize(translateQuality(event.currentTarget.value))}
value={resolutionToString(maxTranscodeSize)}
>
{ transcodeQualities.map(q => (<option key={q} value={q}>{q}</option>))}
</Form.Control>
<Form.Text className="text-muted">Maximum size for generated transcodes</Form.Text>
</Form.Group>
<Form.Group id="streaming-transcode-size">
<Form.Label>Maximum streaming transcode size</Form.Label>
<Form.Control
as="select"
onChange={(event:React.FormEvent<HTMLSelectElement>) => setMaxStreamingTranscodeSize(translateQuality(event.currentTarget.value))}
value={resolutionToString(maxStreamingTranscodeSize)}
>
{ transcodeQualities.map(q => (<option key={q} value={q}>{q}</option>))}
</Form.Control>
<Form.Text className="text-muted">Maximum size for transcoded streams</Form.Text>
</Form.Group>
</Form.Group>
<hr />
<Form.Group>
<h4>Authentication</h4> <h4>Authentication</h4>
<FormGroup <Form.Group id="username">
label="Username" <Form.Label>Username</Form.Label>
helperText="Username to access Stash. Leave blank to disable user authentication" <Form.Control 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>
<InputGroup value={username} onChange={(e: any) => setUsername(e.target.value)} /> </Form.Group>
</FormGroup> <Form.Group id="password">
<FormGroup <Form.Label>Password</Form.Label>
label="Password" <Form.Control type="password" defaultValue={password} onChange={(e: React.FormEvent<HTMLInputElement>) => setPassword(e.currentTarget.value)} />
helperText="Password to access Stash. Leave blank to disable user authentication" <Form.Text className="text-muted">Password to access Stash. Leave blank to disable user authentication</Form.Text>
> </Form.Group>
<InputGroup type="password" value={password} onChange={(e: any) => setPassword(e.target.value)} /> </Form.Group>
</FormGroup>
</FormGroup> <hr />
<Divider />
<h4>Logging</h4> <h4>Logging</h4>
<FormGroup <Form.Group id="log-file">
label="Log file" <Form.Label>Log file</Form.Label>
helperText="Path to the file to output logging to. Blank to disable file logging. Requires restart." <Form.Control 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>
<InputGroup value={logFile} onChange={(e: any) => setLogFile(e.target.value)} /> </Form.Group>
</FormGroup>
<FormGroup <Form.Group>
helperText="Logs to the terminal in addition to a file. Always true if file logging is disabled. Requires restart." <Form.Check
>
<Checkbox
checked={logOut} checked={logOut}
label="Log to terminal" label="Log to terminal"
onChange={() => setLogOut(!logOut)} onChange={() => setLogOut(!logOut)}
/> />
</FormGroup> <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>
<FormGroup inline={true} label="Log Level"> <Form.Group id="log-level">
<HTMLSelect <Form.Label>Log Level</Form.Label>
options={["Debug", "Info", "Warning", "Error"]} <Form.Control
onChange={(event) => setLogLevel(event.target.value)} as="select"
onChange={(event:React.FormEvent<HTMLSelectElement>) => setLogLevel(event.currentTarget.value)}
value={logLevel} value={logLevel}
/> >
</FormGroup> { ["Debug", "Info", "Warning", "Error"].map(o => (<option key={o} value={o}>{o}</option>)) }
</Form.Control>
</Form.Group>
<FormGroup <Form.Group>
helperText="Logs http access to the terminal. Requires restart." <Form.Check
>
<Checkbox
checked={logAccess} checked={logAccess}
label="Log http access" label="Log http access"
onChange={() => setLogAccess(!logAccess)} onChange={() => setLogAccess(!logAccess)}
/> />
</FormGroup> <Form.Text className="text-muted">Logs http access to the terminal. Requires restart.</Form.Text>
</Form.Group>
<Divider /> <hr />
<Button intent="primary" onClick={() => onSave()}>Save</Button>
<Button variant="primary" onClick={() => onSave()}>Save</Button>
</> </>
); );
}; };

View File

@@ -1,20 +1,10 @@
import { import React, { useEffect, useState } from "react";
Button, import { Button, Form, Spinner } from 'react-bootstrap';
Checkbox, import { StashService } from "src/core/StashService";
Divider, import { useToast } from 'src/hooks';
FormGroup,
Spinner,
TextArea,
NumericInput
} from "@blueprintjs/core";
import React, { FunctionComponent, useEffect, useState } from "react";
import { StashService } from "../../core/StashService";
import { ErrorUtils } from "../../utils/errors";
import { ToastUtils } from "../../utils/toasts";
interface IProps {} export const SettingsInterfacePanel: React.FC = () => {
const Toast = useToast();
export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
const config = StashService.useConfiguration(); const config = StashService.useConfiguration();
const [soundOnPreview, setSoundOnPreview] = useState<boolean>(); const [soundOnPreview, setSoundOnPreview] = useState<boolean>();
const [wallShowTitle, setWallShowTitle] = useState<boolean>(); const [wallShowTitle, setWallShowTitle] = useState<boolean>();
@@ -35,66 +25,63 @@ export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
}); });
useEffect(() => { useEffect(() => {
if (!config.data || !config.data.configuration || !!config.error) { return; } if (config.error)
if (!!config.data.configuration.interface) { return;
let iCfg = config.data.configuration.interface;
setSoundOnPreview(iCfg.soundOnPreview !== undefined ? iCfg.soundOnPreview : true); const iCfg = config?.data?.configuration?.interface;
setWallShowTitle(iCfg.wallShowTitle !== undefined ? iCfg.wallShowTitle : true); setSoundOnPreview(iCfg?.soundOnPreview ?? true);
setMaximumLoopDuration(iCfg.maximumLoopDuration || 0); setWallShowTitle(iCfg?.wallShowTitle ?? true);
setAutostartVideo(iCfg.autostartVideo !== undefined ? iCfg.autostartVideo : false); setMaximumLoopDuration(iCfg?.maximumLoopDuration ?? 0);
setShowStudioAsText(iCfg.showStudioAsText !== undefined ? iCfg.showStudioAsText : false); setAutostartVideo(iCfg?.autostartVideo ?? false);
setCSS(config.data.configuration.interface.css || ""); setShowStudioAsText(iCfg?.showStudioAsText ?? false);
setCSSEnabled(config.data.configuration.interface.cssEnabled || false); setCSS(iCfg?.css ?? "");
} setCSSEnabled(iCfg?.cssEnabled ?? false);
}, [config.data]); }, [config]);
async function onSave() { async function onSave() {
try { try {
const result = await updateInterfaceConfig(); const result = await updateInterfaceConfig();
console.log(result); console.log(result);
ToastUtils.success("Updated config"); Toast.success({ content: "Updated config" });
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
} }
return ( return (
<> <>
{!!config.error ? <h1>{config.error.message}</h1> : undefined} {config.error ? <h1>{config.error.message}</h1> : ''}
{(!config.data || !config.data.configuration || config.loading) ? <Spinner size={Spinner.SIZE_LARGE} /> : undefined} {(!config?.data?.configuration || config.loading) ? <Spinner animation="border" variant="light" /> : ''}
<h4>User Interface</h4> <h4>User Interface</h4>
<FormGroup <Form.Group>
label="Scene / Marker Wall" <Form.Label>Scene / Marker Wall</Form.Label>
helperText="Configuration for wall items" <Form.Check
>
<Checkbox
checked={wallShowTitle} checked={wallShowTitle}
label="Display title and tags" label="Display title and tags"
onChange={() => setWallShowTitle(!wallShowTitle)} onChange={() => setWallShowTitle(!wallShowTitle)}
/> />
<Checkbox <Form.Check
checked={soundOnPreview} checked={soundOnPreview}
label="Enable sound" label="Enable sound"
onChange={() => setSoundOnPreview(!soundOnPreview)} onChange={() => setSoundOnPreview(!soundOnPreview)}
/> />
</FormGroup> <Form.Text className="text-muted" >Configuration for wall items</Form.Text>
</Form.Group>
<FormGroup <Form.Group>
label="Scene List" <Form.Label>Scene List</Form.Label>
> <Form.Check
<Checkbox
checked={showStudioAsText} checked={showStudioAsText}
label="Show Studios as text" label="Show Studios as text"
onChange={() => { onChange={() => {
setShowStudioAsText(!showStudioAsText) setShowStudioAsText(!showStudioAsText)
}} }}
/> />
</FormGroup> </Form.Group>
<FormGroup <Form.Group>
label="Scene Player" <Form.Label>Scene Player</Form.Label>
> <Form.Check
<Checkbox
checked={autostartVideo} checked={autostartVideo}
label="Auto-start video" label="Auto-start video"
onChange={() => { onChange={() => {
@@ -102,25 +89,22 @@ export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
}} }}
/> />
<FormGroup <Form.Group id="max-loop-duration">
label="Maximum loop duration" <Form.Label>Maximum loop duration</Form.Label>
helperText="Maximum scene duration - in seconds - where scene player will loop the video - 0 to disable" <Form.Control
>
<NumericInput
value={maximumLoopDuration}
type="number" type="number"
onValueChange={(value: number) => setMaximumLoopDuration(value)} defaultValue={maximumLoopDuration}
onChange={(event:React.FormEvent<HTMLInputElement>) => setMaximumLoopDuration(Number.parseInt(event.currentTarget.value) ?? 0)}
min={0} min={0}
minorStepSize={1} step={1}
/> />
</FormGroup> <Form.Text className="text-muted">Maximum scene duration - in seconds - where scene player will loop the video - 0 to disable</Form.Text>
</FormGroup> </Form.Group>
</Form.Group>
<FormGroup <Form.Group>
label="Custom CSS" <Form.Label>Custom CSS</Form.Label>
helperText="Page must be reloaded for changes to take effect." <Form.Check
>
<Checkbox
checked={cssEnabled} checked={cssEnabled}
label="Custom CSS enabled" label="Custom CSS enabled"
onChange={() => { onChange={() => {
@@ -128,16 +112,17 @@ export const SettingsInterfacePanel: FunctionComponent<IProps> = () => {
}} }}
/> />
<TextArea <Form.Control
value={css} as="textarea"
value={css}
onChange={(e: any) => setCSS(e.target.value)} onChange={(e: any) => setCSS(e.target.value)}
fill={true}
rows={16}> rows={16}>
</TextArea> </Form.Control>
</FormGroup> <Form.Text className="text-muted">Page must be reloaded for changes to take effect.</Form.Text>
</Form.Group>
<Divider /> <hr />
<Button intent="primary" onClick={() => onSave()}>Save</Button> <Button variant="primary" onClick={() => onSave()}>Save</Button>
</> </>
); );
}; };

View File

@@ -1,13 +1,9 @@
import { import React, { useState, useEffect, useRef } from "react";
H4, FormGroup, HTMLSelect, import { Form, Col } from 'react-bootstrap';
} from "@blueprintjs/core"; import * as GQL from "src/core/generated-graphql";
import React, { FunctionComponent, useState, useEffect, useRef } from "react"; import { StashService } from "src/core/StashService";
import * as GQL from "../../core/generated-graphql";
import { StashService } from "../../core/StashService";
interface IProps {} function convertTime(logEntry: GQL.LogEntryDataFragment) {
function convertTime(logEntry : GQL.LogEntryDataFragment) {
function pad(val : number) { function pad(val : number) {
var ret = val.toString(); var ret = val.toString();
if (val <= 9) { if (val <= 9) {
@@ -44,17 +40,17 @@ class LogEntry {
} }
} }
export const SettingsLogsPanel: FunctionComponent<IProps> = (props: IProps) => { export const SettingsLogsPanel: React.FC = () => {
const { data, error } = StashService.useLoggingSubscribe(); const { data, error } = StashService.useLoggingSubscribe();
const { data: existingData } = StashService.useLogs(); const { data: existingData } = StashService.useLogs();
const logEntries = useRef<LogEntry[]>([]); const logEntries = useRef<LogEntry[]>([]);
const [logLevel, setLogLevel] = useState<string>("Info"); const [logLevel, setLogLevel] = useState<string>("Info");
const [filteredLogEntries, setFilteredLogEntries] = useState<LogEntry[]>([]); const [filteredLogEntries, setFilteredLogEntries] = useState<LogEntry[]>([]);
const lastUpdate = useRef<number>(0); const lastUpdate = useRef<number>(0);
const updateTimeout = useRef<NodeJS.Timeout>(); const updateTimeout = useRef<NodeJS.Timeout>();
// maximum number of log entries to display. Subsequent entries will truncate // maximum number of log entries to display. Subsequent entries will truncate
// the list, dropping off the oldest entries first. // the list, dropping off the oldest entries first.
const MAX_LOG_ENTRIES = 200; const MAX_LOG_ENTRIES = 200;
@@ -83,7 +79,7 @@ export const SettingsLogsPanel: FunctionComponent<IProps> = (props: IProps) => {
// filter subscribed data as it comes in, otherwise we'll end up // filter subscribed data as it comes in, otherwise we'll end up
// truncating stuff that wasn't filtered out // truncating stuff that wasn't filtered out
convertedData = convertedData.filter(filterByLogLevel) convertedData = convertedData.filter(filterByLogLevel)
// put newest entries at the top // put newest entries at the top
convertedData.reverse(); convertedData.reverse();
prependLogEntries(convertedData); prependLogEntries(convertedData);
@@ -167,16 +163,21 @@ export const SettingsLogsPanel: FunctionComponent<IProps> = (props: IProps) => {
return ( return (
<> <>
<H4>Logs</H4> <h4>Logs</h4>
<div> <Form.Row id="log-level">
<FormGroup inline={true} label="Log Level"> <Col xs={1}>
<HTMLSelect <Form.Label>Log Level</Form.Label>
options={logLevels} </Col>
onChange={(event) => setLogLevel(event.target.value)} <Col xs={2}>
value={logLevel} <Form.Control
/> as="select"
</FormGroup> defaultValue={logLevel}
</div> onChange={(event) => setLogLevel(event.currentTarget.value)}
>
{ logLevels.map(level => (<option key={level} value={level}>{level}</option>)) }
</Form.Control>
</Col>
</Form.Row>
<div className="logs"> <div className="logs">
{maybeRenderError()} {maybeRenderError()}
{filteredLogEntries.map((logEntry) => {filteredLogEntries.map((logEntry) =>

View File

@@ -1,53 +1,32 @@
import { import React, { useState } from "react";
Button, import { Button, Form } from 'react-bootstrap';
Checkbox, import { StashService } from "src/core/StashService";
FormGroup, import { useToast } from 'src/hooks';
} from "@blueprintjs/core";
import React, { FunctionComponent, useState } from "react";
import { StashService } from "../../../core/StashService";
import { ErrorUtils } from "../../../utils/errors";
import { ToastUtils } from "../../../utils/toasts";
interface IProps {} export const GenerateButton: React.FC = () => {
const Toast = useToast();
export const GenerateButton: FunctionComponent<IProps> = () => { const [sprites, setSprites] = useState(true);
const [sprites, setSprites] = useState<boolean>(true); const [previews, setPreviews] = useState(true);
const [previews, setPreviews] = useState<boolean>(true); const [markers, setMarkers] = useState(true);
const [markers, setMarkers] = useState<boolean>(true); const [transcodes, setTranscodes] = useState(true);
const [transcodes, setTranscodes] = useState<boolean>(true);
async function onGenerate() { async function onGenerate() {
try { try {
await StashService.queryMetadataGenerate({sprites, previews, markers, transcodes}); await StashService.queryMetadataGenerate({sprites, previews, markers, transcodes});
ToastUtils.success("Started generating"); Toast.success({ content: "Started generating" });
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
} }
return ( return (
<FormGroup <Form.Group>
helperText="Generate supporting image, sprite, video, vtt and other files." <Form.Check id="sprite-task" checked={sprites} label="Sprites (for the scene scrubber)" onChange={() => setSprites(!sprites)} />
labelFor="generate" <Form.Check id="preview-task" checked={previews} label="Previews (video previews which play when hovering over a scene)" onChange={() => setPreviews(!previews)} />
inline={true} <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)} />
<Checkbox checked={sprites} label="Sprites (for the scene scrubber)" onChange={() => setSprites(!sprites)} /> <Button id="generate" type="submit" onClick={() => onGenerate()}>Generate</Button>
<Checkbox <Form.Text className="text-muted">Generate supporting image, sprite, video, vtt and other files.</Form.Text>
checked={previews} </Form.Group>
label="Previews (video previews which play when hovering over a scene)"
onChange={() => setPreviews(!previews)}
/>
<Checkbox
checked={markers}
label="Markers (20 second videos which begin at the given timecode)"
onChange={() => setMarkers(!markers)}
/>
<Checkbox
checked={transcodes}
label="Transcodes (MP4 conversions of unsupported video formats)"
onChange={() => setTranscodes(!transcodes)}
/>
<Button id="generate" text="Generate" onClick={() => onGenerate()} />
</FormGroup>
); );
}; };

View File

@@ -1,24 +1,18 @@
import {
Alert,
Button,
Checkbox,
Divider,
FormGroup,
ProgressBar,
} from "@blueprintjs/core";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { StashService } from "../../../core/StashService"; import { Button, Form, ProgressBar } from 'react-bootstrap';
import { ErrorUtils } from "../../../utils/errors";
import { ToastUtils } from "../../../utils/toasts";
import { GenerateButton } from "./GenerateButton";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { StashService } from "src/core/StashService";
import { useToast } from 'src/hooks';
import { Modal } from 'src/components/Shared';
import { GenerateButton } from "./GenerateButton";
export const SettingsTasksPanel: React.FC = () => { export const SettingsTasksPanel: React.FC = () => {
const Toast = useToast();
const [isImportAlertOpen, setIsImportAlertOpen] = useState<boolean>(false); const [isImportAlertOpen, setIsImportAlertOpen] = useState<boolean>(false);
const [isCleanAlertOpen, setIsCleanAlertOpen] = useState<boolean>(false); const [isCleanAlertOpen, setIsCleanAlertOpen] = useState<boolean>(false);
const [useFileMetadata, setUseFileMetadata] = useState<boolean>(false); const [useFileMetadata, setUseFileMetadata] = useState<boolean>(false);
const [status, setStatus] = useState<string>(""); const [status, setStatus] = useState<string>("");
const [progress, setProgress] = useState<number | undefined>(undefined); const [progress, setProgress] = useState<number>(0);
const [autoTagPerformers, setAutoTagPerformers] = useState<boolean>(true); const [autoTagPerformers, setAutoTagPerformers] = useState<boolean>(true);
const [autoTagStudios, setAutoTagStudios] = useState<boolean>(true); const [autoTagStudios, setAutoTagStudios] = useState<boolean>(true);
@@ -53,7 +47,7 @@ export const SettingsTasksPanel: React.FC = () => {
setStatus(statusToText(jobStatus.data.jobStatus.status)); setStatus(statusToText(jobStatus.data.jobStatus.status));
var newProgress = jobStatus.data.jobStatus.progress; var newProgress = jobStatus.data.jobStatus.progress;
if (newProgress < 0) { if (newProgress < 0) {
setProgress(undefined); setProgress(0);
} else { } else {
setProgress(newProgress); setProgress(newProgress);
} }
@@ -65,7 +59,7 @@ export const SettingsTasksPanel: React.FC = () => {
setStatus(statusToText(metadataUpdate.data.metadataUpdate.status)); setStatus(statusToText(metadataUpdate.data.metadataUpdate.status));
var newProgress = metadataUpdate.data.metadataUpdate.progress; var newProgress = metadataUpdate.data.metadataUpdate.progress;
if (newProgress < 0) { if (newProgress < 0) {
setProgress(undefined); setProgress(0);
} else { } else {
setProgress(newProgress); setProgress(newProgress);
} }
@@ -79,20 +73,17 @@ export const SettingsTasksPanel: React.FC = () => {
function renderImportAlert() { function renderImportAlert() {
return ( return (
<Alert <Modal
cancelButtonText="Cancel" show={isImportAlertOpen}
confirmButtonText="Import" icon="trash-alt"
icon="trash" accept={{ text: 'Import', variant: 'danger', onClick: onImport }}
intent="danger" cancel={{ onClick: () => setIsImportAlertOpen(false) }}
isOpen={isImportAlertOpen}
onCancel={() => setIsImportAlertOpen(false)}
onConfirm={() => onImport()}
> >
<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 re-import from
your exported metadata. your exported metadata.
</p> </p>
</Alert> </Modal>
); );
} }
@@ -103,31 +94,28 @@ export const SettingsTasksPanel: React.FC = () => {
function renderCleanAlert() { function renderCleanAlert() {
return ( return (
<Alert <Modal
cancelButtonText="Cancel" show={isCleanAlertOpen}
confirmButtonText="Clean" icon="trash-alt"
icon="trash" accept={{ text: 'Clean', variant: 'danger', onClick: onClean }}
intent="danger" cancel={{ onClick: () => setIsCleanAlertOpen(false) }}
isOpen={isCleanAlertOpen}
onCancel={() => setIsCleanAlertOpen(false)}
onConfirm={() => onClean()}
> >
<p> <p>
Are you sure you want to Clean? Are you sure you want to Clean?
This will delete db information and generated content This will delete db information and generated content
for all scenes that are no longer found in the filesystem. for all scenes that are no longer found in the filesystem.
</p> </p>
</Alert> </Modal>
); );
} }
async function onScan() { async function onScan() {
try { try {
await StashService.queryMetadataScan({useFileMetadata: useFileMetadata}); await StashService.queryMetadataScan({useFileMetadata: useFileMetadata});
ToastUtils.success("Started scan"); Toast.success({ content: "Started scan" });
jobStatus.refetch(); jobStatus.refetch();
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
} }
@@ -143,10 +131,10 @@ export const SettingsTasksPanel: React.FC = () => {
async function onAutoTag() { async function onAutoTag() {
try { try {
await StashService.queryMetadataAutoTag(getAutoTagInput()); await StashService.queryMetadataAutoTag(getAutoTagInput());
ToastUtils.success("Started auto tagging"); Toast.success({ content: "Started auto tagging" });
jobStatus.refetch(); jobStatus.refetch();
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
} }
@@ -156,21 +144,19 @@ export const SettingsTasksPanel: React.FC = () => {
} }
return ( return (
<> <Form.Group>
<FormGroup> <Button id="stop" variant="danger" onClick={() => StashService.queryStopJob().then(() => jobStatus.refetch())}>Stop</Button>
<Button id="stop" text="Stop" intent="danger" onClick={() => StashService.queryStopJob().then(() => jobStatus.refetch())} /> </Form.Group>
</FormGroup>
</>
); );
} }
function renderJobStatus() { function renderJobStatus() {
return ( return (
<> <>
<FormGroup> <Form.Group>
<h5>Status: {status}</h5> <h5>Status: {status}</h5>
{!!status && status !== "Idle" ? <ProgressBar value={progress}/> : undefined} { status !== "Idle" ? <ProgressBar now={progress} label={`${progress}%`} /> : '' }
</FormGroup> </Form.Group>
{maybeRenderStop()} {maybeRenderStop()}
</> </>
); );
@@ -185,83 +171,70 @@ export const SettingsTasksPanel: React.FC = () => {
{renderJobStatus()} {renderJobStatus()}
<Divider/> <hr />
<h4>Library</h4> <h4>Library</h4>
<FormGroup <Form.Group>
helperText="Scan for new content and add it to the database." <Form.Check
labelFor="scan"
inline={true}
>
<Checkbox
checked={useFileMetadata} checked={useFileMetadata}
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" text="Scan" onClick={() => onScan()} /> <Button id="scan" type="submit" onClick={() => onScan()}>Scan</Button>
</FormGroup> <Form.Text className="text-muted">Scan for new content and add it to the database.</Form.Text>
</Form.Group>
<Divider /> <hr />
<h4>Auto Tagging</h4> <h4>Auto Tagging</h4>
<FormGroup <Form.Group>
helperText="Auto-tag content based on filenames." <Form.Check
labelFor="autoTag"
inline={true}
>
<Checkbox
checked={autoTagPerformers} checked={autoTagPerformers}
label="Performers" label="Performers"
onChange={() => setAutoTagPerformers(!autoTagPerformers)} onChange={() => setAutoTagPerformers(!autoTagPerformers)}
/> />
<Checkbox <Form.Check
checked={autoTagStudios} checked={autoTagStudios}
label="Studios" label="Studios"
onChange={() => setAutoTagStudios(!autoTagStudios)} onChange={() => setAutoTagStudios(!autoTagStudios)}
/> />
<Checkbox <Form.Check
checked={autoTagTags} checked={autoTagTags}
label="Tags" label="Tags"
onChange={() => setAutoTagTags(!autoTagTags)} onChange={() => setAutoTagTags(!autoTagTags)}
/> />
<Button id="autoTag" text="Auto Tag" onClick={() => onAutoTag()} /> <Button id="autoTag" type="submit" onClick={() => onAutoTag()}>Auto Tag</Button>
</FormGroup> <Form.Text className="text-muted">Auto-tag content based on filenames.</Form.Text>
</Form.Group>
<FormGroup> <Form.Group>
<Link className="bp3-button" to={"/sceneFilenameParser"}> <Button>
Scene Filename Parser <Link to={"/sceneFilenameParser"}>Scene Filename Parser</Link>
</Link> </Button>
</FormGroup> </Form.Group>
<Divider />
<hr />
<h4>Generated Content</h4> <h4>Generated Content</h4>
<GenerateButton /> <GenerateButton />
<FormGroup <Form.Group>
helperText="Check for missing files and remove them from the database. This is a destructive action." <Button id="clean" variant="danger" onClick={() => setIsCleanAlertOpen(true)}>Clean</Button>
labelFor="clean" <Form.Text className="text-muted">Check for missing files and remove them from the database. This is a destructive action.</Form.Text>
inline={true} </Form.Group>
>
<Button id="clean" text="Clean" intent="danger" onClick={() => setIsCleanAlertOpen(true)} /> <hr />
</FormGroup>
<Divider />
<h4>Metadata</h4> <h4>Metadata</h4>
<FormGroup <Form.Group>
helperText="Export the database content into JSON format" <Button id="export" type="submit"onClick={() => StashService.queryMetadataExport().then(() => { jobStatus.refetch()})}>Export</Button>
labelFor="export" <Form.Text className="text-muted">Export the database content into JSON format.</Form.Text>
inline={true} </Form.Group>
>
<Button id="export" text="Export" onClick={() => StashService.queryMetadataExport().then(() => { jobStatus.refetch()})} />
</FormGroup>
<FormGroup <Form.Group>
helperText="Import from exported JSON. This is a destructive action." <Button id="import" variant="danger" onClick={() => setIsImportAlertOpen(true)}>Import</Button>
labelFor="import" <Form.Text className="text-muted">Import from exported JSON. This is a destructive action.</Form.Text>
inline={true} </Form.Group>
>
<Button id="import" text="Import" intent="danger" onClick={() => setIsImportAlertOpen(true)} />
</FormGroup>
</> </>
); );
}; };

View File

@@ -1,8 +1,8 @@
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 "../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { NavigationUtils } from "../../utils/navigation"; import { NavUtils } from "src/utils";
interface IProps { interface IProps {
performer?: Partial<GQL.PerformerDataFragment>; performer?: Partial<GQL.PerformerDataFragment>;
@@ -92,10 +92,10 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
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 = NavigationUtils.makePerformerScenesUrl(props.performer); linkSrc = NavUtils.makePerformerScenesUrl(props.performer);
} else if (!!props.studio) { } else if (props.studio) {
linkSrc = NavigationUtils.makeStudioScenesUrl(props.studio); linkSrc = NavUtils.makeStudioScenesUrl(props.studio);
} }
return ( return (
<Link to={linkSrc}> <Link to={linkSrc}>

View File

@@ -1,7 +1,7 @@
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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { TextUtils } from "../../utils/text"; import { TextUtils } from "src/utils";
interface IProps { interface IProps {
disabled?: boolean disabled?: boolean
@@ -35,7 +35,7 @@ export const DurationInput: React.FC<IProps> = (props: IProps) => {
if (!v) { if (!v) {
return 0; return 0;
} }
let splits = v.split(":"); let splits = v.split(":");
if (splits.length > 3) { if (splits.length > 3) {
@@ -122,9 +122,7 @@ export const DurationInput: React.FC<IProps> = (props: IProps) => {
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"
> />
{renderButtons()}
</Form.Control>
<InputGroup.Append> <InputGroup.Append>
{maybeRenderReset()} {maybeRenderReset()}
</InputGroup.Append> </InputGroup.Append>

View File

@@ -1,6 +1,6 @@
import { Button, InputGroup, Form, Modal, Spinner } from 'react-bootstrap';
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { StashService } from "../../../core/StashService"; import { Button, InputGroup, Form, Modal, Spinner } from 'react-bootstrap';
import { StashService } from "src/core/StashService";
interface IProps { interface IProps {
directories: string[]; directories: string[];
@@ -17,7 +17,7 @@ export const FolderSelect: React.FC<IProps> = (props: IProps) => {
setSelectedDirectories(props.directories); setSelectedDirectories(props.directories);
}, [props.directories]); }, [props.directories]);
const selectableDirectories:string[] = data && data.directories && !error ? StashService.nullToUndefined(data.directories) : []; const selectableDirectories:string[] = data?.directories ?? [];
function onSelectDirectory() { function onSelectDirectory() {
selectedDirectories.push(currentDirectory); selectedDirectories.push(currentDirectory);
@@ -55,7 +55,7 @@ export const FolderSelect: React.FC<IProps> = (props: IProps) => {
{(!data || !data.directories || loading) ? <Spinner animation="border" variant="light" /> : undefined} {(!data || !data.directories || loading) ? <Spinner animation="border" variant="light" /> : undefined}
</InputGroup.Append> </InputGroup.Append>
</InputGroup> </InputGroup>
/> />
{selectableDirectories.map((path) => { {selectableDirectories.map((path) => {
return <div key={path} onClick={() => setCurrentDirectory(path)}>{path}</div>; return <div key={path} onClick={() => setCurrentDirectory(path)}>{path}</div>;
@@ -71,14 +71,14 @@ export const FolderSelect: React.FC<IProps> = (props: IProps) => {
return ( return (
<> <>
{!!error ? <h1>{error.message}</h1> : undefined} {error ? <h1>{error.message}</h1> : ''}
{renderDialog()} {renderDialog()}
<Form.Group> <Form.Group>
{selectedDirectories.map((path) => { {selectedDirectories.map((path) => {
return <div key={path}>{path} <a onClick={() => onRemoveDirectory(path)}>Remove</a></div>; return <div key={path}>{path} <a onClick={() => onRemoveDirectory(path)}>Remove</a></div>;
})} })}
</Form.Group> </Form.Group>
<Button onClick={() => setIsDisplayingDialog(true)}>Add Directory</Button> <Button onClick={() => setIsDisplayingDialog(true)}>Add Directory</Button>
</> </>
); );

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import React, { useState } 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 { ErrorUtils } from "../../utils/errors"; import * as GQL from "src/core/generated-graphql";
import * as GQL from "../../core/generated-graphql"; import { StashService } from "src/core/StashService";
import { StashService } from "../../core/StashService"; import { useToast } from 'src/hooks';
import useToast from '../Shared/Toast';
type ValidTypes = type ValidTypes =
GQL.AllPerformersForFilterAllPerformers | GQL.AllPerformersForFilterAllPerformers |
@@ -14,7 +14,7 @@ type ValidTypes =
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[];
@@ -32,8 +32,66 @@ interface ISelectProps {
isLoading: boolean; isLoading: boolean;
onChange: (item: ValueType<Option>) => void; onChange: (item: ValueType<Option>) => void;
initialIds: string[]; initialIds: string[];
noSelectionString?: string;
isMulti?: boolean; isMulti?: boolean;
onInputChange?: (input: string) => void;
placeholder?: string;
}
interface ISceneGallerySelect {
initialId?: string;
sceneId: string;
onSelect: (item: GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined) => void;
}
export const SceneGallerySelect: React.FC<ISceneGallerySelect> = (props) => {
const { data, loading } = StashService.useValidGalleriesForScene(props.sceneId);
const galleries = data?.validGalleriesForScene ?? [];
const items = (galleries.length > 0 ? [{ path: 'None', id: '0' }, ...galleries] : [])
.map(g => ({ label: g.path, value: g.id }));
const onChange = (selectedItems:ValueType<Option>) => {
const selectedItem = getSelectedValues(selectedItems)[0];
props.onSelect(galleries.find(g => g.id === selectedItem.value));
};
const initialId = props.initialId ? [props.initialId] : [];
return <SelectComponent onChange={onChange} isLoading={loading} items={items} initialIds={initialId} />
};
interface IScrapePerformerSuggestProps {
scraperId: string;
onSelectPerformer: (query: GQL.ScrapePerformerListScrapePerformerList) => void;
placeholder?: string;
}
export const ScrapePerformerSuggest: React.FC<IScrapePerformerSuggestProps> = (props) => {
const [query, setQuery] = React.useState<string>("");
const { data, loading } = StashService.useScrapePerformerList(props.scraperId, query);
const onInputChange = useCallback(debounce((input:string) => { setQuery(input)}, 500), []);
const onChange = (selectedItems:ValueType<Option>) => (
props.onSelectPerformer(getSelectedValues(selectedItems)[0])
);
const performers = data?.scrapePerformerList ?? [];
console.log(`performers: ${performers}, loading: ${loading}, query: ${query}`);
const items = performers.map(item => ({ label: item.name ?? '', value: item.name ?? '' }));
return <SelectComponent onChange={onChange} onInputChange={onInputChange} isLoading={loading} items={items} initialIds={[]} placeholder={props.placeholder} />
}
interface IMarkerSuggestProps {
initialMarkerTitle?: string;
onChange: (title:string) => void;
}
export const MarkerTitleSuggest: React.FC<IMarkerSuggestProps> = (props) => {
const { data, loading } = StashService.useMarkerStrings();
const suggestions = data?.markerStrings ?? [];
const onChange = (selectedItems:ValueType<Option>) => (
props.onChange(getSelectedValues(selectedItems)[0])
);
const items = suggestions.map(item => ({ label: item?.title ?? '', value: item?.title ?? '' }));
const initialIds = props.initialMarkerTitle ? [props.initialMarkerTitle] : [];
return <SelectComponent creatable onChange={onChange} isLoading={loading} items={items} initialIds={initialIds} />
} }
export const FilterSelect: React.FC<IFilterProps & ITypeProps> = (props) => ( export const FilterSelect: React.FC<IFilterProps & ITypeProps> = (props) => (
@@ -44,17 +102,17 @@ export const FilterSelect: React.FC<IFilterProps & ITypeProps> = (props) => (
export const PerformerSelect: React.FC<IFilterProps> = (props) => { 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 => ({ value: item.id, label: item.name ?? '' }));
const placeholder = props.noSelectionString ?? "Select performer..."
const onChange = (selectedItems:ValueType<Option>) => { const onChange = (selectedItems:ValueType<Option>) => {
const selectedIds = (Array.isArray(selectedItems) ? selectedItems : [selectedItems]) const selectedIds = getSelectedValues(selectedItems);
.map(item => item.value);
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} /> 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) => {
@@ -62,14 +120,14 @@ export const StudioSelect: React.FC<IFilterProps> = (props) => {
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 => ({ value: item.id, label: item.name }));
const placeholder = props.noSelectionString ?? "Select studio..."
const onChange = (selectedItems:ValueType<Option>) => { const onChange = (selectedItems:ValueType<Option>) => {
const selectedIds = (Array.isArray(selectedItems) ? selectedItems : [selectedItems]) const selectedIds = getSelectedValues(selectedItems);
.map(item => item.value);
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} /> 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) => {
@@ -78,6 +136,7 @@ export const TagSelect: React.FC<IFilterProps> = (props) => {
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 tags = data?.allTags ?? []; const tags = data?.allTags ?? [];
@@ -92,30 +151,27 @@ export const TagSelect: React.FC<IFilterProps> = (props) => {
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({ content: (<span>Created tag: <b>{tagName}</b></span>) }); Toast.success({ content: (<span>Created tag: <b>{tagName}</b></span>) });
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
}; };
const onChange = (selectedItems:ValueType<Option>) => { const onChange = (selectedItems:ValueType<Option>) => {
debugger; const selected = getSelectedValues(selectedItems);
const selected = (Array.isArray(selectedItems) ? selectedItems : [selectedItems])
.map(item => item.value);
setSelectedIds(selected); setSelectedIds(selected);
props.onSelect(tags.filter(item => selected.indexOf(item.id) !== -1)); props.onSelect(tags.filter(item => selected.indexOf(item.id) !== -1));
}; };
const selected = tags.filter(tag => selectedIds.indexOf(tag.id) !== -1).map(tag => ({value: tag.id, label: tag.name})); const selected = tags.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 items:Option[] = tags.map(item => ({ value: item.id, label: item.name }));
return <SelectComponent {...props} onChange={onChange} creatable={true} type="tags" return <SelectComponent {...props} onChange={onChange} creatable={true} type="tags" placeholder={placeholder}
isLoading={loading || dataLoading} items={items} onCreateOption={onCreate} selectedOptions={selected} /> isLoading={loading || dataLoading} items={items} onCreateOption={onCreate} selectedOptions={selected} />
} }
const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({ const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
type, type,
initialIds, initialIds,
noSelectionString,
onChange, onChange,
className, className,
items, items,
@@ -124,6 +180,8 @@ const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
onCreateOption, onCreateOption,
creatable = false, creatable = false,
isMulti = false, isMulti = false,
onInputChange,
placeholder
}) => { }) => {
const defaultValue = items.filter(item => initialIds?.indexOf(item.value) !== -1) ?? null; const defaultValue = items.filter(item => initialIds?.indexOf(item.value) !== -1) ?? null;
@@ -135,12 +193,19 @@ const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
isMulti: isMulti, isMulti: isMulti,
defaultValue: defaultValue, defaultValue: defaultValue,
noOptionsMessage: () => (type !== 'tags' ? 'None' : null), noOptionsMessage: () => (type !== 'tags' ? 'None' : null),
placeholder: noSelectionString ?? "(No selection)" placeholder: placeholder,
onInputChange
} }
return ( return (
creatable creatable
? <CreatableSelect {...props} isLoading={isLoading} isDisabled={isLoading} onCreateOption={onCreateOption} /> ? <CreatableSelect {...props} isLoading={isLoading} isDisabled={isLoading} onCreateOption={onCreateOption} />
: <Select {...props} isLoading={isLoading} /> : <Select {...props} isLoading={isLoading} />
); );
}; };
const getSelectedValues = (selectedItems:ValueType<Option>) => (
(Array.isArray(selectedItems) ? selectedItems : [selectedItems])
.map(item => item.value)
);

View File

@@ -1,9 +1,8 @@
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 "../../core/generated-graphql"; import { PerformerDataFragment, SceneMarkerDataFragment, TagDataFragment } from "src/core/generated-graphql";
import { NavigationUtils } from "../../utils/navigation"; import { NavUtils, TextUtils } from "src/utils";
import { TextUtils } from "../../utils/text";
interface IProps { interface IProps {
tag?: Partial<TagDataFragment>; tag?: Partial<TagDataFragment>;
@@ -14,18 +13,18 @@ interface IProps {
export const TagLink: React.FC<IProps> = (props: IProps) => { export const TagLink: React.FC<IProps> = (props: IProps) => {
let link: string = "#"; let link: string = "#";
let title: string = ""; let title: string = "";
if (!!props.tag) { if (props.tag) {
link = NavigationUtils.makeTagScenesUrl(props.tag); link = NavUtils.makeTagScenesUrl(props.tag);
title = props.tag.name || ""; title = props.tag.name || "";
} else if (!!props.performer) { } else if (props.performer) {
link = NavigationUtils.makePerformerScenesUrl(props.performer); link = NavUtils.makePerformerScenesUrl(props.performer);
title = props.performer.name || ""; title = props.performer.name || "";
} else if (!!props.marker) { } else if (props.marker) {
link = NavigationUtils.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" className="tag-item"
variant="secondary" variant="secondary"
> >

View File

@@ -0,0 +1,15 @@
export {
SceneGallerySelect,
ScrapePerformerSuggest,
MarkerTitleSuggest,
FilterSelect,
PerformerSelect,
StudioSelect,
TagSelect
} from './Select';
export { default as Icon } from './Icon';
export { default as Modal } from './Modal';
export { DetailsEditNavbar } from './DetailsEditNavbar';
export { DurationInput } from './DurationInput';
export { TagLink } from './TagLink';

View File

@@ -1,27 +1,27 @@
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 "../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
interface IProps { interface IProps {
studio: GQL.StudioDataFragment; studio: GQL.StudioDataFragment;
} }
export const StudioCard: React.FC<IProps> = (props: IProps) => { export const StudioCard: React.FC<IProps> = ({ studio }) => {
return ( return (
<Card <Card
className="col-4" className="col-4"
> >
<Link <Link
to={`/studios/${props.studio.id}`} to={`/studios/${studio.id}`}
className="studio previewable image" className="studio previewable image"
style={{backgroundImage: `url(${props.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">
{props.studio.name} {studio.name}
</h4> </h4>
<span>{props.studio.scene_count} scenes.</span> <span>{studio.scene_count} scenes.</span>
</div> </div>
</Card> </Card>
); );

View File

@@ -2,17 +2,16 @@ 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 "../../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "../../../core/StashService"; import { StashService } from "src/core/StashService";
import { ErrorUtils } from "../../../utils/errors"; import { ImageUtils, TableUtils } from "src/utils";
import { TableUtils } from "../../../utils/table"; import { DetailsEditNavbar } from "src/components/Shared";
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar"; import { useToast} from "src/hooks";
import { ToastUtils } from "../../../utils/toasts";
import { ImageUtils } from "../../../utils/image";
export const Studio: React.FC = () => { export const Studio: React.FC = () => {
const { id = '' } = useParams();
const history = useHistory(); const history = useHistory();
const Toast = useToast();
const { id = 'new' } = useParams();
const isNew = id === "new"; const isNew = id === "new";
// Editing state // Editing state
@@ -58,11 +57,13 @@ export const Studio: React.FC = () => {
setImage(this.result as string); setImage(this.result as string);
} }
ImageUtils.addPasteImageHook(onImageLoad); ImageUtils.usePasteImage(onImageLoad);
if (!isNew && !isEditing) { if (!isNew && !isEditing) {
if (!data || !data.findStudio || loading) { return <Spinner animation="border" variant="light" />; } if (!data?.findStudio || loading)
if (!!error) { return <>error...</>; } return <Spinner animation="border" variant="light" />;
if (error)
return <div>{error.message}</div>;
} }
function getStudioInput() { function getStudioInput() {
@@ -89,7 +90,7 @@ export const Studio: React.FC = () => {
history.push(`/studios/${result.data.studioCreate.id}`); history.push(`/studios/${result.data.studioCreate.id}`);
} }
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
} }
@@ -99,9 +100,9 @@ export const Studio: React.FC = () => {
} }
try { try {
await StashService.queryMetadataAutoTag({ studios: [studio.id]}); await StashService.queryMetadataAutoTag({ studios: [studio.id]});
ToastUtils.success("Started auto tagging"); Toast.success({ content: "Started auto tagging" });
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
} }
@@ -109,14 +110,14 @@ export const Studio: React.FC = () => {
try { try {
await deleteStudio(); await deleteStudio();
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
// redirect to studios page // redirect to studios page
history.push(`/studios`); history.push(`/studios`);
} }
function onImageChange(event: React.FormEvent<HTMLInputElement>) { function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad); ImageUtils.onImageChange(event, onImageLoad);
} }
@@ -135,7 +136,7 @@ export const Studio: React.FC = () => {
onSave={onSave} onSave={onSave}
onDelete={onDelete} onDelete={onDelete}
onAutoTag={onAutoTag} onAutoTag={onAutoTag}
onImageChange={onImageChange} onImageChange={onImageChangeHandler}
/> />
<h1> <h1>
{ !isEditing { !isEditing

View File

@@ -1,10 +1,10 @@
import React, { FunctionComponent } from "react"; import React, { FunctionComponent } from "react";
import { QueryHookResult } from "react-apollo-hooks"; import { QueryHookResult } from "react-apollo-hooks";
import { FindStudiosQuery, FindStudiosVariables } from "../../core/generated-graphql"; import { FindStudiosQuery, FindStudiosVariables } from "src/core/generated-graphql";
import { ListHook } from "../../hooks/ListHook"; import { ListHook } from "src/hooks";
import { IBaseProps } from "../../models/base-props"; import { IBaseProps } from "src/models/base-props";
import { ListFilterModel } from "../../models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode, FilterMode } from "../../models/list-filter/types"; import { DisplayMode, FilterMode } from "src/models/list-filter/types";
import { StudioCard } from "./StudioCard"; import { StudioCard } from "./StudioCard";
interface IProps extends IBaseProps {} interface IProps extends IBaseProps {}

View File

@@ -1,14 +1,14 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Form, Modal, Spinner } from 'react-bootstrap'; import { Button, Form, Spinner } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "../../core/StashService"; import { StashService } from "src/core/StashService";
import { ErrorUtils } from "../../utils/errors"; import { NavUtils } from "src/utils";
import { NavigationUtils } from "../../utils/navigation"; import { Icon, Modal } from 'src/components/Shared';
import { ToastUtils } from "../../utils/toasts"; import { useToast } from 'src/hooks';
export const TagList: React.FC = () => { export const TagList: React.FC = () => {
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<GQL.TagDataFragment> | null>(null);
@@ -21,7 +21,8 @@ export const TagList: React.FC = () => {
function getTagInput() { function getTagInput() {
const tagInput: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { name }; const tagInput: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { name };
if (!!editingTag) { (tagInput as Partial<GQL.TagUpdateInput>).id = editingTag.id; } if (editingTag)
(tagInput as Partial<GQL.TagUpdateInput>).id = editingTag.id;
return tagInput; return tagInput;
} }
@@ -37,59 +38,54 @@ export const TagList: React.FC = () => {
try { try {
if (editingTag && editingTag.id) { if (editingTag && editingTag.id) {
await updateTag(); await updateTag();
ToastUtils.success("Updated tag"); Toast.success({ content: "Updated tag" });
} else { } else {
await createTag(); await createTag();
ToastUtils.success("Created tag"); Toast.success({ content: "Created tag" });
} }
setEditingTag(null); setEditingTag(null);
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
} }
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]});
ToastUtils.success("Started auto tagging"); Toast.success({ content: "Started auto tagging" });
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
} }
async function onDelete() { async function onDelete() {
try { try {
await deleteTag(); await deleteTag();
ToastUtils.success("Deleted tag"); Toast.success({ content: "Deleted tag" });
setDeletingTag(null); setDeletingTag(null);
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
} }
const deleteAlert = ( const deleteAlert = (
<Modal <Modal
onHide={() => {}} onHide={() => {}}
show={!!deletingTag} show={!!deletingTag}
icon="trash-alt"
accept={{ onClick: onDelete, variant: 'danger', text: 'Delete' }}
cancel={{ onClick: () => setDeletingTag(null) }}
> >
<Modal.Body> <span>Are you sure you want to delete {deletingTag && deletingTag.name}?</span>
<FontAwesomeIcon icon="trash-alt" color="danger" />
<span>Are you sure you want to delete {deletingTag && deletingTag.name}?</span>
</Modal.Body>
<Modal.Footer>
<div>
<Button variant="danger" onClick={onDelete}>Delete</Button>
<Button onClick={() => setDeletingTag(null)}>Cancel</Button>
</div>
</Modal.Footer>
</Modal> </Modal>
); );
if (!data || !data.allTags) { return <Spinner animation="border" variant="light" />; } if (!data?.allTags)
if (!!error) { return <>{error.message}</>; } return <Spinner animation="border" variant="light" />;
if (error)
return <div>{error.message}</div>;
const tagElements = data.allTags.map((tag) => { const tagElements = data.allTags.map((tag) => {
return ( return (
@@ -99,13 +95,13 @@ export const TagList: React.FC = () => {
<span onClick={() => setEditingTag(tag)}>{tag.name}</span> <span onClick={() => setEditingTag(tag)}>{tag.name}</span>
<div style={{float: "right"}}> <div style={{float: "right"}}>
<Button onClick={() => onAutoTag(tag)}>Auto Tag</Button> <Button onClick={() => onAutoTag(tag)}>Auto Tag</Button>
<Link to={NavigationUtils.makeTagScenesUrl(tag)}>Scenes: {tag.scene_count}</Link> <Link to={NavUtils.makeTagScenesUrl(tag)}>Scenes: {tag.scene_count}</Link>
<Link to={NavigationUtils.makeTagSceneMarkersUrl(tag)}> <Link to={NavUtils.makeTagSceneMarkersUrl(tag)}>
Markers: {tag.scene_marker_count} Markers: {tag.scene_marker_count}
</Link> </Link>
<span>Total: {(tag.scene_count || 0) + (tag.scene_marker_count || 0)}</span> <span>Total: {(tag.scene_count || 0) + (tag.scene_marker_count || 0)}</span>
<Button variant="danger" onClick={() => setDeletingTag(tag)}> <Button variant="danger" onClick={() => setDeletingTag(tag)}>
<FontAwesomeIcon icon="trash-alt" color="danger" /> <Icon icon="trash-alt" color="danger" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -118,25 +114,20 @@ export const TagList: React.FC = () => {
<Button variant="primary" style={{marginTop: "20px"}} onClick={() => setEditingTag({})}>New Tag</Button> <Button variant="primary" style={{marginTop: "20px"}} onClick={() => setEditingTag({})}>New Tag</Button>
<Modal <Modal
onHide={() => {setEditingTag(null)}} show={!!editingTag}
show={!!editingTag} header={editingTag && editingTag.id ? "Edit Tag" : "New Tag"}
onHide={() => setEditingTag(null)}
accept={{ onClick: onEdit, variant: 'danger', text: (editingTag?.id ? 'Update' : 'Create') }}
> >
<Modal.Header> <Form.Group controlId="tag-name">
{ editingTag && editingTag.id ? "Edit Tag" : "New Tag" } <Form.Label>Name</Form.Label>
</Modal.Header> <Form.Control
<Modal.Body> onChange={(newValue: any) => setName(newValue.target.value)}
<Form.Group controlId="tag-name"> defaultValue={(editingTag && editingTag.name) || ''}
<Form.Label>Name</Form.Label> />
<Form.Control </Form.Group>
onChange={(newValue: any) => setName(newValue.target.value)}
defaultValue={(editingTag && editingTag.name) || ''}
/>
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button onClick={() => onEdit()}>{editingTag && editingTag.id ? "Update" : "Create"}</Button>
</Modal.Footer>
</Modal> </Modal>
{tagElements} {tagElements}
</div> </div>
); );

View File

@@ -1,11 +1,10 @@
import _ from "lodash"; import _ from "lodash";
import React, { FunctionComponent, useRef, useState, useEffect } from "react"; import React, { FunctionComponent, useRef, useState, useEffect } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { VideoHoverHook } from "../../hooks/VideoHover"; import { StashService } from "src/core/StashService";
import { TextUtils } from "../../utils/text"; import { VideoHoverHook } from "src/hooks";
import { NavigationUtils } from "../../utils/navigation"; import { TextUtils, NavUtils } from "src/utils";
import { StashService } from "../../core/StashService";
interface IWallItemProps { interface IWallItemProps {
scene?: GQL.SlimSceneDataFragment; scene?: GQL.SlimSceneDataFragment;
@@ -55,31 +54,31 @@ export const WallItem: FunctionComponent<IWallItemProps> = (props: IWallItemProp
} }
let linkSrc: string = "#"; let linkSrc: string = "#";
if (props.clickHandler === undefined) { if (props.clickHandler) {
if (props.scene !== undefined) { if (props.scene) {
linkSrc = `/scenes/${props.scene.id}`; linkSrc = `/scenes/${props.scene.id}`;
} else if (props.sceneMarker !== undefined) { } else if (props.sceneMarker) {
linkSrc = NavigationUtils.makeSceneMarkerUrl(props.sceneMarker); linkSrc = NavUtils.makeSceneMarkerUrl(props.sceneMarker);
} }
} }
function onTransitionEnd(event: React.TransitionEvent<HTMLDivElement>) { function onTransitionEnd(event: React.TransitionEvent<HTMLDivElement>) {
const target = (event.target as any); const target = event.currentTarget;
if (target.classList.contains("double-scale")) { if (target.classList.contains("double-scale") && target.parentElement) {
target.parentElement.style.zIndex = 10; target.parentElement.style.zIndex = '10';
} else { } else if(target.parentElement) {
target.parentElement.style.zIndex = null; 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(`${props.sceneMarker!.title} - ${TextUtils.secondsToTimestamp(props.sceneMarker.seconds)}`);
const thisTags = props.sceneMarker.tags.map((tag) => (<span key={tag.id}>{tag.name}</span>)); 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>); 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 || "");
setScreenshotPath(props.scene.paths.screenshot || ""); setScreenshotPath(props.scene.paths.screenshot || "");
setTitle(props.scene.title || ""); setTitle(props.scene.title || "");
@@ -123,7 +122,7 @@ export const WallItem: FunctionComponent<IWallItemProps> = (props: IWallItemProp
{title} {title}
</div> </div>
{tags} {tags}
</div> : undefined </div> : ''
} }
</Link> </Link>
</div> </div>

View File

@@ -1,7 +1,7 @@
import React, { FunctionComponent, useState } from "react"; import React, { FunctionComponent, useState } from "react";
import * as GQL from "../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import "./Wall.scss";
import { WallItem } from "./WallItem"; import { WallItem } from "./WallItem";
import "./Wall.scss";
interface IWallPanelProps { interface IWallPanelProps {
scenes?: GQL.SlimSceneDataFragment[]; scenes?: GQL.SlimSceneDataFragment[];

View File

@@ -1,16 +1,16 @@
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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Icon } from 'src/components/Shared';
import { CriterionModifier } from "../../core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { Criterion, CriterionType } from "../../models/list-filter/criteria/criterion"; import { Criterion, CriterionType } from "src/models/list-filter/criteria/criterion";
import { NoneCriterion } from "../../models/list-filter/criteria/none"; import { NoneCriterion } from "src/models/list-filter/criteria/none";
import { PerformersCriterion } from "../../models/list-filter/criteria/performers"; import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
import { StudiosCriterion } from "../../models/list-filter/criteria/studios"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
import { TagsCriterion } from "../../models/list-filter/criteria/tags"; import { TagsCriterion } from "src/models/list-filter/criteria/tags";
import { makeCriteria } from "../../models/list-filter/criteria/utils"; import { makeCriteria } from "src/models/list-filter/criteria/utils";
import { ListFilterModel } from "../../models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { FilterSelect } from "../select/FilterSelect"; import { FilterSelect } from "src/components/Shared";
interface IAddFilterProps { interface IAddFilterProps {
onAddCriterion: (criterion: Criterion, oldId?: string) => void; onAddCriterion: (criterion: Criterion, oldId?: string) => void;
@@ -65,8 +65,8 @@ 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;
} else if (value === undefined) { } else if (value === undefined) {
@@ -174,7 +174,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (props: IAddFilterProps) =>
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}>
@@ -193,14 +193,14 @@ 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()} onClick={() => onToggle()}
active={isOpen} active={isOpen}
> >
<FontAwesomeIcon icon="filter" /> <Icon icon="filter" />
</Button> </Button>
</OverlayTrigger> </OverlayTrigger>
<Modal <Modal
show={isOpen} show={isOpen}
onHide={() => onToggle()}> onHide={() => onToggle()}>

View File

@@ -1,11 +1,11 @@
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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Criterion } from "../../models/list-filter/criteria/criterion"; import { Icon } from 'src/components/Shared';
import { ListFilterModel } from "../../models/list-filter/filter"; import { Criterion } from "src/models/list-filter/criteria/criterion";
import { DisplayMode } from "../../models/list-filter/types"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { AddFilter } from "./AddFilter"; import { AddFilter } from "./AddFilter";
interface IListFilterOperation { interface IListFilterOperation {
@@ -114,7 +114,7 @@ export const ListFilter: React.FC<IListFilterProps> = (props: IListFilterProps)
active={props.filter.displayMode === option} active={props.filter.displayMode === option}
onClick={() => onChangeDisplayMode(option)} onClick={() => onChangeDisplayMode(option)}
> >
<FontAwesomeIcon icon={getIcon(option)} /> <Icon icon={getIcon(option)} />
</Button> </Button>
</OverlayTrigger> </OverlayTrigger>
)); ));
@@ -129,7 +129,7 @@ export const ListFilter: React.FC<IListFilterProps> = (props: IListFilterProps)
> >
{criterion.getLabel()} {criterion.getLabel()}
<Button onClick={() => onRemoveCriterionTag(criterion)}> <Button onClick={() => onRemoveCriterionTag(criterion)}>
<FontAwesomeIcon icon="times" /> <Icon icon="times" />
</Button> </Button>
</Badge> </Badge>
)); ));
@@ -176,7 +176,7 @@ export const ListFilter: React.FC<IListFilterProps> = (props: IListFilterProps)
<Dropdown> <Dropdown>
<Dropdown.Toggle variant="secondary" id="more-menu"> <Dropdown.Toggle variant="secondary" id="more-menu">
<Button> <Button>
<FontAwesomeIcon icon="ellipsis-h" /> <Icon icon="ellipsis-h" />
</Button> </Button>
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu> <Dropdown.Menu>
@@ -191,7 +191,7 @@ export const ListFilter: React.FC<IListFilterProps> = (props: IListFilterProps)
if (props.onChangeZoom) { if (props.onChangeZoom) {
props.onChangeZoom(v); props.onChangeZoom(v);
} }
} }
function maybeRenderZoom() { function maybeRenderZoom() {
if (props.onChangeZoom) { if (props.onChangeZoom) {
@@ -240,7 +240,7 @@ export const ListFilter: React.FC<IListFilterProps> = (props: IListFilterProps)
<Tooltip id="sort-direction-tooltip">{props.filter.sortDirection === "asc" ? "Ascending" : "Descending"}</Tooltip> <Tooltip id="sort-direction-tooltip">{props.filter.sortDirection === "asc" ? "Ascending" : "Descending"}</Tooltip>
}> }>
<Button onClick={onChangeSortDirection}> <Button onClick={onChangeSortDirection}>
<FontAwesomeIcon 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>

View File

@@ -1,9 +1,8 @@
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 "../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "../../utils/text"; import { NavUtils, TextUtils } from "src/utils";
import { NavigationUtils } from "../../utils/navigation";
interface IPerformerCardProps { interface IPerformerCardProps {
performer: GQL.PerformerDataFragment; performer: GQL.PerformerDataFragment;
@@ -12,7 +11,7 @@ interface IPerformerCardProps {
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; }
@@ -37,7 +36,7 @@ export const PerformerCard: React.FC<IPerformerCardProps> = (props: IPerformerCa
{props.performer.name} {props.performer.name}
</h4> </h4>
{age !== 0 ? <div>{ageString}</div> : ''} {age !== 0 ? <div>{ageString}</div> : ''}
<span>Stars in {props.performer.scene_count} <Link to={NavigationUtils.makePerformerScenesUrl(props.performer)}>scenes</Link>. <span>Stars in {props.performer.scene_count} <Link to={NavUtils.makePerformerScenesUrl(props.performer)}>scenes</Link>.
</span> </span>
</div> </div>
</Card> </Card>

View File

@@ -1,22 +1,18 @@
import _ from "lodash";
import { Button, Form, Modal, Spinner, Table } from 'react-bootstrap';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import * as GQL from "../../../core/generated-graphql"; import { Button, Form, Spinner, Table } from 'react-bootstrap';
import { StashService } from "../../../core/StashService"; import _ from "lodash";
import { IBaseProps } from "../../../models"; import { useParams, useHistory } from 'react-router-dom';
import { ErrorUtils } from "../../../utils/errors"; import * as GQL from "src/core/generated-graphql";
import { TableUtils } from "../../../utils/table"; import { StashService } from "src/core/StashService";
import { ScrapePerformerSuggest } from "../../select/ScrapePerformerSuggest"; import { DetailsEditNavbar, Icon, Modal, ScrapePerformerSuggest } from "src/components/Shared";
import { DetailsEditNavbar } from "../../Shared/DetailsEditNavbar"; import { ImageUtils, TableUtils } from 'src/utils'
import { ToastUtils } from "../../../utils/toasts"; import { useToast } from 'src/hooks';
import { EditableTextUtils } from "../../../utils/editabletext";
import { ImageUtils } from "../../../utils/image";
interface IPerformerProps extends IBaseProps {} export const Performer: React.FC = () => {
const Toast = useToast();
export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) => { const history = useHistory();
const isNew = props.match.params.id === "new"; const { id = 'new' } = useParams();
const isNew = id === "new";
// Editing state // Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew); const [isEditing, setIsEditing] = useState<boolean>(isNew);
@@ -52,7 +48,7 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
const Scrapers = StashService.useListPerformerScrapers(); const Scrapers = StashService.useListPerformerScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.ListPerformerScrapersListPerformerScrapers[]>([]); const [queryableScrapers, setQueryableScrapers] = useState<GQL.ListPerformerScrapersListPerformerScrapers[]>([]);
const { data, error, loading } = StashService.useFindPerformer(props.match.params.id); const { data, error, loading } = StashService.useFindPerformer(id);
const updatePerformer = StashService.usePerformerUpdate(getPerformerInput() as GQL.PerformerUpdateInput); const updatePerformer = StashService.usePerformerUpdate(getPerformerInput() as GQL.PerformerUpdateInput);
const createPerformer = StashService.usePerformerCreate(getPerformerInput() as GQL.PerformerCreateInput); const createPerformer = StashService.usePerformerCreate(getPerformerInput() as GQL.PerformerCreateInput);
const deletePerformer = StashService.usePerformerDestroy(getPerformerInput() as GQL.PerformerDestroyInput); const deletePerformer = StashService.usePerformerDestroy(getPerformerInput() as GQL.PerformerDestroyInput);
@@ -80,7 +76,8 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
useEffect(() => { useEffect(() => {
setIsLoading(loading); setIsLoading(loading);
if (!data || !data.findPerformer || !!error) { return; } if (!data || !data.findPerformer || error)
return;
setPerformer(data.findPerformer); setPerformer(data.findPerformer);
}, [data]); }, [data]);
@@ -98,8 +95,8 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
setImage(this.result as string); setImage(this.result as string);
} }
ImageUtils.addPasteImageHook(onImageLoad); ImageUtils.usePasteImage(onImageLoad);
useEffect(() => { useEffect(() => {
var newQueryableScrapers : GQL.ListPerformerScrapersListPerformerScrapers[] = []; var newQueryableScrapers : GQL.ListPerformerScrapersListPerformerScrapers[] = [];
@@ -113,10 +110,10 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
}, [Scrapers.data]); }, [Scrapers.data]);
if ((!isNew && !isEditing && (!data || !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 <>error...</>; } return <div>{error.message}</div>;
function getPerformerInput() { function getPerformerInput() {
const performerInput: Partial<GQL.PerformerCreateInput | GQL.PerformerUpdateInput> = { const performerInput: Partial<GQL.PerformerCreateInput | GQL.PerformerUpdateInput> = {
@@ -140,7 +137,7 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
}; };
if (!isNew) { if (!isNew) {
(performerInput as GQL.PerformerUpdateInput).id = props.match.params.id; (performerInput as GQL.PerformerUpdateInput).id = id;
} }
return performerInput; return performerInput;
} }
@@ -154,10 +151,10 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
} else { } else {
const result = await createPerformer(); const result = await createPerformer();
setPerformer(result.data.performerCreate); setPerformer(result.data.performerCreate);
props.history.push(`/performers/${result.data.performerCreate.id}`); history.push(`/performers/${result.data.performerCreate.id}`);
} }
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
setIsLoading(false); setIsLoading(false);
} }
@@ -167,12 +164,12 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
try { try {
await deletePerformer(); await deletePerformer();
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
setIsLoading(false); setIsLoading(false);
// redirect to performers page // redirect to performers page
props.history.push(`/performers`); history.push('/performers');
} }
async function onAutoTag() { async function onAutoTag() {
@@ -181,13 +178,13 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
} }
try { try {
await StashService.queryMetadataAutoTag({ performers: [performer.id]}); await StashService.queryMetadataAutoTag({ performers: [performer.id]});
ToastUtils.success("Started auto tagging"); Toast.success({ content: "Started auto tagging" });
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
} }
function onImageChange(event: React.FormEvent<HTMLInputElement>) { function onImageChangeHandler(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad); ImageUtils.onImageChange(event, onImageLoad);
} }
@@ -196,38 +193,39 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
} }
function getQueryScraperPerformerInput() { function getQueryScraperPerformerInput() {
if (!scrapePerformerDetails) { if (!scrapePerformerDetails)
return {}; return {};
}
let ret = _.clone(scrapePerformerDetails); const { __typename, ...ret } = scrapePerformerDetails;
delete ret.__typename; return ret;
return ret as GQL.ScrapedPerformerInput;
} }
async function onScrapePerformer() { async function onScrapePerformer() {
setIsDisplayingScraperDialog(undefined); setIsDisplayingScraperDialog(undefined);
setIsLoading(true); setIsLoading(true);
try { try {
if (!scrapePerformerDetails || !isDisplayingScraperDialog) { return; } if (!scrapePerformerDetails || !isDisplayingScraperDialog)
return;
const result = await StashService.queryScrapePerformer(isDisplayingScraperDialog.id, getQueryScraperPerformerInput()); const result = await StashService.queryScrapePerformer(isDisplayingScraperDialog.id, getQueryScraperPerformerInput());
if (!result.data || !result.data.scrapePerformer) { return; } if (!result?.data?.scrapePerformer)
return;
updatePerformerEditState(result.data.scrapePerformer); updatePerformerEditState(result.data.scrapePerformer);
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
setIsLoading(false); setIsLoading(false);
} }
async function onScrapePerformerURL() { async function onScrapePerformerURL() {
if (!url) { return; } if (!url)
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) {
ErrorUtils.handle(e); Toast.error(e);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -248,31 +246,24 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
<Modal <Modal
show={!!isDisplayingScraperDialog} show={!!isDisplayingScraperDialog}
onHide={() => setIsDisplayingScraperDialog(undefined)} onHide={() => setIsDisplayingScraperDialog(undefined)}
header="Scrape"
accept={{ onClick: onScrapePerformer, text: "Scrape" }}
> >
<Modal.Header> <div className="dialog-content">
Scrape <ScrapePerformerSuggest
</Modal.Header> placeholder="Performer name"
<Modal.Body> scraperId={isDisplayingScraperDialog ? isDisplayingScraperDialog.id : ""}
<div className="dialog-content"> onSelectPerformer={(query) => setScrapePerformerDetails(query)}
<ScrapePerformerSuggest />
placeholder="Performer name" </div>
style={{width: "100%"}}
scraperId={isDisplayingScraperDialog ? isDisplayingScraperDialog.id : ""}
onSelectPerformer={(query) => setScrapePerformerDetails(query)}
/>
</div>
</Modal.Body>
<Modal.Footer>
<Button onClick={() => onScrapePerformer()}>Scrape</Button>
</Modal.Footer>
</Modal> </Modal>
); );
} }
function urlScrapable(url: string) : boolean { function urlScrapable(url: string) {
return !!url && !!Scrapers.data && Scrapers.data.listPerformerScrapers && Scrapers.data.listPerformerScrapers.some((s) => { return !!url && (Scrapers?.data?.listPerformerScrapers ?? []).some(s => (
return !!s.performer && !!s.performer.urls && s.performer.urls.some((u) => { return url.includes(u); }); (s?.performer?.urls ?? []).some(u => url.includes(u))
}); ));
} }
function maybeRenderScrapeButton() { function maybeRenderScrapeButton() {
@@ -280,10 +271,10 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
return undefined; return undefined;
} }
return ( return (
<Button <Button
id="scrape-url-button" id="scrape-url-button"
onClick={() => onScrapePerformerURL()}> onClick={() => onScrapePerformerURL()}>
<FontAwesomeIcon icon="file-upload" /> <Icon icon="file-upload" />
</Button> </Button>
) )
} }
@@ -292,13 +283,17 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
return ( return (
<tr> <tr>
<td id="url-field"> <td id="url-field">
URL URL
{maybeRenderScrapeButton()} {maybeRenderScrapeButton()}
</td> </td>
<td> <td>
{EditableTextUtils.renderInputGroup({ <Form.Control
value: url, isEditing, onChange: setUrl, placeholder: "URL" value={url}
})} readOnly={!isEditing}
plaintext={!isEditing}
placeholder="URL"
onChange={(event: React.FormEvent<HTMLInputElement>) => setUrl(event.currentTarget.value) }
/>
</td> </td>
</tr> </tr>
); );
@@ -319,26 +314,31 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
onToggleEdit={() => { setIsEditing(!isEditing); updatePerformerEditState(performer); }} onToggleEdit={() => { setIsEditing(!isEditing); updatePerformerEditState(performer); }}
onSave={onSave} onSave={onSave}
onDelete={onDelete} onDelete={onDelete}
onImageChange={onImageChange} onImageChange={onImageChangeHandler}
scrapers={queryableScrapers} scrapers={queryableScrapers}
onDisplayScraperDialog={onDisplayFreeOnesDialog} onDisplayScraperDialog={onDisplayFreeOnesDialog}
onAutoTag={onAutoTag} onAutoTag={onAutoTag}
/> />
<h1> <h1>
{ !isEditing { <Form.Control
? <span>{name}</span> readOnly={!isEditing}
: <Form.Control 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">
<Form.Label>Aliases:</Form.Label> <Form.Label>Aliases:</Form.Label>
{EditableTextUtils.renderInputGroup({ <Form.Control
value: aliases, isEditing: isEditing, placeholder: "Aliases", onChange: setAliases value={aliases}
})} readOnly={!isEditing}
plaintext={!isEditing}
placeholder="Aliases"
onChange={(event: React.FormEvent<HTMLInputElement>) => setAliases(event.currentTarget.value) }
/>
</Form.Group> </Form.Group>
</h6> </h6>
<div> <div>
@@ -348,7 +348,7 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
className={favorite ? "favorite" : undefined} className={favorite ? "favorite" : undefined}
onClick={() => setFavorite(!favorite)} onClick={() => setFavorite(!favorite)}
> >
<FontAwesomeIcon icon="heart" /> <Icon icon="heart" />
</Button> </Button>
</div> </div>

View File

@@ -1,25 +1,25 @@
import _ from "lodash"; import _ from "lodash";
import React, { FunctionComponent } from "react"; import React from "react";
import { QueryHookResult } from "react-apollo-hooks"; import { QueryHookResult } from "react-apollo-hooks";
import { FindPerformersQuery, FindPerformersVariables } from "../../core/generated-graphql"; import { FindPerformersQuery, FindPerformersVariables } from "src/core/generated-graphql";
import { ListHook } from "../../hooks/ListHook"; import { StashService } from "src/core/StashService";
import { IBaseProps } from "../../models/base-props"; import { ListHook } from "src/hooks";
import { ListFilterModel } from "../../models/list-filter/filter"; import { IBaseProps } from "src/models/base-props";
import { DisplayMode, FilterMode } from "../../models/list-filter/types"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode, FilterMode } from "src/models/list-filter/types";
import { PerformerCard } from "./PerformerCard"; import { PerformerCard } from "./PerformerCard";
import { PerformerListTable } from "./PerformerListTable"; import { PerformerListTable } from "./PerformerListTable";
import { StashService } from "../../core/StashService";
interface IPerformerListProps extends IBaseProps {} interface IPerformerListProps extends IBaseProps {}
export const PerformerList: FunctionComponent<IPerformerListProps> = (props: IPerformerListProps) => { export const PerformerList: React.FC<IPerformerListProps> = (props: IPerformerListProps) => {
const otherOperations = [ const otherOperations = [
{ {
text: "Open Random", text: "Open Random",
onClick: getRandom, onClick: getRandom,
} }
]; ];
const listData = ListHook.useList({ const listData = ListHook.useList({
filterMode: FilterMode.Performers, filterMode: FilterMode.Performers,
props, props,

View File

@@ -1,21 +1,21 @@
import React from "react"; import React from "react";
import { Button, Table } from 'react-bootstrap'; import { Button, Table } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { NavigationUtils } from "../../utils/navigation"; import { Icon } from 'src/components/Shared';
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) { function maybeRenderFavoriteHeart(performer : GQL.PerformerDataFragment) {
if (!performer.favorite) { return; } if (!performer.favorite) { return; }
return ( return (
<Button disabled className="favorite"> <Button disabled className="favorite">
<FontAwesomeIcon icon="heart" /> <Icon icon="heart" />
</Button> </Button>
); );
} }
@@ -31,7 +31,7 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (props: IP
}; };
return ( return (
<Link <Link
className="performer-list-thumbnail" className="performer-list-thumbnail"
to={`/performers/${performer.id}`} to={`/performers/${performer.id}`}
style={style}/> style={style}/>
@@ -59,7 +59,7 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (props: IP
{maybeRenderFavoriteHeart(performer)} {maybeRenderFavoriteHeart(performer)}
</td> </td>
<td> <td>
<Link to={NavigationUtils.makePerformerScenesUrl(performer)}> <Link to={NavUtils.makePerformerScenesUrl(performer)}>
<h6>{performer.scene_count}</h6> <h6>{performer.scene_count}</h6>
</Link> </Link>
</td> </td>
@@ -97,4 +97,4 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (props: IP
</> </>
); );
}; };

View File

@@ -1,14 +1,12 @@
import { Button, ButtonGroup, Card, Form, Popover, OverlayTrigger } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, ButtonGroup, Card, Form, Popover, OverlayTrigger } from 'react-bootstrap';
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql"; import cx from 'classnames';
import { VideoHoverHook } from "../../hooks/VideoHover"; import * as GQL from "src/core/generated-graphql";
import { ColorUtils } from "../../utils/color"; import { StashService } from "src/core/StashService";
import { TextUtils } from "../../utils/text"; import { VideoHoverHook } from "src/hooks";
import { TagLink } from "../Shared/TagLink"; import { Icon, TagLink } from 'src/components/Shared';
import { ZoomUtils } from "../../utils/zoom"; import { TextUtils } from "src/utils";
import { StashService } from "../../core/StashService";
interface ISceneCardProps { interface ISceneCardProps {
scene: GQL.SlimSceneDataFragment; scene: GQL.SlimSceneDataFragment;
@@ -20,14 +18,14 @@ interface ISceneCardProps {
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 ${ColorUtils.classForRating(props.scene.rating)}`}> <div className={`rating-banner ${props.scene.rating ? `rating-${props.scene.rating}` : '' }`}>
RATING: {props.scene.rating} RATING: {props.scene.rating}
</div> </div>
); );
@@ -36,23 +34,21 @@ 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> : undefined} {props.scene.file.height ? <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) : ""} {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 = "";
if (showStudioAsText) { if (showStudioAsText) {
style = {}; style = {};
text = props.scene.studio.name; text = props.scene.studio.name;
@@ -71,7 +67,8 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
} }
function maybeRenderTagPopoverButton() { function maybeRenderTagPopoverButton() {
if (props.scene.tags.length <= 0) { return; } if (props.scene.tags.length <= 0)
return;
const popover = ( const popover = (
<Popover id="tag-popover"> <Popover id="tag-popover">
@@ -84,7 +81,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
return ( return (
<OverlayTrigger trigger="hover" placement="bottom" overlay={popover}> <OverlayTrigger trigger="hover" placement="bottom" overlay={popover}>
<Button> <Button>
<FontAwesomeIcon icon="tag" /> <Icon icon="tag" />
{props.scene.tags.length} {props.scene.tags.length}
</Button> </Button>
</OverlayTrigger> </OverlayTrigger>
@@ -92,7 +89,8 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
} }
function maybeRenderPerformerPopoverButton() { function maybeRenderPerformerPopoverButton() {
if (props.scene.performers.length <= 0) { return; } if (props.scene.performers.length <= 0)
return;
const popover = ( const popover = (
<Popover id="performer-popover"> <Popover id="performer-popover">
@@ -116,7 +114,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
return ( return (
<OverlayTrigger trigger="hover" placement="bottom" overlay={popover}> <OverlayTrigger trigger="hover" placement="bottom" overlay={popover}>
<Button> <Button>
<FontAwesomeIcon icon="user" /> <Icon icon="user" />
{props.scene.performers.length} {props.scene.performers.length}
</Button> </Button>
</OverlayTrigger> </OverlayTrigger>
@@ -140,7 +138,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
return ( return (
<OverlayTrigger trigger="hover" placement="bottom" overlay={popover}> <OverlayTrigger trigger="hover" placement="bottom" overlay={popover}>
<Button> <Button>
<FontAwesomeIcon icon="tag" /> <Icon icon="tag" />
{props.scene.scene_markers.length} {props.scene.scene_markers.length}
</Button> </Button>
</OverlayTrigger> </OverlayTrigger>
@@ -182,31 +180,11 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
return height > width; return height > width;
} }
function getLinkClassName() {
let ret = "image previewable";
if (isPortrait()) {
ret += " portrait";
}
return ret;
}
function getVideoClassName() {
let ret = "preview";
if (isPortrait()) {
ret += " portrait";
}
return ret;
}
var shiftKey = false; var shiftKey = false;
return ( return (
<Card <Card
className={"col-4 " + ZoomUtils.classForZoom(props.zoomIndex)} className={`col-4 zoom-${props.zoomIndex}`}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
> >
@@ -217,13 +195,18 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
onChange={() => props.onSelectedChanged(!props.selected, shiftKey)} onChange={() => props.onSelectedChanged(!props.selected, shiftKey)}
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => { shiftKey = event.shiftKey; event.stopPropagation(); } } onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => { shiftKey = event.shiftKey; event.stopPropagation(); } }
/> />
<Link to={`/scenes/${props.scene.id}`} className={getLinkClassName()}> <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 className={getVideoClassName()} loop={true} poster={props.scene.paths.screenshot || ""} ref={videoHoverHook.videoEl}> <video
{!!previewPath ? <source src={previewPath} /> : ""} loop
className={cx('preview', {portrait: isPortrait()})}
poster={props.scene.paths.screenshot || ""}
ref={videoHoverHook.videoEl}
>
{previewPath ? <source src={previewPath} /> : ""}
</video> </video>
</div> </div>
</Link> </Link>

View File

@@ -1,10 +1,10 @@
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, { FunctionComponent, useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import * as GQL from "../../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "../../../core/StashService"; import { StashService } from "src/core/StashService";
import { IBaseProps } from "../../../models"; import { IBaseProps } from "src/models";
import { GalleryViewer } from "../../Galleries/GalleryViewer"; import { GalleryViewer } from "src/components/Galleries/GalleryViewer";
import { ScenePlayer } from "../ScenePlayer/ScenePlayer"; import { ScenePlayer } from "../ScenePlayer/ScenePlayer";
import { SceneDetailPanel } from "./SceneDetailPanel"; import { SceneDetailPanel } from "./SceneDetailPanel";
import { SceneEditPanel } from "./SceneEditPanel"; import { SceneEditPanel } from "./SceneEditPanel";
@@ -14,18 +14,15 @@ import { ScenePerformerPanel } from "./ScenePerformerPanel";
interface ISceneProps extends IBaseProps {} interface ISceneProps extends IBaseProps {}
export const Scene: FunctionComponent<ISceneProps> = (props: ISceneProps) => { export const Scene: React.FC<ISceneProps> = (props: ISceneProps) => {
const [timestamp, setTimestamp] = useState<number>(0); const [timestamp, setTimestamp] = useState<number>(0);
const [autoplay, setAutoplay] = useState<boolean>(false); const [autoplay, setAutoplay] = useState<boolean>(false);
const [scene, setScene] = useState<Partial<GQL.SceneDataFragment>>({}); const [scene, setScene] = useState<Partial<GQL.SceneDataFragment>>({});
const [isLoading, setIsLoading] = useState(false);
const { data, error, loading } = StashService.useFindScene(props.match.params.id); const { data, error, loading } = StashService.useFindScene(props.match.params.id);
useEffect(() => { useEffect(() => (
setIsLoading(loading); setScene(data?.findScene ?? {})
if (!data || !data.findScene || !!error) { return; } ), [data]);
setScene(StashService.nullToUndefined(data.findScene));
}, [data]);
useEffect(() => { useEffect(() => {
const queryParams = queryString.parse(props.location.search); const queryParams = queryString.parse(props.location.search);
@@ -42,12 +39,15 @@ export const Scene: FunctionComponent<ISceneProps> = (props: ISceneProps) => {
setTimestamp(marker.seconds); setTimestamp(marker.seconds);
} }
if (!data || !data.findScene || isLoading || Object.keys(scene).length === 0) { if (!data?.findScene || loading || Object.keys(scene).length === 0) {
return <Spinner animation="border"/>; return <Spinner animation="border"/>;
} }
if (error)
return <div>{error.message}</div>
const modifiedScene = const modifiedScene =
Object.assign({scene_marker_tags: data.sceneMarkerTags}, scene) as GQL.SceneDataFragment; // TODO Hack from angular Object.assign({scene_marker_tags: data.sceneMarkerTags}, scene) as GQL.SceneDataFragment; // TODO Hack from angular
if (!!error) { return <>error...</>; }
return ( return (
<> <>
@@ -82,9 +82,9 @@ export const Scene: FunctionComponent<ISceneProps> = (props: ISceneProps) => {
<Tab <Tab
eventKey="scene-edit-panel" eventKey="scene-edit-panel"
title="Edit"> title="Edit">
<SceneEditPanel <SceneEditPanel
scene={modifiedScene} scene={modifiedScene}
onUpdate={(newScene) => setScene(newScene)} onUpdate={(newScene) => setScene(newScene)}
onDelete={() => props.history.push("/scenes")} onDelete={() => props.history.push("/scenes")}
/> />
</Tab> </Tab>

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import * as GQL from "../../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "../../../utils/text"; import { TextUtils } from "src/utils";
import { TagLink } from "../../Shared/TagLink"; import { TagLink } from "src/components/Shared";
import { SceneHelpers } from "../helpers"; import { SceneHelpers } from "../helpers";
interface ISceneDetailProps { interface ISceneDetailProps {
@@ -38,9 +38,9 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props: ISceneDetai
<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> : undefined} {props.scene.date ? <h4>{props.scene.date}</h4> : ''}
{!!props.scene.rating ? <h6>Rating: {props.scene.rating}</h6> : undefined} {props.scene.rating ? <h6>Rating: {props.scene.rating}</h6> : ''}
{!!props.scene.file.height ? <h6>Resolution: {TextUtils.resolution(props.scene.file.height)}</h6> : undefined} {props.scene.file.height ? <h6>Resolution: {TextUtils.resolution(props.scene.file.height)}</h6> : ''}
{renderDetails()} {renderDetails()}
{renderTags()} {renderTags()}
</> </>

View File

@@ -1,13 +1,10 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import * as GQL from "../../../core/generated-graphql"; import { Collapse, Dropdown, DropdownButton, Form, Button, Spinner } from 'react-bootstrap';
import { StashService } from "../../../core/StashService"; import * as GQL from "src/core/generated-graphql";
import { ErrorUtils } from "../../../utils/errors"; import { StashService } from "src/core/StashService";
import { ToastUtils } from "../../../utils/toasts"; import { FilterSelect, StudioSelect, SceneGallerySelect, Modal, Icon } from "src/components/Shared";
import { FilterSelect, StudioSelect } from "../../select/FilterSelect"; import { useToast } from 'src/hooks';
import { ValidGalleriesSelect } from "../../select/ValidGalleriesSelect"; import { ImageUtils } from 'src/utils';
import { ImageUtils } from "../../../utils/image";
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Collapse, Dropdown, DropdownButton, Form, Button, Modal, Spinner } from 'react-bootstrap';
interface IProps { interface IProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
@@ -16,6 +13,7 @@ interface IProps {
} }
export const SceneEditPanel: React.FC<IProps> = (props: IProps) => { export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
const Toast = useToast();
const [title, setTitle] = useState<string | undefined>(undefined); const [title, setTitle] = useState<string | undefined>(undefined);
const [details, setDetails] = useState<string | undefined>(undefined); const [details, setDetails] = useState<string | undefined>(undefined);
const [url, setUrl] = useState<string | undefined>(undefined); const [url, setUrl] = useState<string | undefined>(undefined);
@@ -76,7 +74,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
setCoverImagePreview(props.scene.paths.screenshot); setCoverImagePreview(props.scene.paths.screenshot);
}, [props.scene]); }, [props.scene]);
ImageUtils.addPasteImageHook(onImageLoad); ImageUtils.usePasteImage(onImageLoad);
function getSceneInput(): GQL.SceneUpdateInput { function getSceneInput(): GQL.SceneUpdateInput {
return { return {
@@ -99,9 +97,9 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
try { try {
const result = await updateScene(); const result = await updateScene();
props.onUpdate(result.data.sceneUpdate); props.onUpdate(result.data.sceneUpdate);
ToastUtils.success("Updated scene"); Toast.success({ content: "Updated scene" });
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
setIsLoading(false); setIsLoading(false);
} }
@@ -119,9 +117,9 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
setIsLoading(true); setIsLoading(true);
try { try {
await deleteScene(); await deleteScene();
ToastUtils.success("Deleted scene"); Toast.success({ content: "Deleted scene" });
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
setIsLoading(false); setIsLoading(false);
@@ -148,30 +146,19 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
function renderDeleteAlert() { function renderDeleteAlert() {
return ( return (
<Modal <Modal
keyboard={false}
onHide={() => {}}
show={isDeleteAlertOpen} show={isDeleteAlertOpen}
icon="trash-alt"
header="Delete Scene?"
accept={{ variant: 'danger', onClick: onDelete, text: "Delete" }}
cancel={{ onClick: () => setIsDeleteAlertOpen(false), text: "Cancel" }}
> >
<Modal.Header> <p>
<FontAwesomeIcon icon="trash-alt" /> 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.
<span>Delete Scene?</span> </p>
</Modal.Header> <Form>
<Modal.Body> <Form.Check checked={deleteFile} label="Delete file" onChange={() => setDeleteFile(!deleteFile)} />
<p> <Form.Check checked={deleteGenerated} label="Delete generated supporting files" onChange={() => setDeleteGenerated(!deleteGenerated)} />
Are you sure you want to delete this scene? Unless the file is also deleted, this scene will be re-added when scan is performed.
</p>
<Form>
<Form.Check checked={deleteFile} label="Delete file" onChange={() => setDeleteFile(!deleteFile)} />
<Form.Check checked={deleteGenerated} label="Delete generated supporting files" onChange={() => setDeleteGenerated(!deleteGenerated)} />
</Form> </Form>
</Modal.Body>
<Modal.Footer>
<div>
<Button variant="danger" onClick={onDelete}>Delete</Button>
<Button onClick={() => setIsDeleteAlertOpen(false)}>Cancel</Button>
</div>
</Modal.Footer>
</Modal> </Modal>
); );
} }
@@ -184,7 +171,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
function onCoverImageChange(event: React.FormEvent<HTMLInputElement>) { function onCoverImageChange(event: React.FormEvent<HTMLInputElement>) {
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 {
@@ -192,7 +179,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
if (!result.data || !result.data.scrapeScene) { return; } if (!result.data || !result.data.scrapeScene) { return; }
updateSceneFromScrapedScene(result.data.scrapeScene); updateSceneFromScrapedScene(result.data.scrapeScene);
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -227,7 +214,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
if (!details && scene.details) { if (!details && scene.details) {
setDetails(scene.details); setDetails(scene.details);
} }
if (!date && scene.date) { if (!date && scene.date) {
setDate(scene.date); setDate(scene.date);
} }
@@ -235,7 +222,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
if (!url && scene.url) { if (!url && scene.url) {
setUrl(scene.url); setUrl(scene.url);
} }
if (!studioId && scene.studio && scene.studio.id) { if (!studioId && scene.studio && scene.studio.id) {
setStudioId(scene.studio.id); setStudioId(scene.studio.id);
} }
@@ -271,7 +258,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
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) {
ErrorUtils.handle(e); Toast.error(e);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -282,10 +269,10 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
return undefined; return undefined;
} }
return ( return (
<Button <Button
id="scrape-url-button" id="scrape-url-button"
onClick={onScrapeSceneURL}> onClick={onScrapeSceneURL}>
<FontAwesomeIcon icon="file-download" /> <Icon icon="file-download" />
</Button> </Button>
) )
} }
@@ -343,10 +330,10 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
<Form.Group controlId="gallery"> <Form.Group controlId="gallery">
<Form.Label>Gallery</Form.Label> <Form.Label>Gallery</Form.Label>
<ValidGalleriesSelect <SceneGallerySelect
sceneId={props.scene.id} sceneId={props.scene.id}
initialId={galleryId} initialId={galleryId}
onSelectItem={(item) => setGalleryId(item ? item.id : undefined)} onSelect={(item) => setGalleryId(item ? item.id : undefined)}
/> />
</Form.Group> </Form.Group>
@@ -370,7 +357,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
<div> <div>
<label onClick={() => setIsCoverImageOpen(!isCoverImageOpen)}> <label onClick={() => setIsCoverImageOpen(!isCoverImageOpen)}>
<FontAwesomeIcon icon={isCoverImageOpen ? "chevron-down" : "chevron-right"} /> <Icon icon={isCoverImageOpen ? "chevron-down" : "chevron-right"} />
<span>Cover Image</span> <span>Cover Image</span>
</label> </label>
<Collapse in={isCoverImageOpen}> <Collapse in={isCoverImageOpen}>
@@ -382,7 +369,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
</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}>Save</Button>
<Button className="edit-button" variant="danger" onClick={() => setIsDeleteAlertOpen(true)}>Delete</Button> <Button className="edit-button" variant="danger" onClick={() => setIsDeleteAlertOpen(true)}>Delete</Button>

View File

@@ -1,13 +1,13 @@
import React from "react";
import { Table } from 'react-bootstrap'; import { Table } from 'react-bootstrap';
import React, { FunctionComponent } from "react"; import * as GQL from "src/core/generated-graphql";
import * as GQL from "../../../core/generated-graphql"; import { TextUtils } from "src/utils";
import { TextUtils } from "../../../utils/text";
interface ISceneFileInfoPanelProps { interface ISceneFileInfoPanelProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
} }
export const SceneFileInfoPanel: FunctionComponent<ISceneFileInfoPanelProps> = (props: ISceneFileInfoPanelProps) => { export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (props: ISceneFileInfoPanelProps) => {
function renderChecksum() { function renderChecksum() {
return ( return (
<tr> <tr>
@@ -25,7 +25,7 @@ export const SceneFileInfoPanel: FunctionComponent<ISceneFileInfoPanelProps> = (
</tr> </tr>
); );
} }
function renderStream() { function renderStream() {
return ( return (
<tr> <tr>

View File

@@ -1,15 +1,13 @@
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, FormikActions, FormikProps } from "formik"; import { Field, FieldProps, Form, Formik, FormikActions } from "formik";
import React, { CSSProperties, FunctionComponent, useState } from "react"; import * as GQL from "src/core/generated-graphql";
import * as GQL from "../../../core/generated-graphql"; import { StashService } from "src/core/StashService";
import { StashService } from "../../../core/StashService"; import { TextUtils } from "src/utils";
import { TextUtils } from "../../../utils/text"; import { useToast } from 'src/hooks';
import { TagSelect } from "../../select/FilterSelect"; import { DurationInput, TagSelect, MarkerTitleSuggest } from "src/components/Shared";
import { MarkerTitleSuggest } from "../../select/MarkerTitleSuggest"; import { WallPanel } from "src/components/Wall/WallPanel";
import { WallPanel } from "../../Wall/WallPanel";
import { SceneHelpers } from "../helpers"; import { SceneHelpers } from "../helpers";
import { ErrorUtils } from "../../../utils/errors";
import { DurationInput } from "../../Shared/DurationInput";
interface ISceneMarkersPanelProps { interface ISceneMarkersPanelProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
@@ -23,7 +21,8 @@ interface IFormFields {
tagIds: string[]; tagIds: string[];
} }
export const SceneMarkersPanel: FunctionComponent<ISceneMarkersPanelProps> = (props: ISceneMarkersPanelProps) => { export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (props: ISceneMarkersPanelProps) => {
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);
@@ -33,9 +32,9 @@ export const SceneMarkersPanel: FunctionComponent<ISceneMarkersPanelProps> = (pr
const jwplayer = SceneHelpers.getPlayer(); const jwplayer = SceneHelpers.getPlayer();
function onOpenEditor(marker: GQL.SceneMarkerDataFragment | null = null) { function onOpenEditor(marker?: GQL.SceneMarkerDataFragment) {
setIsEditorOpen(true); setIsEditorOpen(true);
setEditingMarker(marker); setEditingMarker(marker ?? null);
} }
function onClickMarker(marker: GQL.SceneMarkerDataFragment) { function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
@@ -104,17 +103,17 @@ export const SceneMarkersPanel: FunctionComponent<ISceneMarkersPanelProps> = (pr
tag_ids: values.tagIds, tag_ids: values.tagIds,
}; };
if (!isEditing) { if (!isEditing) {
sceneMarkerCreate({ variables }).then((response) => { sceneMarkerCreate({ variables }).then(() => {
setIsEditorOpen(false); setIsEditorOpen(false);
setEditingMarker(null); setEditingMarker(null);
}).catch((err) => ErrorUtils.handleApolloError(err)); }).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((response) => { sceneMarkerUpdate({ variables: updateVariables }).then(() => {
setIsEditorOpen(false); setIsEditorOpen(false);
setEditingMarker(null); setEditingMarker(null);
}).catch((err) => ErrorUtils.handleApolloError(err)); }).catch((err) => Toast.error(err));
} }
} }
function onDelete() { function onDelete() {
@@ -128,12 +127,8 @@ export const SceneMarkersPanel: FunctionComponent<ISceneMarkersPanelProps> = (pr
function renderTitleField(fieldProps: FieldProps<IFormFields>) { function renderTitleField(fieldProps: FieldProps<IFormFields>) {
return ( return (
<MarkerTitleSuggest <MarkerTitleSuggest
initialMarkerString={!!editingMarker ? editingMarker.title : undefined} initialMarkerTitle={editingMarker?.title}
placeholder="Title" onChange={(query:string) => fieldProps.form.setFieldValue("title", query)}
name={fieldProps.field.name}
onBlur={fieldProps.field.onBlur}
value={fieldProps.field.value}
onQueryChange={(query) => fieldProps.form.setFieldValue("title", query)}
/> />
); );
} }
@@ -163,9 +158,9 @@ export const SceneMarkersPanel: FunctionComponent<ISceneMarkersPanelProps> = (pr
/> />
); );
} }
function renderFormFields(formikProps: FormikProps<IFormFields>) { function renderFormFields() {
let deleteButton: JSX.Element | undefined; let deleteButton: JSX.Element | undefined;
if (!!editingMarker) { if (editingMarker) {
deleteButton = ( deleteButton = (
<Button <Button
variant="danger" variant="danger"
@@ -205,7 +200,7 @@ export const SceneMarkersPanel: FunctionComponent<ISceneMarkersPanelProps> = (pr
); );
} }
let initialValues: any; let initialValues: any;
if (!!editingMarker) { if (editingMarker) {
initialValues = { initialValues = {
title: editingMarker.title, title: editingMarker.title,
seconds: editingMarker.seconds, seconds: editingMarker.seconds,

View File

@@ -1,6 +1,6 @@
import React, { FunctionComponent } from "react"; import React, { FunctionComponent } from "react";
import * as GQL from "../../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { PerformerCard } from "../../performers/PerformerCard"; import { PerformerCard } from "src/components/performers/PerformerCard";
interface IScenePerformerPanelProps { interface IScenePerformerPanelProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;

View File

@@ -1,16 +1,14 @@
import { Badge, Button, Card, Collapse, Dropdown, DropdownButton, Form, Table, Spinner } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { StashService } from "../../core/StashService"; import { Badge, Button, Card, Collapse, Dropdown, DropdownButton, Form, Table, Spinner } from 'react-bootstrap';
import * as GQL from "../../core/generated-graphql";
import { SlimSceneDataFragment, Maybe } from "../../core/generated-graphql";
import { TextUtils } from "../../utils/text";
import _ from "lodash"; import _ from "lodash";
import { ToastUtils } from "../../utils/toasts"; import { StashService } from "src/core/StashService";
import { ErrorUtils } from "../../utils/errors"; import * as GQL from "src/core/generated-graphql";
import { SlimSceneDataFragment, Maybe } from "src/core/generated-graphql";
import { FilterSelect, Icon, StudioSelect } from "src/components/Shared";
import { TextUtils } from "src/utils";
import { useToast } from "src/hooks";
import { Pagination } from "../list/Pagination"; import { Pagination } from "../list/Pagination";
import { FilterSelect, StudioSelect } from "../select/FilterSelect";
class ParserResult<T> { class ParserResult<T> {
public value: Maybe<T>; public value: Maybe<T>;
public originalValue: Maybe<T>; public originalValue: Maybe<T>;
@@ -22,7 +20,7 @@ class ParserResult<T> {
} }
public setValue(v : Maybe<T>) { public setValue(v : Maybe<T>) {
if (!!v) { if (v) {
this.value = v; this.value = v;
this.set = !_.isEqual(this.value, this.originalValue); this.set = !_.isEqual(this.value, this.originalValue);
} }
@@ -258,6 +256,7 @@ const builtInRecipes = [
]; ];
export const SceneFilenameParser: React.FC = () => { export const SceneFilenameParser: React.FC = () => {
const Toast = useToast();
const [parserResult, setParserResult] = useState<SceneParserResult[]>([]); const [parserResult, setParserResult] = useState<SceneParserResult[]>([]);
const [parserInput, setParserInput] = useState<IParserInput>(initialParserInput()); const [parserInput, setParserInput] = useState<IParserInput>(initialParserInput());
@@ -268,7 +267,7 @@ export const SceneFilenameParser: React.FC = () => {
const [allStudioSet, setAllStudioSet] = useState<boolean>(false); const [allStudioSet, setAllStudioSet] = useState<boolean>(false);
const [showFields, setShowFields] = useState<Map<string, boolean>>(initialShowFieldsState()); const [showFields, setShowFields] = useState<Map<string, boolean>>(initialShowFieldsState());
const [totalItems, setTotalItems] = useState<number>(0); const [totalItems, setTotalItems] = useState<number>(0);
// Network state // Network state
@@ -320,17 +319,17 @@ export const SceneFilenameParser: React.FC = () => {
setParserResult([]); setParserResult([]);
setIsLoading(true); setIsLoading(true);
try { try {
const response = await StashService.queryParseSceneFilenames(getParserFilter(), getParserInput()); const response = await StashService.queryParseSceneFilenames(getParserFilter(), getParserInput());
let result = response.data.parseSceneFilenames; let result = response.data.parseSceneFilenames;
if (!!result) { if (result) {
parseResults(result.results); parseResults(result.results);
setTotalItems(result.count); setTotalItems(result.count);
} }
} catch (err) { } catch (err) {
ErrorUtils.handle(err); Toast.error(err);
} }
setIsLoading(false); setIsLoading(false);
@@ -373,9 +372,9 @@ export const SceneFilenameParser: React.FC = () => {
try { try {
await updateScenes(); await updateScenes();
ToastUtils.success("Updated scenes"); Toast.success({ content: "Updated scenes" });
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
setIsLoading(false); setIsLoading(false);
@@ -395,7 +394,7 @@ export const SceneFilenameParser: React.FC = () => {
function determineFieldsToHide() { function determineFieldsToHide() {
var pattern = parserInput.pattern; var pattern = parserInput.pattern;
var titleSet = pattern.includes("{title}"); var titleSet = pattern.includes("{title}");
var dateSet = pattern.includes("{date}") || var dateSet = pattern.includes("{date}") ||
pattern.includes("{dd}") || // don't worry about other partial date fields since this should be implied pattern.includes("{dd}") || // don't worry about other partial date fields since this should be implied
ParserField.fullDateFields.some((f) => { ParserField.fullDateFields.some((f) => {
return pattern.includes("{" + f.field + "}"); return pattern.includes("{" + f.field + "}");
@@ -518,7 +517,7 @@ export const SceneFilenameParser: React.FC = () => {
const fieldRows = [...props.fields.entries()].map(([label, enabled]) => ( const fieldRows = [...props.fields.entries()].map(([label, enabled]) => (
<div key={label} onClick={() => {handleClick(label)}}> <div key={label} onClick={() => {handleClick(label)}}>
<FontAwesomeIcon icon={enabled ? "check" : "times" } /> <Icon icon={enabled ? "check" : "times" } />
<span>{label}</span> <span>{label}</span>
</div> </div>
)); ));
@@ -526,7 +525,7 @@ export const SceneFilenameParser: React.FC = () => {
return ( return (
<div> <div>
<div onClick={() => setOpen(!open)}> <div onClick={() => setOpen(!open)}>
<FontAwesomeIcon icon={open ? "chevron-down" : "chevron-right" } /> <Icon icon={open ? "chevron-down" : "chevron-right" } />
<span>Display fields</span> <span>Display fields</span>
</div> </div>
<Collapse in={open}> <Collapse in={open}>
@@ -567,9 +566,9 @@ export const SceneFilenameParser: React.FC = () => {
setWhitespaceCharacters(recipe.whitespaceCharacters); setWhitespaceCharacters(recipe.whitespaceCharacters);
setCapitalizeTitle(recipe.capitalizeTitle); setCapitalizeTitle(recipe.capitalizeTitle);
} }
const validFields = [new ParserField("", "Wildcard")].concat(ParserField.validFields); const validFields = [new ParserField("", "Wildcard")].concat(ParserField.validFields);
function addParserField(field: ParserField) { function addParserField(field: ParserField) {
setPattern(pattern + field.getFieldPattern()); setPattern(pattern + field.getFieldPattern());
} }
@@ -601,7 +600,7 @@ export const SceneFilenameParser: React.FC = () => {
/> />
<div>Matches with {"{i}"}</div> <div>Matches with {"{i}"}</div>
</Form.Group> </Form.Group>
<Form.Group> <Form.Group>
<h5>Title</h5> <h5>Title</h5>
<Form.Label>Whitespace characters:</Form.Label> <Form.Label>Whitespace characters:</Form.Label>
@@ -619,7 +618,7 @@ export const SceneFilenameParser: React.FC = () => {
</Form.Group> </Form.Group>
<div>These characters will be replaced with whitespace in the title</div> <div>These characters will be replaced with whitespace in the title</div>
</Form.Group> </Form.Group>
{/* TODO - mapping stuff will go here */} {/* TODO - mapping stuff will go here */}
<Form.Group> <Form.Group>
@@ -726,7 +725,7 @@ export const SceneFilenameParser: React.FC = () => {
/> />
); );
} }
function renderNewInputGroup(props : ISceneParserFieldProps, onChange : (value : any) => void) { function renderNewInputGroup(props : ISceneParserFieldProps, onChange : (value : any) => void) {
return ( return (
<InputGroupWrapper <InputGroupWrapper
@@ -840,17 +839,17 @@ export const SceneFilenameParser: React.FC = () => {
<td style={{textAlign: "left"}}> <td style={{textAlign: "left"}}>
{props.scene.filename} {props.scene.filename}
</td> </td>
<SceneParserField <SceneParserField
key="title" key="title"
fieldName="Title" fieldName="Title"
className="parser-field-title" className="parser-field-title"
parserResult={props.scene.title} parserResult={props.scene.title}
onSetChanged={(set) => onTitleChanged(set, props.scene.title.value)} onSetChanged={(set) => onTitleChanged(set, props.scene.title.value)}
onValueChanged={(value) => onTitleChanged(props.scene.title.set, value)} onValueChanged={(value) => onTitleChanged(props.scene.title.set, value)}
renderOriginalInputField={renderOriginalInputGroup} renderOriginalInputField={renderOriginalInputGroup}
renderNewInputField={renderNewInputGroup} renderNewInputField={renderNewInputGroup}
/> />
<SceneParserField <SceneParserField
key="date" key="date"
fieldName="Date" fieldName="Date"
className="parser-field-date" className="parser-field-date"
@@ -860,7 +859,7 @@ export const SceneFilenameParser: React.FC = () => {
renderOriginalInputField={renderOriginalInputGroup} renderOriginalInputField={renderOriginalInputGroup}
renderNewInputField={renderNewInputGroup} renderNewInputField={renderNewInputGroup}
/> />
<SceneParserField <SceneParserField
key="performers" key="performers"
fieldName="Performers" fieldName="Performers"
className="parser-field-performers" className="parser-field-performers"
@@ -871,7 +870,7 @@ export const SceneFilenameParser: React.FC = () => {
renderOriginalInputField={renderOriginalSelect} renderOriginalInputField={renderOriginalSelect}
renderNewInputField={renderNewPerformerSelect} renderNewInputField={renderNewPerformerSelect}
/> />
<SceneParserField <SceneParserField
key="tags" key="tags"
fieldName="Tags" fieldName="Tags"
className="parser-field-tags" className="parser-field-tags"
@@ -882,7 +881,7 @@ export const SceneFilenameParser: React.FC = () => {
renderOriginalInputField={renderOriginalSelect} renderOriginalInputField={renderOriginalSelect}
renderNewInputField={renderNewTagSelect} renderNewInputField={renderNewTagSelect}
/> />
<SceneParserField <SceneParserField
key="studio" key="studio"
fieldName="Studio" fieldName="Studio"
className="parser-field-studio" className="parser-field-studio"
@@ -944,9 +943,9 @@ export const SceneFilenameParser: React.FC = () => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{parserResult.map((scene) => {parserResult.map((scene) =>
<SceneParserRow <SceneParserRow
scene={scene} scene={scene}
key={scene.id} key={scene.id}
onChange={(changedScene) => onChange(scene, changedScene)}/> onChange={(changedScene) => onChange(scene, changedScene)}/>
)} )}
@@ -978,4 +977,4 @@ export const SceneFilenameParser: React.FC = () => {
</Card> </Card>
); );
}; };

View File

@@ -1,16 +1,16 @@
import _ from "lodash";
import React from "react"; import React from "react";
import _ from "lodash";
import { QueryHookResult } from "react-apollo-hooks"; import { QueryHookResult } from "react-apollo-hooks";
import { FindScenesQuery, FindScenesVariables, SlimSceneDataFragment } from "../../core/generated-graphql"; import { FindScenesQuery, FindScenesVariables, SlimSceneDataFragment } from "src/core/generated-graphql";
import { ListHook } from "../../hooks/ListHook"; import { StashService } from "src/core/StashService";
import { IBaseProps } from "../../models/base-props"; import { ListHook } from "src/hooks";
import { ListFilterModel } from "../../models/list-filter/filter"; import { IBaseProps } from "src/models/base-props";
import { DisplayMode, FilterMode } from "../../models/list-filter/types"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode, FilterMode } from "src/models/list-filter/types";
import { WallPanel } from "../Wall/WallPanel"; import { WallPanel } from "../Wall/WallPanel";
import { SceneCard } from "./SceneCard"; import { SceneCard } from "./SceneCard";
import { SceneListTable } from "./SceneListTable"; import { SceneListTable } from "./SceneListTable";
import { SceneSelectedOptions } from "./SceneSelectedOptions"; import { SceneSelectedOptions } from "./SceneSelectedOptions";
import { StashService } from "../../core/StashService";
interface ISceneListProps extends IBaseProps {} interface ISceneListProps extends IBaseProps {}
@@ -21,7 +21,7 @@ export const SceneList: React.FC<ISceneListProps> = (props: ISceneListProps) =>
onClick: playRandom, onClick: playRandom,
} }
]; ];
const listData = ListHook.useList({ const listData = ListHook.useList({
filterMode: FilterMode.Scenes, filterMode: FilterMode.Scenes,
props, props,
@@ -31,7 +31,7 @@ export const SceneList: React.FC<ISceneListProps> = (props: ISceneListProps) =>
renderSelectedOptions renderSelectedOptions
}); });
async function playRandom(result: QueryHookResult<FindScenesQuery, FindScenesVariables>, filter: ListFilterModel, selectedIds: Set<string>) { 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) {
let count = result.data.findScenes.count; let count = result.data.findScenes.count;
@@ -54,7 +54,7 @@ export const SceneList: React.FC<ISceneListProps> = (props: ISceneListProps) =>
if (!result.data || !result.data.findScenes) { return undefined; } if (!result.data || !result.data.findScenes) { return undefined; }
var scenes = result.data.findScenes.scenes; var scenes = result.data.findScenes.scenes;
var selectedScenes : SlimSceneDataFragment[] = []; var selectedScenes : SlimSceneDataFragment[] = [];
selectedIds.forEach((id) => { selectedIds.forEach((id) => {
var scene = scenes.find((scene) => { var scene = scenes.find((scene) => {
@@ -75,9 +75,9 @@ export const SceneList: React.FC<ISceneListProps> = (props: ISceneListProps) =>
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)}

View File

@@ -1,17 +1,15 @@
import React from "react"; import React from "react";
import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql";
import { TextUtils } from "../../utils/text";
import { NavigationUtils } from "../../utils/navigation";
import { Table } from 'react-bootstrap'; import { Table } from 'react-bootstrap';
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { NavUtils, TextUtils } from "src/utils";
interface ISceneListTableProps { 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}')`,
@@ -23,7 +21,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (props: ISceneList
}; };
return ( return (
<Link <Link
className="scene-list-thumbnail" className="scene-list-thumbnail"
to={`/scenes/${scene.id}`} to={`/scenes/${scene.id}`}
style={style}/> style={style}/>
@@ -37,7 +35,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (props: ISceneList
function renderTags(tags : GQL.SlimSceneDataTags[]) { function renderTags(tags : GQL.SlimSceneDataTags[]) {
return tags.map((tag) => ( return tags.map((tag) => (
<Link to={NavigationUtils.makeTagScenesUrl(tag)}> <Link to={NavUtils.makeTagScenesUrl(tag)}>
<h6>{tag.name}</h6> <h6>{tag.name}</h6>
</Link> </Link>
)); ));
@@ -45,16 +43,16 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (props: ISceneList
function renderPerformers(performers : GQL.SlimSceneDataPerformers[]) { function renderPerformers(performers : GQL.SlimSceneDataPerformers[]) {
return performers.map((performer) => ( return performers.map((performer) => (
<Link to={NavigationUtils.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={NavigationUtils.makeStudioScenesUrl(studio)}> <Link to={NavUtils.makeStudioScenesUrl(studio)}>
<h6>{studio.name}</h6> <h6>{studio.name}</h6>
</Link> </Link>
); );
@@ -63,7 +61,6 @@ 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)} {renderSceneImage(scene)}
@@ -71,7 +68,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (props: ISceneList
<td style={{textAlign: "left"}}> <td style={{textAlign: "left"}}>
<Link to={`/scenes/${scene.id}`}> <Link to={`/scenes/${scene.id}`}>
<h5 className="text-truncate"> <h5 className="text-truncate">
{!!scene.title ? scene.title : TextUtils.fileNameFromPath(scene.path)} {scene.title ?? TextUtils.fileNameFromPath(scene.path)}
</h5> </h5>
</Link> </Link>
</td> </td>
@@ -91,12 +88,10 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (props: ISceneList
{renderStudio(scene.studio)} {renderStudio(scene.studio)}
</td> </td>
</tr> </tr>
</>
) )
} }
return ( return (
<>
<div className="grid"> <div className="grid">
<Table striped bordered> <Table striped bordered>
<thead> <thead>
@@ -115,7 +110,6 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (props: ISceneList
</tbody> </tbody>
</Table> </Table>
</div> </div>
</>
); );
}; };

View File

@@ -1,24 +1,22 @@
import _ from "lodash"; import _ from "lodash";
import React, { FunctionComponent } from "react"; import React from "react";
import { QueryHookResult } from "react-apollo-hooks"; import { QueryHookResult } from "react-apollo-hooks";
import { FindSceneMarkersQuery, FindSceneMarkersVariables } from "../../core/generated-graphql"; import { FindSceneMarkersQuery, FindSceneMarkersVariables } from "src/core/generated-graphql";
import { ListHook } from "../../hooks/ListHook"; import { StashService } from "src/core/StashService";
import { IBaseProps } from "../../models/base-props"; import { NavUtils } from "src/utils";
import { ListFilterModel } from "../../models/list-filter/filter"; import { ListHook } from "src/hooks";
import { DisplayMode, FilterMode } from "../../models/list-filter/types"; import { IBaseProps } from "src/models/base-props";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode, FilterMode } from "src/models/list-filter/types";
import { WallPanel } from "../Wall/WallPanel"; import { WallPanel } from "../Wall/WallPanel";
import { StashService } from "../../core/StashService";
import { NavigationUtils } from "../../utils/navigation";
interface IProps extends IBaseProps {} interface IProps extends IBaseProps {}
export const SceneMarkerList: FunctionComponent<IProps> = (props: IProps) => { export const SceneMarkerList: React.FC<IProps> = (props: IProps) => {
const otherOperations = [ const otherOperations = [{
{ text: "Play Random",
text: "Play Random", onClick: playRandom
onClick: playRandom, }];
}
];
const listData = ListHook.useList({ const listData = ListHook.useList({
filterMode: FilterMode.SceneMarkers, filterMode: FilterMode.SceneMarkers,
@@ -27,7 +25,7 @@ export const SceneMarkerList: FunctionComponent<IProps> = (props: IProps) => {
renderContent, renderContent,
}); });
async function playRandom(result: QueryHookResult<FindSceneMarkersQuery, FindSceneMarkersVariables>, filter: ListFilterModel, selectedIds: Set<string>) { 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) {
let count = result.data.findSceneMarkers.count; let count = result.data.findSceneMarkers.count;
@@ -39,7 +37,7 @@ export const SceneMarkerList: FunctionComponent<IProps> = (props: IProps) => {
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
let url = NavigationUtils.makeSceneMarkerUrl(singleResult!.data!.findSceneMarkers!.scene_markers[0]) let url = NavUtils.makeSceneMarkerUrl(singleResult.data.findSceneMarkers.scene_markers[0])
props.history.push(url); props.history.push(url);
} }
} }
@@ -49,7 +47,8 @@ export const SceneMarkerList: FunctionComponent<IProps> = (props: IProps) => {
result: QueryHookResult<FindSceneMarkersQuery, FindSceneMarkersVariables>, result: QueryHookResult<FindSceneMarkersQuery, FindSceneMarkersVariables>,
filter: ListFilterModel, filter: ListFilterModel,
) { ) {
if (!result.data || !result.data.findSceneMarkers) { return; } if (!result?.data?.findSceneMarkers)
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

@@ -1,10 +1,10 @@
import React from "react"; import React from "react";
import ReactJWPlayer from "react-jw-player"; import ReactJWPlayer from "react-jw-player";
import { HotKeys } from "react-hotkeys"; import { HotKeys } from "react-hotkeys";
import * as GQL from "../../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { SceneHelpers } from "../helpers"; import { SceneHelpers } from "../helpers";
import { ScenePlayerScrubber } from "./ScenePlayerScrubber"; import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
import { StashService } from "../../../core/StashService";
interface IScenePlayerProps { interface IScenePlayerProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
@@ -110,8 +110,8 @@ export class ScenePlayerImpl extends React.Component<IScenePlayerProps, IScenePl
let getCurrentTimeHook: ((_videoTag: any) => number) | undefined = undefined; let getCurrentTimeHook: ((_videoTag: any) => number) | undefined = undefined;
if (!this.props.scene.is_streamable) { if (!this.props.scene.is_streamable) {
getDurationHook = () => { getDurationHook = () => {
return this.props.scene.file.duration; return this.props.scene.file.duration;
}; };
seekHook = (seekToPosition: number, _videoTag: any) => { seekHook = (seekToPosition: number, _videoTag: any) => {

View File

@@ -1,7 +1,7 @@
import axios from "axios";
import React, { CSSProperties, useEffect, useRef, useState } from "react"; import React, { CSSProperties, useEffect, useRef, useState } from "react";
import * as GQL from "../../../core/generated-graphql"; import axios from "axios";
import { TextUtils } from "../../../utils/text"; import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils";
import "./ScenePlayerScrubber.scss"; import "./ScenePlayerScrubber.scss";
interface IScenePlayerScrubberProps { interface IScenePlayerScrubberProps {

View File

@@ -1,11 +1,10 @@
import _ from "lodash";
import { Button, ButtonGroup, Form, Spinner } from 'react-bootstrap';
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { FilterSelect, StudioSelect } from "../select/FilterSelect"; import { Button, ButtonGroup, Form, Spinner } from 'react-bootstrap';
import { StashService } from "../../core/StashService"; import _ from "lodash";
import * as GQL from "../../core/generated-graphql"; import { StashService } from "src/core/StashService";
import { ErrorUtils } from "../../utils/errors"; import * as GQL from "src/core/generated-graphql";
import { ToastUtils } from "../../utils/toasts"; import { FilterSelect, StudioSelect } from "src/components/Shared";
import { useToast } from "src/hooks";
interface IListOperationProps { interface IListOperationProps {
selected: GQL.SlimSceneDataFragment[], selected: GQL.SlimSceneDataFragment[],
@@ -13,6 +12,7 @@ interface IListOperationProps {
} }
export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IListOperationProps) => { export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IListOperationProps) => {
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);
@@ -36,7 +36,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) {
@@ -48,8 +48,8 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
// if rating is set, then we are setting the rating for all // if rating is set, then we are setting the rating for all
sceneInput.rating = Number.parseInt(rating); sceneInput.rating = Number.parseInt(rating);
} }
// if studioId is undefined // if studioId is undefined
if (studioId === undefined) { if (studioId === undefined) {
// and all scenes have the same studioId, // and all scenes have the same studioId,
// then unset the studioId, otherwise ignoring studioId // then unset the studioId, otherwise ignoring studioId
@@ -61,7 +61,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
// if studioId is set, then we are setting it // if studioId is set, then we are setting it
sceneInput.studio_id = studioId; sceneInput.studio_id = studioId;
} }
// if performerIds are empty // if performerIds are empty
if (!performerIds || performerIds.length === 0) { if (!performerIds || performerIds.length === 0) {
// and all scenes have the same ids, // and all scenes have the same ids,
@@ -73,7 +73,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
// if performerIds non-empty, then we are setting them // if performerIds non-empty, then we are setting them
sceneInput.performer_ids = performerIds; sceneInput.performer_ids = performerIds;
} }
// if tagIds non-empty, then we are setting them // if tagIds non-empty, then we are setting them
if (!tagIds || tagIds.length === 0) { if (!tagIds || tagIds.length === 0) {
// and all scenes have the same ids, // and all scenes have the same ids,
@@ -93,9 +93,9 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
setIsLoading(true); setIsLoading(true);
try { try {
await updateScenes(); await updateScenes();
ToastUtils.success("Updated scenes"); Toast.success({ content: "Updated scenes" });
} catch (e) { } catch (e) {
ErrorUtils.handle(e); Toast.error(e);
} }
setIsLoading(false); setIsLoading(false);
props.onScenesUpdated(); props.onScenesUpdated();
@@ -152,7 +152,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
first = false; first = false;
} else { } else {
const perfIds = !!scene.performers ? scene.performers.map(toId).sort() : []; const perfIds = !!scene.performers ? scene.performers.map(toId).sort() : [];
if (!_.isEqual(ret, perfIds)) { if (!_.isEqual(ret, perfIds)) {
ret = []; ret = [];
} }
@@ -172,7 +172,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
first = false; first = false;
} else { } else {
const tIds = !!scene.tags ? scene.tags.map(toId).sort() : []; const tIds = !!scene.tags ? scene.tags.map(toId).sort() : [];
if (!_.isEqual(ret, tIds)) { if (!_.isEqual(ret, tIds)) {
ret = []; ret = [];
} }
@@ -212,7 +212,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
} }
const perfIds = !!scene.performers ? scene.performers.map(toId).sort() : []; const perfIds = !!scene.performers ? scene.performers.map(toId).sort() : [];
const tIds = !!scene.tags ? scene.tags.map(toId).sort() : []; const tIds = !!scene.tags ? scene.tags.map(toId).sort() : [];
if (!_.isEqual(performerIds, perfIds)) { if (!_.isEqual(performerIds, perfIds)) {
performerIds = []; performerIds = [];
} }
@@ -222,7 +222,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
} }
} }
}); });
setRating(rating); setRating(rating);
setStudioId(studioId); setStudioId(studioId);
setPerformerIds(performerIds); setPerformerIds(performerIds);
@@ -249,7 +249,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
/> />
); );
} }
function render() { function render() {
return ( return (
<> <>
@@ -283,9 +283,9 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (props: IList
<Form.Label>Performers</Form.Label> <Form.Label>Performers</Form.Label>
{renderMultiSelect("tags", tagIds)} {renderMultiSelect("tags", tagIds)}
</Form.Group> </Form.Group>
<ButtonGroup className="operation-item"> <ButtonGroup className="operation-item">
<Button <Button
variant="primary" variant="primary"
onClick={onSave}> onClick={onSave}>
Apply Apply

View File

@@ -1,6 +1,6 @@
import React, { } from "react"; import React, { } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
export class SceneHelpers { export class SceneHelpers {
public static maybeRenderStudio( public static maybeRenderStudio(

View File

@@ -1,190 +0,0 @@
import * as React from "react";
import { MenuItem } from "@blueprintjs/core";
import { IMultiSelectProps, ItemPredicate, ItemRenderer, MultiSelect } from "@blueprintjs/select";
import * as GQL from "../../core/generated-graphql";
import { StashService } from "../../core/StashService";
import { HTMLInputProps } from "../../models";
import { ErrorUtils } from "../../utils/errors";
import { ToastUtils } from "../../utils/toasts";
const InternalPerformerMultiSelect = MultiSelect.ofType<GQL.AllPerformersForFilterAllPerformers>();
const InternalTagMultiSelect = MultiSelect.ofType<GQL.AllTagsForFilterAllTags>();
const InternalStudioMultiSelect = MultiSelect.ofType<GQL.AllStudiosForFilterAllStudios>();
type ValidTypes =
GQL.AllPerformersForFilterAllPerformers |
GQL.AllTagsForFilterAllTags |
GQL.AllStudiosForFilterAllStudios;
interface IProps extends HTMLInputProps, Partial<IMultiSelectProps<ValidTypes>> {
type: "performers" | "studios" | "tags";
initialIds?: string[];
onUpdate: (items: ValidTypes[]) => void;
}
export const FilterMultiSelect: React.FunctionComponent<IProps> = (props: IProps) => {
let MultiSelectImpl = getMultiSelectImpl();
let InternalMultiSelect = MultiSelectImpl.getInternalMultiSelect();
const data = MultiSelectImpl.getData();
const [selectedItems, setSelectedItems] = React.useState<ValidTypes[]>([]);
const [items, setItems] = React.useState<ValidTypes[]>([]);
const [newTagName, setNewTagName] = React.useState<string>("");
const createTag = StashService.useTagCreate(getTagInput() as GQL.TagCreateInput);
React.useEffect(() => {
if (!!data) {
MultiSelectImpl.translateData();
}
}, [data]);
function getTagInput() {
const tagInput: Partial<GQL.TagCreateInput | GQL.TagUpdateInput> = { name: newTagName };
return tagInput;
}
async function onCreateNewObject(item: ValidTypes) {
var created : any;
if (props.type === "tags") {
try {
created = await createTag();
items.push(created.data.tagCreate);
setItems(items.slice());
addSelectedItem(created.data.tagCreate);
ToastUtils.success("Created tag");
} catch (e) {
ErrorUtils.handle(e);
}
}
}
function createNewTag(query : string) {
setNewTagName(query);
return {
name : query
};
}
function createNewRenderer(query: string, active: boolean, handleClick: React.MouseEventHandler<HTMLElement>) {
// if tag already exists with that name, then don't return anything
if (items.find((item) => {
return item.name === query;
})) {
return undefined;
}
return (
<MenuItem
icon="add"
text={`Create "${query}"`}
active={active}
onClick={handleClick}
/>
);
}
React.useEffect(() => {
if (!!props.initialIds && !!items) {
const initialItems = items.filter((item) => props.initialIds!.includes(item.id));
setSelectedItems(initialItems);
}
}, [props.initialIds, items]);
function getMultiSelectImpl() {
let getInternalMultiSelect: () => new (props: IMultiSelectProps<any>) => MultiSelect<any>;
let getData: () => GQL.AllPerformersForFilterQuery | GQL.AllStudiosForFilterQuery | GQL.AllTagsForFilterQuery | undefined;
let translateData: () => void;
let createNewObject: ((query : string) => void) | undefined = undefined;
switch (props.type) {
case "performers": {
getInternalMultiSelect = () => { return InternalPerformerMultiSelect; };
getData = () => { const { data } = StashService.useAllPerformersForFilter(); return data; }
translateData = () => { let perfData = data as GQL.AllPerformersForFilterQuery; setItems(!!perfData && !!perfData.allPerformers ? perfData.allPerformers : []); };
break;
}
case "studios": {
getInternalMultiSelect = () => { return InternalStudioMultiSelect; };
getData = () => { const { data } = StashService.useAllStudiosForFilter(); return data; }
translateData = () => { let studioData = data as GQL.AllStudiosForFilterQuery; setItems(!!studioData && !!studioData.allStudios ? studioData.allStudios : []); };
break;
}
case "tags": {
getInternalMultiSelect = () => { return InternalTagMultiSelect; };
getData = () => { const { data } = StashService.useAllTagsForFilter(); return data; }
translateData = () => { let tagData = data as GQL.AllTagsForFilterQuery; setItems(!!tagData && !!tagData.allTags ? tagData.allTags : []); };
createNewObject = createNewTag;
break;
}
default: {
throw "Unhandled case in FilterMultiSelect";
}
}
return {
getInternalMultiSelect: getInternalMultiSelect,
getData: getData,
translateData: translateData,
createNewObject: createNewObject
};
}
const renderItem: ItemRenderer<ValidTypes> = (item, itemProps) => {
if (!itemProps.modifiers.matchesPredicate) { return null; }
return (
<MenuItem
active={itemProps.modifiers.active}
disabled={itemProps.modifiers.disabled}
key={item.id}
onClick={itemProps.handleClick}
text={item.name}
/>
);
};
const filter: ItemPredicate<ValidTypes> = (query, item) => {
if (selectedItems.includes(item)) { return false; }
return item.name!.toLowerCase().indexOf(query.toLowerCase()) >= 0;
};
function addSelectedItem(item: ValidTypes) {
selectedItems.push(item);
setSelectedItems(selectedItems);
props.onUpdate(selectedItems);
}
function onItemSelect(item: ValidTypes) {
if (item.id === undefined) {
// create the new item, if applicable
onCreateNewObject(item);
} else {
addSelectedItem(item);
}
}
function onItemRemove(value: string, index: number) {
const newSelectedItems = selectedItems.filter((_, i) => i !== index);
setSelectedItems(newSelectedItems);
props.onUpdate(newSelectedItems);
}
return (
<InternalMultiSelect
items={items}
selectedItems={selectedItems}
itemRenderer={renderItem}
itemPredicate={filter}
tagRenderer={(tag) => tag.name}
tagInputProps={{ onRemove: onItemRemove }}
onItemSelect={onItemSelect}
resetOnSelect={true}
popoverProps={{position: "bottom"}}
createNewItemFromQuery={MultiSelectImpl.createNewObject}
createNewItemRenderer={createNewRenderer}
{...props}
/>
);
};

View File

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

View File

@@ -1,74 +0,0 @@
import * as React from "react";
import { MenuItem } from "@blueprintjs/core";
import { ItemRenderer, Suggest } from "@blueprintjs/select";
import * as GQL from "../../core/generated-graphql";
import { StashService } from "../../core/StashService";
import { HTMLInputProps } from "../../models";
const InternalSuggest = Suggest.ofType<GQL.ScrapePerformerListScrapePerformerList>();
interface IProps extends HTMLInputProps {
scraperId: string;
onSelectPerformer: (query: GQL.ScrapePerformerListScrapePerformerList) => void;
}
export const ScrapePerformerSuggest: React.FunctionComponent<IProps> = (props: IProps) => {
const [query, setQuery] = React.useState<string>("");
const [selectedItem, setSelectedItem] = React.useState<GQL.ScrapePerformerListScrapePerformerList | undefined>();
const [debouncedQuery, setDebouncedQuery] = React.useState<string>("");
const { data, error, loading } = StashService.useScrapePerformerList(props.scraperId, debouncedQuery);
React.useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
}, 500);
return () => {
clearTimeout(handler);
};
}, [query])
const performerNames = !!data && !!data.scrapePerformerList ? data.scrapePerformerList : [];
const renderInputValue = (performer: GQL.ScrapePerformerListScrapePerformerList) => performer.name || "";
const renderItem: ItemRenderer<GQL.ScrapePerformerListScrapePerformerList> = (performer, itemProps) => {
if (!itemProps.modifiers.matchesPredicate) { return null; }
return (
<MenuItem
active={itemProps.modifiers.active}
disabled={itemProps.modifiers.disabled}
key={performer.name}
onClick={itemProps.handleClick}
text={performer.name}
/>
);
};
function renderLoadingError() {
if (error) {
return (<MenuItem disabled={true} text={error.toString()} />);
}
if (loading) {
return (<MenuItem disabled={true} text="Loading..." />);
}
if (debouncedQuery && data && !!data.scrapePerformerList && data.scrapePerformerList.length === 0) {
return (<MenuItem disabled={true} text="No results" />);
}
}
return (
<InternalSuggest
inputValueRenderer={renderInputValue}
items={performerNames}
itemRenderer={renderItem}
onItemSelect={(item) => { props.onSelectPerformer(item); setSelectedItem(item); }}
onQueryChange={(newQuery) => { setQuery(newQuery); }}
activeItem={null}
selectedItem={selectedItem}
noResults={renderLoadingError()}
popoverProps={{position: "bottom"}}
/>
);
};

View File

@@ -1,75 +0,0 @@
import * as React from "react";
import { Button, MenuItem } from "@blueprintjs/core";
import { ItemPredicate, ItemRenderer, Select } from "@blueprintjs/select";
import * as GQL from "../../core/generated-graphql";
import { StashService } from "../../core/StashService";
import { HTMLInputProps } from "../../models";
const InternalSelect = Select.ofType<GQL.ValidGalleriesForSceneValidGalleriesForScene>();
interface IProps extends HTMLInputProps {
initialId?: string;
sceneId: string;
onSelectItem: (item: GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined) => void;
}
export const ValidGalleriesSelect: React.FunctionComponent<IProps> = (props: IProps) => {
const { data } = StashService.useValidGalleriesForScene(props.sceneId);
const items = !!data && !!data.validGalleriesForScene ? data.validGalleriesForScene : [];
// Add a none option to clear the gallery
if (!items.find((item) => item.id === "0")) { items.unshift({id: "0", path: "None"}); }
const [selectedItem, setSelectedItem] = React.useState<GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined>(undefined);
const [isInitialized, setIsInitialized] = React.useState<boolean>(false);
if (!!props.initialId && !selectedItem && !isInitialized) {
const initialItem = items.find((item) => props.initialId === item.id);
if (!!initialItem) {
setSelectedItem(initialItem);
setIsInitialized(true);
}
}
const renderItem: ItemRenderer<GQL.ValidGalleriesForSceneValidGalleriesForScene> = (item, itemProps) => {
if (!itemProps.modifiers.matchesPredicate) { return null; }
return (
<MenuItem
active={itemProps.modifiers.active}
disabled={itemProps.modifiers.disabled}
key={item.id}
onClick={itemProps.handleClick}
text={item.path}
shouldDismissPopover={false}
/>
);
};
const filter: ItemPredicate<GQL.ValidGalleriesForSceneValidGalleriesForScene> = (query, item) => {
return item.path!.toLowerCase().indexOf(query.toLowerCase()) >= 0;
};
function onItemSelect(item: GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined) {
if (item && item.id === "0") {
item = undefined;
}
props.onSelectItem(item);
setSelectedItem(item);
}
const buttonText = selectedItem ? selectedItem.path : "(No selection)";
return (
<InternalSelect
items={items}
itemRenderer={renderItem}
itemPredicate={filter}
noResults={<MenuItem disabled={true} text="No results." />}
onItemSelect={onItemSelect}
popoverProps={{position: "bottom"}}
{...props}
>
<Button fill={true} text={buttonText} />
</InternalSelect>
);
};

View File

@@ -2,10 +2,9 @@ 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 _ from "lodash"; import { getMainDefinition } from "apollo-utilities";
import { ListFilterModel } from "../models/list-filter/filter"; import { ListFilterModel } from "../models/list-filter/filter";
import * as GQL from "./generated-graphql"; import * as GQL from "./generated-graphql";
import { getMainDefinition } from "apollo-utilities";
export class StashService { export class StashService {
public static client: ApolloClient<any>; public static client: ApolloClient<any>;
@@ -41,7 +40,7 @@ export class StashService {
reconnect: true reconnect: true
}, },
}); });
const link = split( const link = split(
({ query }) => { ({ query }) => {
const { kind, operation } = getMainDefinition(query); const { kind, operation } = getMainDefinition(query);
@@ -215,27 +214,27 @@ export class StashService {
]; ];
public static useSceneMarkerCreate() { public static useSceneMarkerCreate() {
return GQL.useSceneMarkerCreate({ refetchQueries: ["FindScene"] }); return GQL.useSceneMarkerCreate({ refetchQueries: ["FindScene"] });
} }
public static useSceneMarkerUpdate() { public static useSceneMarkerUpdate() {
return GQL.useSceneMarkerUpdate({ refetchQueries: ["FindScene"] }); return GQL.useSceneMarkerUpdate({ refetchQueries: ["FindScene"] });
} }
public static useSceneMarkerDestroy() { public static useSceneMarkerDestroy() {
return GQL.useSceneMarkerDestroy({ refetchQueries: ["FindScene"] }); return GQL.useSceneMarkerDestroy({ refetchQueries: ["FindScene"] });
} }
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 }}); return GQL.useScrapePerformerList({ variables: { scraper_id: scraperId, query: q }, skip: q === ''});
} }
public static useScrapePerformer(scraperId: string, scrapedPerformer : GQL.ScrapedPerformerInput) { public static useScrapePerformer(scraperId: string, scrapedPerformer : GQL.ScrapedPerformerInput) {
return GQL.useScrapePerformer({ variables: { scraper_id: scraperId, scraped_performer: scrapedPerformer }}); 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) { return GQL.useScrapeFreeonesPerformers({ variables: { q } }); }
@@ -261,13 +260,13 @@ 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)
}); });
@@ -289,7 +288,7 @@ 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"]
@@ -306,18 +305,18 @@ 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 : input }}); return GQL.useScenesUpdate({ variables: { input : 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)
}); });
@@ -330,22 +329,22 @@ 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)
}); });
} }
@@ -358,21 +357,21 @@ 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)
}); });
@@ -520,18 +519,5 @@ export class StashService {
}); });
} }
public static nullToUndefined(value: any): any {
if (_.isPlainObject(value)) {
return _.mapValues(value, StashService.nullToUndefined);
}
if (Array.isArray(value)) {
return value.map(StashService.nullToUndefined);
}
if (value === null) {
return undefined;
}
return value;
}
private constructor() {} private constructor() {}
} }

View File

@@ -1,7 +1,7 @@
import { Spinner } from "@blueprintjs/core";
import _ from "lodash"; import _ from "lodash";
import queryString from "query-string"; import queryString from "query-string";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Spinner } from 'react-bootstrap';
import { QueryHookResult } from "react-apollo-hooks"; import { QueryHookResult } from "react-apollo-hooks";
import { ListFilter } from "../components/list/ListFilter"; import { ListFilter } from "../components/list/ListFilter";
import { Pagination } from "../components/list/Pagination"; import { Pagination } from "../components/list/Pagination";
@@ -201,7 +201,7 @@ export class ListHook {
function singleSelect(id: string, selected: boolean) { function singleSelect(id: string, selected: boolean) {
setLastClickedId(id); setLastClickedId(id);
const newSelectedIds = _.clone(selectedIds); const newSelectedIds = _.clone(selectedIds);
if (selected) { if (selected) {
newSelectedIds.add(id); newSelectedIds.add(id);
@@ -215,7 +215,7 @@ export class ListHook {
function multiSelect(id: string, selected : boolean) { function multiSelect(id: string, selected : boolean) {
let startIndex = 0; let startIndex = 0;
let thisIndex = -1; let thisIndex = -1;
if (!!lastClickedId) { if (!!lastClickedId) {
startIndex = getItems().findIndex((item) => { startIndex = getItems().findIndex((item) => {
return item.id === lastClickedId; return item.id === lastClickedId;
@@ -228,14 +228,14 @@ export class ListHook {
selectRange(startIndex, thisIndex); selectRange(startIndex, thisIndex);
} }
function selectRange(startIndex : number, endIndex : number) { function selectRange(startIndex : number, endIndex : number) {
if (startIndex > endIndex) { if (startIndex > endIndex) {
let tmp = startIndex; let tmp = startIndex;
startIndex = endIndex; startIndex = endIndex;
endIndex = tmp; endIndex = tmp;
} }
const subset = getItems().slice(startIndex, endIndex + 1); const subset = getItems().slice(startIndex, endIndex + 1);
const newSelectedIds : Set<string> = new Set(); const newSelectedIds : Set<string> = new Set();
@@ -293,7 +293,7 @@ export class ListHook {
filter={filter} filter={filter}
/> />
{options.renderSelectedOptions && selectedIds.size > 0 ? options.renderSelectedOptions(result, selectedIds) : undefined} {options.renderSelectedOptions && selectedIds.size > 0 ? options.renderSelectedOptions(result, selectedIds) : undefined}
{result.loading ? <Spinner size={Spinner.SIZE_LARGE} /> : 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

View File

@@ -5,6 +5,7 @@ interface IToast {
header?: string; header?: string;
content: JSX.Element|string; content: JSX.Element|string;
delay?: number; delay?: number;
variant?: 'success'|'danger'|'warning'|'info';
} }
interface IActiveToast extends IToast { interface IActiveToast extends IToast {
id: number; id: number;
@@ -21,7 +22,13 @@ export const ToastProvider: React.FC = ({children}) => {
); );
const toastItems = toasts.map(toast => ( const toastItems = toasts.map(toast => (
<Toast key={toast.id} onClose={() => removeToast(toast.id)} autohide delay={toast.delay ?? 5000}> <Toast
autohide
key={toast.id}
onClose={() => removeToast(toast.id)}
className={toast.variant ?? 'success'}
delay={toast.delay ?? 5000}
>
<Toast.Header> <Toast.Header>
<span className="mr-auto"> <span className="mr-auto">
{ toast.header ?? 'Stash' } { toast.header ?? 'Stash' }
@@ -30,7 +37,7 @@ export const ToastProvider: React.FC = ({children}) => {
<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++ }])
); );
@@ -47,7 +54,17 @@ export const ToastProvider: React.FC = ({children}) => {
const useToasts = () => { const useToasts = () => {
const setToast = useContext(ToastContext); const setToast = useContext(ToastContext);
return setToast; return {
success: setToast,
error: (error: Error) => {
console.error(error.message);
setToast({
variant: 'danger',
header: 'Error',
content: error.message ?? error.toString()
});
}
};
} }
export default useToasts; export default useToasts;

View File

@@ -0,0 +1,4 @@
export { default as useToast } from './Toast';
export { useInterfaceLocalForage } from './LocalForage';
export { VideoHoverHook } from './VideoHover';
export { ListHook } from './ListHook';

View File

@@ -4,7 +4,6 @@
@import "styles/shared/details"; @import "styles/shared/details";
@import "styles/blueprint-overrides";
@import "styles/scrollbars"; @import "styles/scrollbars";
@import "styles/variables"; @import "styles/variables";
@@ -392,7 +391,7 @@ span.block {
& .inputs label { & .inputs label {
width: 12em; width: 12em;
} }
& .inputs .bp3-input-group { & .inputs .bp3-input-group {
width: 80ch; width: 80ch;
} }
@@ -400,11 +399,11 @@ span.block {
& .scene-parser-results { & .scene-parser-results {
overflow-x: auto; overflow-x: auto;
} }
& .scene-parser-row .bp3-checkbox { & .scene-parser-row .bp3-checkbox {
margin: 0px -20px 0px 0px; margin: 0px -20px 0px 0px;
} }
& .scene-parser-row .parser-field-title input { & .scene-parser-row .parser-field-title input {
width: 50ch; width: 50ch;
} }
@@ -424,15 +423,15 @@ span.block {
& .scene-parser-row .parser-field-studio input { & .scene-parser-row .parser-field-studio input {
width: 15ch; width: 15ch;
} }
& .scene-parser-row input { & .scene-parser-row input {
min-width: 10ch; min-width: 10ch;
} }
& .scene-parser-row .bp3-form-group { & .scene-parser-row .bp3-form-group {
margin-bottom: 0px; margin-bottom: 0px;
} }
& .scene-parser-row div:first-child > input { & .scene-parser-row div:first-child > input {
margin-bottom: 5px; margin-bottom: 5px;
} }

View File

@@ -1,4 +1,4 @@
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { ILabeledId, ILabeledValue } from "../types"; import { ILabeledId, ILabeledValue } from "../types";
export type CriterionType = export type CriterionType =

View File

@@ -1,4 +1,4 @@
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import {
Criterion, Criterion,
CriterionType, CriterionType,

View File

@@ -1,4 +1,4 @@
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import {
Criterion, Criterion,
CriterionType, CriterionType,

View File

@@ -1,4 +1,4 @@
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import {
Criterion, Criterion,
CriterionType, CriterionType,

View File

@@ -1,4 +1,4 @@
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import {
Criterion, Criterion,
CriterionType, CriterionType,

View File

@@ -1,4 +1,4 @@
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { ILabeledId } from "../types"; import { ILabeledId } from "../types";
import { import {
Criterion, Criterion,

View File

@@ -1,4 +1,4 @@
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import {
Criterion, Criterion,
CriterionType, CriterionType,

View File

@@ -1,4 +1,4 @@
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import {
Criterion, Criterion,
CriterionType, CriterionType,

View File

@@ -1,4 +1,4 @@
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { ILabeledId } from "../types"; import { ILabeledId } from "../types";
import { import {
Criterion, Criterion,

View File

@@ -1,5 +1,5 @@
import * as GQL from "../../../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { ILabeledId } from "../types"; import { ILabeledId } from "../types";
import { import {
Criterion, Criterion,

View File

@@ -1,6 +1,6 @@
import { import {
CriterionModifier, CriterionModifier,
} from "../../../core/generated-graphql"; } from "src/core/generated-graphql";
import { Criterion, CriterionType, StringCriterion, NumberCriterion } from "./criterion"; import { Criterion, CriterionType, StringCriterion, NumberCriterion } from "./criterion";
import { FavoriteCriterion } from "./favorite"; import { FavoriteCriterion } from "./favorite";
import { HasMarkersCriterion } from "./has-markers"; import { HasMarkersCriterion } from "./has-markers";
@@ -24,7 +24,7 @@ export function makeCriteria(type: CriterionType = "none") {
case "sceneTags": return new TagsCriterion("sceneTags"); case "sceneTags": return new TagsCriterion("sceneTags");
case "performers": return new PerformersCriterion(); case "performers": return new PerformersCriterion();
case "studios": return new StudiosCriterion(); case "studios": return new StudiosCriterion();
case "birth_year": case "birth_year":
case "age": case "age":
var ret = new NumberCriterion(type, type); var ret = new NumberCriterion(type, type);
@@ -36,7 +36,7 @@ export function makeCriteria(type: CriterionType = "none") {
Criterion.getModifierOption(CriterionModifier.LessThan) Criterion.getModifierOption(CriterionModifier.LessThan)
]; ];
return ret; return ret;
case "ethnicity": case "ethnicity":
case "country": case "country":
case "eye_color": case "eye_color":
case "height": case "height":

View File

@@ -6,7 +6,7 @@ import {
SceneFilterType, SceneFilterType,
SceneMarkerFilterType, SceneMarkerFilterType,
SortDirectionEnum, SortDirectionEnum,
} from "../../core/generated-graphql"; } from "src/core/generated-graphql";
import { Criterion, ICriterionOption, CriterionType, CriterionOption, NumberCriterion, StringCriterion } from "./criteria/criterion"; import { Criterion, ICriterionOption, CriterionType, CriterionOption, NumberCriterion, StringCriterion } from "./criteria/criterion";
import { FavoriteCriterion, FavoriteCriterionOption } from "./criteria/favorite"; import { FavoriteCriterion, FavoriteCriterionOption } from "./criteria/favorite";
import { HasMarkersCriterion, HasMarkersCriterionOption } from "./criteria/has-markers"; import { HasMarkersCriterion, HasMarkersCriterionOption } from "./criteria/has-markers";

View File

@@ -1,30 +0,0 @@
.bp3-popover-content {
padding: 10px;
max-width: 33vw;
min-width: 200px;
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.bp3-select-popover .bp3-menu {
max-width: 400px;
max-height: 300px;
overflow: auto;
padding: 0;
}
.bp3-multi-select-popover .bp3-menu {
max-width: 400px;
max-height: 300px;
overflow: auto;
padding: 0;
}
.filter-container .bp3-portal {
position: relative;
}
.card-section.centered .bp3-popover-wrapper {
flex: unset !important;
}

View File

@@ -1,12 +0,0 @@
export class ColorUtils {
public static classForRating(rating: number): string {
switch (rating) {
case 5: return "rating-5";
case 4: return "rating-4";
case 3: return "rating-3";
case 2: return "rating-2";
case 1: return "rating-1";
default: return "";
}
}
}

View File

@@ -1,71 +0,0 @@
import { HTMLSelect, InputGroup, IOptionProps, TextArea, Label } from "@blueprintjs/core";
import React from "react";
export class EditableTextUtils {
public static renderTextArea(options: {
value: string | undefined,
isEditing: boolean,
onChange: ((value: string) => void)
}) {
let element: JSX.Element;
if (options.isEditing) {
element = (
<TextArea
fill={true}
onChange={(newValue) => options.onChange(newValue.target.value)}
value={options.value}
/>
);
} else {
element = <p className="pre">{options.value}</p>;
}
return element;
}
public static renderInputGroup(options: {
value: string | undefined,
isEditing: boolean,
placeholder?: string,
onChange: ((value: string) => void),
}) {
let element: JSX.Element;
if (options.isEditing) {
element = (
<InputGroup
onChange={(newValue: any) => options.onChange(newValue.target.value)}
value={options.value}
placeholder={options.placeholder}
/>
);
} else {
element = <Label>{options.value}</Label>;
}
return element;
}
public static renderHtmlSelect(options: {
value: string | number | undefined,
isEditing: boolean,
onChange: ((value: string) => void),
selectOptions: Array<string | number | IOptionProps>,
}) {
let stringValue = options.value;
if (typeof stringValue === "number") {
stringValue = stringValue.toString();
}
let element: JSX.Element;
if (options.isEditing) {
element = (
<HTMLSelect
options={options.selectOptions}
onChange={(event) => options.onChange(event.target.value)}
value={stringValue}
/>
);
} else {
element = <span>{options.value}</span>;
}
return element;
}
}

View File

@@ -1,24 +0,0 @@
import { Intent, Position, Toaster } from "@blueprintjs/core";
import { ApolloError } from "apollo-boost";
const toaster = Toaster.create({
position: Position.TOP,
});
export class ErrorUtils {
public static handle(error: any) {
console.error(error);
toaster.show({
message: error.toString(),
intent: Intent.DANGER,
});
}
public static handleApolloError(error: ApolloError) {
console.error(error);
toaster.show({
message: error.message,
intent: Intent.DANGER,
});
}
}

View File

@@ -1,34 +1,37 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
export class ImageUtils { const readImage = (file: File, onLoadEnd: (this: FileReader) => void) => {
const reader: FileReader = new FileReader();
private static readImage(file: File, onLoadEnd: (this: FileReader) => any) { reader.onloadend = onLoadEnd;
const reader: FileReader = new FileReader(); reader.readAsDataURL(file);
reader.onloadend = onLoadEnd;
reader.readAsDataURL(file);
}
public static onImageChange(event: React.FormEvent<HTMLInputElement>, onLoadEnd: (this: FileReader) => any) {
const file: File = (event.target as any).files[0];
ImageUtils.readImage(file, onLoadEnd);
}
public static pasteImage(e : any, onLoadEnd: (this: FileReader) => any) {
if (e.clipboardData.files.length === 0) {
return;
}
const file: File = e.clipboardData.files[0];
ImageUtils.readImage(file, onLoadEnd);
}
public static addPasteImageHook(onLoadEnd: (this: FileReader) => any) {
useEffect(() => {
const pasteImage = (e: any) => { ImageUtils.pasteImage(e, onLoadEnd) }
window.addEventListener("paste", pasteImage);
return () => window.removeEventListener("paste", pasteImage);
});
}
} }
const pasteImage = (event: ClipboardEvent, onLoadEnd: (this: FileReader) => void) => {
const files = event?.clipboardData?.files;
if(!files?.length)
return;
const file = files[0];
readImage(file, onLoadEnd);
}
const onImageChange = (event: React.FormEvent<HTMLInputElement>, onLoadEnd: (this: FileReader) => void) => {
const file = event?.currentTarget?.files?.[0];
if(file)
readImage(file, onLoadEnd);
}
const usePasteImage = (onLoadEnd: (this: FileReader) => void) => {
useEffect(() => {
const paste = (event: ClipboardEvent) => ( pasteImage(event, onLoadEnd) );
document.addEventListener("paste", paste);
return () => document.removeEventListener("paste", paste);
});
}
const Image = {
onImageChange,
usePasteImage
}
export default Image;

View File

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

View File

@@ -5,45 +5,57 @@ 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";
export class NavigationUtils { const makePerformerScenesUrl = (performer: Partial<GQL.PerformerDataFragment>) => {
public static makePerformerScenesUrl(performer: Partial<GQL.PerformerDataFragment>): string { if (!performer.id)
if (performer.id === undefined) { return "#"; } 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()}`;
}
public static makeStudioScenesUrl(studio: Partial<GQL.StudioDataFragment>): string {
if (studio.id === undefined) { return "#"; }
const filter = new ListFilterModel(FilterMode.Scenes);
const criterion = new StudiosCriterion();
criterion.value = [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }];
filter.criteria.push(criterion);
return `/scenes?${filter.makeQueryParameters()}`;
}
public static makeTagScenesUrl(tag: Partial<GQL.TagDataFragment>): string {
if (tag.id === undefined) { return "#"; }
const filter = new ListFilterModel(FilterMode.Scenes);
const criterion = new TagsCriterion("tags");
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
filter.criteria.push(criterion);
return `/scenes?${filter.makeQueryParameters()}`;
}
public static makeTagSceneMarkersUrl(tag: Partial<GQL.TagDataFragment>): string {
if (tag.id === undefined) { return "#"; }
const filter = new ListFilterModel(FilterMode.SceneMarkers);
const criterion = new TagsCriterion("tags");
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
filter.criteria.push(criterion);
return `/scenes/markers?${filter.makeQueryParameters()}`;
}
public static makeSceneMarkerUrl(sceneMarker: Partial<GQL.SceneMarkerDataFragment>): string {
if (sceneMarker.id === undefined || sceneMarker.scene === undefined) { return "#"; }
return `/scenes/${sceneMarker.scene.id}?t=${sceneMarker.seconds}`;
}
} }
const makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (!studio.id)
return "#";
const filter = new ListFilterModel(FilterMode.Scenes);
const criterion = new StudiosCriterion();
criterion.value = [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }];
filter.criteria.push(criterion);
return `/scenes?${filter.makeQueryParameters()}`;
}
const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (!tag.id)
return "#";
const filter = new ListFilterModel(FilterMode.Scenes);
const criterion = new TagsCriterion("tags");
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
filter.criteria.push(criterion);
return `/scenes?${filter.makeQueryParameters()}`;
}
const makeTagSceneMarkersUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (!tag.id)
return "#";
const filter = new ListFilterModel(FilterMode.SceneMarkers);
const criterion = new TagsCriterion("tags");
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
filter.criteria.push(criterion);
return `/scenes/markers?${filter.makeQueryParameters()}`;
}
const makeSceneMarkerUrl = (sceneMarker: Partial<GQL.SceneMarkerDataFragment>) => {
if (!sceneMarker.id || !sceneMarker.scene)
return "#";
return `/scenes/${sceneMarker.scene.id}?t=${sceneMarker.seconds}`;
}
const Nav = {
makePerformerScenesUrl,
makeStudioScenesUrl,
makeTagSceneMarkersUrl,
makeTagScenesUrl,
makeSceneMarkerUrl
}
export default Nav;

View File

@@ -1,137 +1,133 @@
import { EditableText, IOptionProps } from "@blueprintjs/core";
import { Form } from 'react-bootstrap';
import React from "react"; import React from "react";
import { EditableTextUtils } from "./editabletext"; import { Form } from 'react-bootstrap';
import { FilterSelect } from "../components/select/FilterSelect"; import { FilterSelect } from "src/components/Shared";
import _ from "lodash";
export class TableUtils { const renderEditableTextTableRow = (options: {
public static renderEditableTextTableRow(options: { title: string;
title: string; value?: string | number;
value: string | number | undefined; isEditing: boolean;
isEditing: boolean; onChange: ((value: string) => void);
onChange: ((value: string) => void); }) => (
}) { <tr>
let stringValue = options.value; <td>{options.title}</td>
if (typeof stringValue === "number") { <td>
stringValue = stringValue.toString(); <Form.Control
} readOnly={!options.isEditing}
return ( plaintext={!options.isEditing}
<tr> onChange={(event: React.FormEvent<HTMLInputElement>) => ( options.onChange(event.currentTarget.value) )}
<td>{options.title}</td> value={typeof options.value === 'number' ? options.value.toString() : options.value}
<td> placeholder={options.title}
<EditableText />
disabled={!options.isEditing} </td>
value={stringValue} </tr>
placeholder={options.title} )
multiline={true}
onChange={(newValue) => options.onChange(newValue)}
/>
</td>
</tr>
);
}
public static 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>
return ( <td>{options.title}</td>
<tr> <td>
<td>{options.title}</td> <Form.Control
<td> as="textarea"
{EditableTextUtils.renderTextArea(options)} readOnly={!options.isEditing}
</td> plaintext={!options.isEditing}
</tr> onChange={(event: React.FormEvent<HTMLTextAreaElement>) => ( options.onChange(event.currentTarget.value) )}
); value={options.value}
} />
</td>
</tr>
)
public static 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),
}) { }) => (
let optionsCopy = _.clone(options); <tr>
optionsCopy.placeholder = options.placeholder || options.title; <td>{options.title}</td>
return ( <td>
<tr> <Form.Control
<td>{options.title}</td> readOnly={!options.isEditing}
<td> plaintext={!options.isEditing}
{ !options.isEditing defaultValue={options.value}
? <h4>{optionsCopy.value}</h4> placeholder={options.placeholder ?? options.title}
: <Form.Control onChange={(event: React.FormEvent<HTMLInputElement>) => ( options.onChange(event.currentTarget.value) )}
defaultValue={options.value} />
placeholder={optionsCopy.placeholder} </td>
onChange={ (event:any) => options.onChange(event.target.value) } </tr>
/> )
}
</td>
</tr>
);
}
public static renderHtmlSelect(options: { const renderHtmlSelect = (options: {
title: string, title: string,
value: string | number | undefined, value?: string | number,
isEditing: boolean, isEditing: boolean,
onChange: ((value: string) => void), onChange: ((value: string) => void),
selectOptions: Array<string | number | IOptionProps>, selectOptions: Array<string | number>,
}) { }) => (
return ( <tr>
<tr> <td>{options.title}</td>
<td>{options.title}</td> <td>
<td> <Form.Control
{EditableTextUtils.renderHtmlSelect(options)} as="select"
</td> readOnly={!options.isEditing}
</tr> plaintext={!options.isEditing}
); onChange={(event: React.FormEvent<HTMLSelectElement>) => ( options.onChange(event.currentTarget.value) )}
} />
</td>
</tr>
)
// TODO: isediting // TODO: isediting
public static 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),
}) { }) => (
return ( <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
public static 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),
}) { }) => (
return ( <tr>
<tr> <td>{options.title}</td>
<td>{options.title}</td> <td>
<td> <FilterSelect
<FilterSelect type={options.type}
type={options.type} isMulti={true}
isMulti={true} 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 = {
renderEditableTextTableRow,
renderTextArea,
renderInputGroup,
renderHtmlSelect,
renderFilterSelect,
renderMultiSelect
} }
export default Table;

View File

@@ -1,83 +1,98 @@
export class TextUtils { const Units = [
"bytes",
"kB",
"MB",
"GB",
"TB",
"PB",
];
public static truncate(value?: string, limit: number = 100, tail: string = "..."): string { const truncate = (value?: string, limit: number = 100, tail: string = "...") => {
if (!value) { return ""; } if (!value)
return value.length > limit ? value.substring(0, limit) + tail : value; return "";
} return value.length > limit
? value.substring(0, limit) + tail
public static fileSize(bytes: number = 0, precision: number = 2): string { : value;
if (isNaN(parseFloat(String(bytes))) || !isFinite(bytes)) { return "?"; }
let unit = 0;
while ( bytes >= 1024 ) {
bytes /= 1024;
unit++;
}
return bytes.toFixed(+precision) + " " + this.units[unit];
}
public static secondsToTimestamp(seconds: number): string {
let ret = new Date(seconds * 1000).toISOString().substr(11, 8);
if (ret.startsWith("00")) {
// strip hours if under one hour
ret = ret.substr(3);
}
if (ret.startsWith("0")) {
// for duration under a minute, leave one leading zero
ret = ret.substr(1);
}
return ret;
}
public static fileNameFromPath(path: string): string {
if (!!path === false) { return "No File Name"; }
return path.replace(/^.*[\\\/]/, "");
}
public static age(dateString?: string, fromDateString?: string): number {
if (!dateString) { return 0; }
const birthdate = new Date(dateString);
const fromDate = !!fromDateString ? new Date(fromDateString) : new Date();
let age = fromDate.getFullYear() - birthdate.getFullYear();
if (birthdate.getMonth() > fromDate.getMonth() ||
(birthdate.getMonth() >= fromDate.getMonth() && birthdate.getDay() > fromDate.getDay())) {
age -= 1;
}
return age;
}
public static bitRate(bitrate: number) {
const megabits = bitrate / 1000000;
return `${megabits.toFixed(2)} megabits per second`;
}
public static resolution(height: number) {
if (height >= 240 && height < 480) {
return "240p";
} else if (height >= 480 && height < 720) {
return "480p";
} else if (height >= 720 && height < 1080) {
return "720p";
} else if (height >= 1080 && height < 2160) {
return "1080p";
} else if (height >= 2160) {
return "4K";
} else {
return undefined;
}
}
private static units = [
"bytes",
"kB",
"MB",
"GB",
"TB",
"PB",
];
} }
const fileSize = (bytes: number = 0, precision: number = 2) => {
if (Number.isNaN(parseFloat(String(bytes))) || !isFinite(bytes))
return "?";
let unit = 0;
while ( bytes >= 1024 ) {
bytes /= 1024;
unit++;
}
return `${bytes.toFixed(+precision)} ${Units[unit]}`;
}
const secondsToTimestamp = (seconds: number) => {
let ret = new Date(seconds * 1000).toISOString().substr(11, 8);
if (ret.startsWith("00")) {
// strip hours if under one hour
ret = ret.substr(3);
}
if (ret.startsWith("0")) {
// for duration under a minute, leave one leading zero
ret = ret.substr(1);
}
return ret;
}
const fileNameFromPath = (path: string) => {
if (!!path === false)
return "No File Name";
return path.replace(/^.*[\\/]/, "");
}
const age = (dateString?: string, fromDateString?: string) => {
if (!dateString)
return 0;
const birthdate = new Date(dateString);
const fromDate = fromDateString ? new Date(fromDateString) : new Date();
let age = fromDate.getFullYear() - birthdate.getFullYear();
if (birthdate.getMonth() > fromDate.getMonth() ||
(birthdate.getMonth() >= fromDate.getMonth() && birthdate.getDay() > fromDate.getDay())) {
age -= 1;
}
return age;
}
const bitRate = (bitrate: number) => {
const megabits = bitrate / 1000000;
return `${megabits.toFixed(2)} megabits per second`;
}
const resolution = (height: number) => {
if (height >= 240 && height < 480) {
return "240p";
} else if (height >= 480 && height < 720) {
return "480p";
} else if (height >= 720 && height < 1080) {
return "720p";
} else if (height >= 1080 && height < 2160) {
return "1080p";
} else if (height >= 2160) {
return "4K";
} else {
return undefined;
}
}
const TextUtils = {
truncate,
fileSize,
secondsToTimestamp,
fileNameFromPath,
age,
bitRate,
resolution
}
export default TextUtils;

View File

@@ -1,14 +0,0 @@
import { Intent, Position, Toaster } from "@blueprintjs/core";
const toaster = Toaster.create({
position: Position.TOP,
});
export class ToastUtils {
public static success(message: string) {
toaster.show({
message,
intent: Intent.SUCCESS,
});
}
}

View File

@@ -1,6 +0,0 @@
export class ZoomUtils {
public static classForZoom(zoomIndex: number): string {
return "zoom-" + zoomIndex;
}
}

View File

@@ -18,11 +18,11 @@
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "preserve", "jsx": "preserve",
"downlevelIteration": true, "downlevelIteration": true,
"experimentalDecorators": true "experimentalDecorators": true,
"baseUrl": "."
}, },
"include": [ "include": [
"src" "src/**/*"
] ]
} }

View File

@@ -953,57 +953,6 @@
lodash "^4.17.13" lodash "^4.17.13"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@blueprintjs/core@3.22.1":
version "3.22.1"
resolved "https://registry.yarnpkg.com/@blueprintjs/core/-/core-3.22.1.tgz#2fd1e985c46440f16dbe7136f563c4e833e2f34d"
integrity sha512-egM1jyE+O4X/HvgCDZ96DDvwIlC/z9J1vBVYgnZmeGHPhjCOnmc8bRqlifmPMTBwO6xl/1H67gCp/GEhyvu0EA==
dependencies:
"@blueprintjs/icons" "^3.12.0"
"@types/dom4" "^2.0.1"
classnames "^2.2"
dom4 "^2.1.5"
normalize.css "^8.0.1"
popper.js "^1.15.0"
react-lifecycles-compat "^3.0.4"
react-popper "^1.3.3"
react-transition-group "^2.9.0"
resize-observer-polyfill "^1.5.1"
tslib "~1.9.0"
"@blueprintjs/core@^3.20.0":
version "3.22.3"
resolved "https://registry.yarnpkg.com/@blueprintjs/core/-/core-3.22.3.tgz#57dc2c072a17db0e52cc5679d8bbc016082b27e7"
integrity sha512-IaxkvJyF+4VCvAjMvyHtJ4qUiQNwgPu4zIxLRo1cBsu30gHjYzwe+xDdssBR7yRnXARguM6bkI523w+yJOerCA==
dependencies:
"@blueprintjs/icons" "^3.12.0"
"@types/dom4" "^2.0.1"
classnames "^2.2"
dom4 "^2.1.5"
normalize.css "^8.0.1"
popper.js "^1.15.0"
react-lifecycles-compat "^3.0.4"
react-popper "^1.3.7"
react-transition-group "^2.9.0"
resize-observer-polyfill "^1.5.1"
tslib "~1.9.0"
"@blueprintjs/icons@^3.12.0":
version "3.13.0"
resolved "https://registry.yarnpkg.com/@blueprintjs/icons/-/icons-3.13.0.tgz#bff93a9ea5ced03afd2b3a65ddf4bb90bda485e4"
integrity sha512-fvXGsAJ66SSjeHv3OeXjLEdKdPJ3wVztjhJQCAd51uebhj3FJ16EDDvO7BqBw5FyVkLkU11KAxSoCFZt7TC9GA==
dependencies:
classnames "^2.2"
tslib "~1.9.0"
"@blueprintjs/select@3.11.2":
version "3.11.2"
resolved "https://registry.yarnpkg.com/@blueprintjs/select/-/select-3.11.2.tgz#3324db0de44a9f386b957aac1ba3ec774b3eb1e7"
integrity sha512-fU0Km6QI/ayWhzYeu9N1gTj0+L0XUO4KB3u2LfJXgj648UGY8F4HX2ETdJ+XPdtsu6TesrIL7ghMQhtLcvafBg==
dependencies:
"@blueprintjs/core" "^3.20.0"
classnames "^2.2"
tslib "~1.9.0"
"@cnakazawa/watch@^1.0.3": "@cnakazawa/watch@^1.0.3":
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.3.tgz#099139eaec7ebf07a27c1786a3ff64f39464d2ef" resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.3.tgz#099139eaec7ebf07a27c1786a3ff64f39464d2ef"
@@ -1483,10 +1432,10 @@
dependencies: dependencies:
"@types/babel-types" "*" "@types/babel-types" "*"
"@types/dom4@^2.0.1": "@types/classnames@^2.2.9":
version "2.0.1" version "2.2.9"
resolved "https://registry.yarnpkg.com/@types/dom4/-/dom4-2.0.1.tgz#506d5781b9bcab81bd9a878b198aec7dee2a6033" resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.9.tgz#d868b6febb02666330410fe7f58f3c4b8258be7b"
integrity sha512-kSkVAvWmMZiCYtvqjqQEwOmvKwcH+V4uiv3qPQ8pAh1Xl39xggGEo8gHUqV4waYGHezdFw0rKBR8Jt0CrQSDZA== integrity sha512-MNl+rT5UmZeilaPxAVs6YaPC2m6aA8rofviZbhbxpPpl61uKodfdQVsBtgJGTqGizEf02oW3tsVe7FYB8kK14A==
"@types/eslint-visitor-keys@^1.0.0": "@types/eslint-visitor-keys@^1.0.0":
version "1.0.0" version "1.0.0"
@@ -3209,7 +3158,7 @@ class-utils@^0.3.5:
isobject "^3.0.0" isobject "^3.0.0"
static-extend "^0.1.1" static-extend "^0.1.1"
classnames@^2.2, classnames@^2.2.6: classnames@^2.2.6:
version "2.2.6" version "2.2.6"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
@@ -3647,14 +3596,6 @@ create-react-context@^0.2.2:
fbjs "^0.8.0" fbjs "^0.8.0"
gud "^1.0.0" gud "^1.0.0"
create-react-context@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.3.0.tgz#546dede9dc422def0d3fc2fe03afe0bc0f4f7d8c"
integrity sha512-dNldIoSuNSvlTJ7slIKC/ZFGKexBMBrrcc+TTe1NdmROnaASuLPvqpwj9v4XS4uXZ8+YPu0sNmShX2rXI5LNsw==
dependencies:
gud "^1.0.0"
warning "^4.0.3"
cross-fetch@2.2.2: cross-fetch@2.2.2:
version "2.2.2" version "2.2.2"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-2.2.2.tgz#a47ff4f7fc712daba8f6a695a11c948440d45723" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-2.2.2.tgz#a47ff4f7fc712daba8f6a695a11c948440d45723"
@@ -4001,7 +3942,7 @@ decode-uri-component@^0.2.0:
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
deep-equal@^1.0.1, deep-equal@^1.1.1: deep-equal@^1.0.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a"
integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g== integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==
@@ -4242,11 +4183,6 @@ dom-walk@^0.1.0:
resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018" resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018"
integrity sha1-ZyIm3HTI95mtNTB9+TaroRrNYBg= integrity sha1-ZyIm3HTI95mtNTB9+TaroRrNYBg=
dom4@^2.1.5:
version "2.1.5"
resolved "https://registry.yarnpkg.com/dom4/-/dom4-2.1.5.tgz#f98a94eb67b340f0fa5b42b0ee9c38cda035428e"
integrity sha512-gJbnVGq5zaBUY0lUh0LUEVGYrtN75Ks8ZwpwOYvnVFrKy/qzXK4R/1WuLIFExWj/tBxbRAkTzZUGJHXmqsBNjQ==
domain-browser@^1.1.1: domain-browser@^1.1.1:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
@@ -9006,7 +8942,7 @@ pnp-webpack-plugin@1.5.0:
dependencies: dependencies:
ts-pnp "^1.1.2" ts-pnp "^1.1.2"
popper.js@^1.14.4, popper.js@^1.15.0, popper.js@^1.16.0: popper.js@^1.15.0, popper.js@^1.16.0:
version "1.16.0" version "1.16.0"
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.0.tgz#2e1816bcbbaa518ea6c2e15a466f4cb9c6e2fbb3" resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.0.tgz#2e1816bcbbaa518ea6c2e15a466f4cb9c6e2fbb3"
integrity sha512-+G+EkOPoE5S/zChTpmBSSDYmhXJ5PsW8eMhH8cP/CQHMFPBG/kC9Y5IIw6qNYgdJ+/COf0ddY2li28iHaZRSjw== integrity sha512-+G+EkOPoE5S/zChTpmBSSDYmhXJ5PsW8eMhH8cP/CQHMFPBG/kC9Y5IIw6qNYgdJ+/COf0ddY2li28iHaZRSjw==
@@ -10126,19 +10062,6 @@ react-photo-gallery@7.0.2:
prop-types "~15.7.2" prop-types "~15.7.2"
resize-observer-polyfill "^1.5.0" resize-observer-polyfill "^1.5.0"
react-popper@^1.3.3, react-popper@^1.3.7:
version "1.3.7"
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.3.7.tgz#f6a3471362ef1f0d10a4963673789de1baca2324"
integrity sha512-nmqYTx7QVjCm3WUZLeuOomna138R1luC4EqkW3hxJUrAe+3eNz3oFCLYdnPwILfn0mX1Ew2c3wctrjlUMYYUww==
dependencies:
"@babel/runtime" "^7.1.2"
create-react-context "^0.3.0"
deep-equal "^1.1.1"
popper.js "^1.14.4"
prop-types "^15.6.1"
typed-styles "^0.0.7"
warning "^4.0.2"
react-prop-toggle@^1.0.2: react-prop-toggle@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/react-prop-toggle/-/react-prop-toggle-1.0.2.tgz#8b0b7e74653606b1427cfcf6c4eaa9198330568e" resolved "https://registry.yarnpkg.com/react-prop-toggle/-/react-prop-toggle-1.0.2.tgz#8b0b7e74653606b1427cfcf6c4eaa9198330568e"
@@ -10262,7 +10185,7 @@ react-select@^3.0.8:
react-input-autosize "^2.2.2" react-input-autosize "^2.2.2"
react-transition-group "^2.2.1" react-transition-group "^2.2.1"
react-transition-group@2, react-transition-group@^2.2.1, react-transition-group@^2.9.0: react-transition-group@2, react-transition-group@^2.2.1:
version "2.9.0" version "2.9.0"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"
integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg== integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==
@@ -10589,7 +10512,7 @@ requires-port@^1.0.0:
resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
resize-observer-polyfill@^1.5.0, resize-observer-polyfill@^1.5.1: resize-observer-polyfill@^1.5.0:
version "1.5.1" version "1.5.1"
resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
@@ -11866,11 +11789,6 @@ tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
tslib@~1.9.0:
version "1.9.3"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286"
integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==
tsutils@^3.17.1: tsutils@^3.17.1:
version "3.17.1" version "3.17.1"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759"
@@ -11925,11 +11843,6 @@ type@^2.0.0:
resolved "https://registry.yarnpkg.com/type/-/type-2.0.0.tgz#5f16ff6ef2eb44f260494dae271033b29c09a9c3" resolved "https://registry.yarnpkg.com/type/-/type-2.0.0.tgz#5f16ff6ef2eb44f260494dae271033b29c09a9c3"
integrity sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow== integrity sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow==
typed-styles@^0.0.7:
version "0.0.7"
resolved "https://registry.yarnpkg.com/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9"
integrity sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q==
typedarray@^0.0.6: typedarray@^0.0.6:
version "0.0.6" version "0.0.6"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
@@ -12260,7 +12173,7 @@ warning@^3.0.0:
dependencies: dependencies:
loose-envify "^1.0.0" loose-envify "^1.0.0"
warning@^4.0.2, warning@^4.0.3: warning@^4.0.3:
version "4.0.3" version "4.0.3"
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==