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

@@ -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} />

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();
@@ -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"
helperText="Directory locations to your content"
>
<FolderSelect <FolderSelect
directories={stashes} directories={stashes}
onDirectoriesChanged={onStashesChanged} onDirectoriesChanged={onStashesChanged}
/> />
</FormGroup> <Form.Text className="text-muted">Directory locations to your content</Form.Text>
</FormGroup> </Form.Group>
<FormGroup <Form.Group id="database-path">
label="Database Path" <Form.Label>Database Path</Form.Label>
helperText="File location for the SQLite database (requires restart)" <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={databasePath} onChange={(e: any) => setDatabasePath(e.target.value)} /> </Form.Group>
</FormGroup>
<FormGroup <Form.Group id="generated-path">
label="Generated Path" <Form.Label>Generated Path</Form.Label>
helperText="Directory location for the generated files (scene markers, scene previews, sprites, etc)" <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>
<InputGroup value={generatedPath} onChange={(e: any) => setGeneratedPath(e.target.value)} /> </Form.Group>
</FormGroup>
<FormGroup <Form.Group>
label="Excluded Patterns" <Form.Label>Excluded Patterns</Form.Label>
> { excludes ? excludes.map((regexp, i) => (
<InputGroup>
{ (excludes) ? excludes.map((regexp, i) => { <Form.Control
return(
<InputGroup
value={regexp} value={regexp}
onChange={(e: any) => excludeRegexChanged(i, e.target.value)} onChange={(e: any) => excludeRegexChanged(i, e.target.value)}
rightElement={<Button icon="minus" minimal={true} intent="danger" onClick={(e: any) => excludeRemoveRegex(i)} />}
/> />
); <InputGroup.Append>
}) : null <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"
minimal={true}
target="_blank" 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 /> <hr />
<FormGroup>
<Form.Group>
<h4>Video</h4> <h4>Video</h4>
<FormGroup <Form.Group id="transcode-size">
label="Maximum transcode size" <Form.Label>Maximum transcode size</Form.Label>
helperText="Maximum size for generated transcodes" <Form.Control
> as="select">
<HTMLSelect onChange={(event:React.FormEvent<HTMLSelectElement>) => setMaxTranscodeSize(translateQuality(event.currentTarget.value))}
options={transcodeQualities}
onChange={(event) => setMaxTranscodeSize(translateQuality(event.target.value))}
value={resolutionToString(maxTranscodeSize)} value={resolutionToString(maxTranscodeSize)}
/>
</FormGroup>
<FormGroup
label="Maximum streaming transcode size"
helperText="Maximum size for transcoded streams"
> >
<HTMLSelect { transcodeQualities.map(q => (<option key={q} value={q}>{q}</option>))}
options={transcodeQualities} </Form.Control>
onChange={(event) => setMaxStreamingTranscodeSize(translateQuality(event.target.value))} <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)} value={resolutionToString(maxStreamingTranscodeSize)}
/> >
</FormGroup> { transcodeQualities.map(q => (<option key={q} value={q}>{q}</option>))}
</FormGroup> </Form.Control>
<Divider /> <Form.Text className="text-muted">Maximum size for transcoded streams</Form.Text>
</Form.Group>
</Form.Group>
<FormGroup> <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>
<FormGroup
helperText="Logs http access to the terminal. Requires restart."
> >
<Checkbox { ["Debug", "Info", "Warning", "Error"].map(o => (<option key={o} value={o}>{o}</option>)) }
</Form.Control>
</Form.Group>
<Form.Group>
<Form.Check
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
as="textarea"
value={css} 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,7 +40,7 @@ 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();
@@ -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
@@ -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);
@@ -71,7 +71,7 @@ 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) => {

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) => (
@@ -47,14 +105,14 @@ export const PerformerSelect: React.FC<IFilterProps> = (props) => {
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,7 +193,8 @@ 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 (
@@ -144,3 +203,9 @@ const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
: <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,14 +13,14 @@ 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 (

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,36 +38,35 @@ 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);
} }
} }
@@ -74,22 +74,18 @@ export const TagList: React.FC = () => {
<Modal <Modal
onHide={() => {}} onHide={() => {}}
show={!!deletingTag} show={!!deletingTag}
icon="trash-alt"
accept={{ onClick: onDelete, variant: 'danger', text: 'Delete' }}
cancel={{ onClick: () => setDeletingTag(null) }}
> >
<Modal.Body>
<FontAwesomeIcon icon="trash-alt" color="danger" />
<span>Are you sure you want to delete {deletingTag && deletingTag.name}?</span> <span>Are you sure you want to delete {deletingTag && deletingTag.name}?</span>
</Modal.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,13 +114,11 @@ 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>
{ editingTag && editingTag.id ? "Edit Tag" : "New Tag" }
</Modal.Header>
<Modal.Body>
<Form.Group controlId="tag-name"> <Form.Group controlId="tag-name">
<Form.Label>Name</Form.Label> <Form.Label>Name</Form.Label>
<Form.Control <Form.Control
@@ -132,11 +126,8 @@ export const TagList: React.FC = () => {
defaultValue={(editingTag && editingTag.name) || ''} defaultValue={(editingTag && editingTag.name) || ''}
/> />
</Form.Group> </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;
@@ -197,7 +197,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (props: IAddFilterProps) =>
onClick={() => onToggle()} onClick={() => onToggle()}
active={isOpen} active={isOpen}
> >
<FontAwesomeIcon icon="filter" /> <Icon icon="filter" />
</Button> </Button>
</OverlayTrigger> </OverlayTrigger>

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>
@@ -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,7 +95,7 @@ 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>
Scrape
</Modal.Header>
<Modal.Body>
<div className="dialog-content"> <div className="dialog-content">
<ScrapePerformerSuggest <ScrapePerformerSuggest
placeholder="Performer name" placeholder="Performer name"
style={{width: "100%"}}
scraperId={isDisplayingScraperDialog ? isDisplayingScraperDialog.id : ""} scraperId={isDisplayingScraperDialog ? isDisplayingScraperDialog.id : ""}
onSelectPerformer={(query) => setScrapePerformerDetails(query)} onSelectPerformer={(query) => setScrapePerformerDetails(query)}
/> />
</div> </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() {
@@ -283,7 +274,7 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
<Button <Button
id="scrape-url-button" id="scrape-url-button"
onClick={() => onScrapePerformerURL()}> onClick={() => onScrapePerformerURL()}>
<FontAwesomeIcon icon="file-upload" /> <Icon icon="file-upload" />
</Button> </Button>
) )
} }
@@ -296,9 +287,13 @@ export const Performer: React.FC<IPerformerProps> = (props: IPerformerProps) =>
{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,18 +1,18 @@
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",

View File

@@ -1,9 +1,9 @@
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[];
@@ -15,7 +15,7 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (props: IP
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>
); );
} }
@@ -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>

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;
@@ -27,7 +25,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (props: ISceneCardProps) =>
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 (
<> <>

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,15 +146,12 @@ 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>
<FontAwesomeIcon icon="trash-alt" />
<span>Delete Scene?</span>
</Modal.Header>
<Modal.Body>
<p> <p>
Are you sure you want to delete this scene? Unless the file is also deleted, this scene will be re-added when scan is performed. Are you sure you want to delete this scene? Unless the file is also deleted, this scene will be re-added when scan is performed.
</p> </p>
@@ -164,14 +159,6 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
<Form.Check checked={deleteFile} label="Delete file" onChange={() => setDeleteFile(!deleteFile)} /> <Form.Check checked={deleteFile} label="Delete file" onChange={() => setDeleteFile(!deleteFile)} />
<Form.Check checked={deleteGenerated} label="Delete generated supporting files" onChange={() => setDeleteGenerated(!deleteGenerated)} /> <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>
); );
} }
@@ -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);
} }
@@ -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);
} }
@@ -285,7 +272,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
<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}>

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>

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,15 +1,13 @@
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>;
@@ -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());
@@ -325,12 +324,12 @@ export const SceneFilenameParser: React.FC = () => {
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);
@@ -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}>

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

View File

@@ -1,10 +1,8 @@
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[];
@@ -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;

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

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>;
@@ -228,7 +227,7 @@ export class StashService {
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 }});
@@ -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";
@@ -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' }
@@ -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";

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

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) => {
private static readImage(file: File, onLoadEnd: (this: FileReader) => any) {
const reader: FileReader = new FileReader(); const reader: FileReader = new FileReader();
reader.onloadend = onLoadEnd; reader.onloadend = onLoadEnd;
reader.readAsDataURL(file); 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 { const makeStudioScenesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (studio.id === undefined) { return "#"; } if (!studio.id)
return "#";
const filter = new ListFilterModel(FilterMode.Scenes); const filter = new ListFilterModel(FilterMode.Scenes);
const criterion = new StudiosCriterion(); const criterion = new StudiosCriterion();
criterion.value = [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }]; criterion.value = [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }];
filter.criteria.push(criterion); filter.criteria.push(criterion);
return `/scenes?${filter.makeQueryParameters()}`; return `/scenes?${filter.makeQueryParameters()}`;
} }
public static makeTagScenesUrl(tag: Partial<GQL.TagDataFragment>): string { const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (tag.id === undefined) { return "#"; } if (!tag.id)
return "#";
const filter = new ListFilterModel(FilterMode.Scenes); const filter = new ListFilterModel(FilterMode.Scenes);
const criterion = new TagsCriterion("tags"); const criterion = new TagsCriterion("tags");
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
filter.criteria.push(criterion); filter.criteria.push(criterion);
return `/scenes?${filter.makeQueryParameters()}`; return `/scenes?${filter.makeQueryParameters()}`;
} }
public static makeTagSceneMarkersUrl(tag: Partial<GQL.TagDataFragment>): string { const makeTagSceneMarkersUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (tag.id === undefined) { return "#"; } if (!tag.id)
return "#";
const filter = new ListFilterModel(FilterMode.SceneMarkers); const filter = new ListFilterModel(FilterMode.SceneMarkers);
const criterion = new TagsCriterion("tags"); const criterion = new TagsCriterion("tags");
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
filter.criteria.push(criterion); filter.criteria.push(criterion);
return `/scenes/markers?${filter.makeQueryParameters()}`; return `/scenes/markers?${filter.makeQueryParameters()}`;
}
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 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,105 +1,95 @@
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 | undefined; value?: string | number;
isEditing: boolean; isEditing: boolean;
onChange: ((value: string) => void); onChange: ((value: string) => void);
}) { }) => (
let stringValue = options.value;
if (typeof stringValue === "number") {
stringValue = stringValue.toString();
}
return (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
<td> <td>
<EditableText <Form.Control
disabled={!options.isEditing} readOnly={!options.isEditing}
value={stringValue} plaintext={!options.isEditing}
onChange={(event: React.FormEvent<HTMLInputElement>) => ( options.onChange(event.currentTarget.value) )}
value={typeof options.value === 'number' ? options.value.toString() : options.value}
placeholder={options.title} placeholder={options.title}
multiline={true}
onChange={(newValue) => options.onChange(newValue)}
/> />
</td> </td>
</tr> </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),
}) { }) => (
return (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
<td> <td>
{EditableTextUtils.renderTextArea(options)} <Form.Control
as="textarea"
readOnly={!options.isEditing}
plaintext={!options.isEditing}
onChange={(event: React.FormEvent<HTMLTextAreaElement>) => ( options.onChange(event.currentTarget.value) )}
value={options.value}
/>
</td> </td>
</tr> </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);
optionsCopy.placeholder = options.placeholder || options.title;
return (
<tr> <tr>
<td>{options.title}</td> <td>{options.title}</td>
<td> <td>
{ !options.isEditing <Form.Control
? <h4>{optionsCopy.value}</h4> readOnly={!options.isEditing}
: <Form.Control plaintext={!options.isEditing}
defaultValue={options.value} defaultValue={options.value}
placeholder={optionsCopy.placeholder} placeholder={options.placeholder ?? options.title}
onChange={ (event:any) => options.onChange(event.target.value) } onChange={(event: React.FormEvent<HTMLInputElement>) => ( options.onChange(event.currentTarget.value) )}
/> />
}
</td> </td>
</tr> </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>
{EditableTextUtils.renderHtmlSelect(options)} <Form.Control
as="select"
readOnly={!options.isEditing}
plaintext={!options.isEditing}
onChange={(event: React.FormEvent<HTMLSelectElement>) => ( options.onChange(event.currentTarget.value) )}
/>
</td> </td>
</tr> </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>
@@ -110,17 +100,15 @@ export class TableUtils {
/> />
</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>
@@ -132,6 +120,14 @@ export class TableUtils {
/> />
</td> </td>
</tr> </tr>
); )
}
const Table = {
renderEditableTextTableRow,
renderTextArea,
renderInputGroup,
renderHtmlSelect,
renderFilterSelect,
renderMultiSelect
} }
export default Table;

View File

@@ -1,12 +1,23 @@
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
: value;
}
public static fileSize(bytes: number = 0, precision: number = 2): string { const fileSize = (bytes: number = 0, precision: number = 2) => {
if (isNaN(parseFloat(String(bytes))) || !isFinite(bytes)) { return "?"; } if (Number.isNaN(parseFloat(String(bytes))) || !isFinite(bytes))
return "?";
let unit = 0; let unit = 0;
while ( bytes >= 1024 ) { while ( bytes >= 1024 ) {
@@ -14,10 +25,10 @@ export class TextUtils {
unit++; unit++;
} }
return bytes.toFixed(+precision) + " " + this.units[unit]; return `${bytes.toFixed(+precision)} ${Units[unit]}`;
} }
public static secondsToTimestamp(seconds: number): string { const secondsToTimestamp = (seconds: number) => {
let ret = new Date(seconds * 1000).toISOString().substr(11, 8); let ret = new Date(seconds * 1000).toISOString().substr(11, 8);
if (ret.startsWith("00")) { if (ret.startsWith("00")) {
@@ -29,18 +40,20 @@ export class TextUtils {
ret = ret.substr(1); ret = ret.substr(1);
} }
return ret; return ret;
} }
public static fileNameFromPath(path: string): string { const fileNameFromPath = (path: string) => {
if (!!path === false) { return "No File Name"; } if (!!path === false)
return path.replace(/^.*[\\\/]/, ""); return "No File Name";
} return path.replace(/^.*[\\/]/, "");
}
public static age(dateString?: string, fromDateString?: string): number { const age = (dateString?: string, fromDateString?: string) => {
if (!dateString) { return 0; } if (!dateString)
return 0;
const birthdate = new Date(dateString); const birthdate = new Date(dateString);
const fromDate = !!fromDateString ? new Date(fromDateString) : new Date(); const fromDate = fromDateString ? new Date(fromDateString) : new Date();
let age = fromDate.getFullYear() - birthdate.getFullYear(); let age = fromDate.getFullYear() - birthdate.getFullYear();
if (birthdate.getMonth() > fromDate.getMonth() || if (birthdate.getMonth() > fromDate.getMonth() ||
@@ -49,14 +62,14 @@ export class TextUtils {
} }
return age; return age;
} }
public static bitRate(bitrate: number) { const bitRate = (bitrate: number) => {
const megabits = bitrate / 1000000; const megabits = bitrate / 1000000;
return `${megabits.toFixed(2)} megabits per second`; return `${megabits.toFixed(2)} megabits per second`;
} }
public static resolution(height: number) { const resolution = (height: number) => {
if (height >= 240 && height < 480) { if (height >= 240 && height < 480) {
return "240p"; return "240p";
} else if (height >= 480 && height < 720) { } else if (height >= 480 && height < 720) {
@@ -70,14 +83,16 @@ export class TextUtils {
} else { } else {
return undefined; return undefined;
} }
}
private static units = [
"bytes",
"kB",
"MB",
"GB",
"TB",
"PB",
];
} }
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==