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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import React from "react";
import queryString from "query-string";
import { Card, Tab, Nav, Row, Col } from 'react-bootstrap';
import { useHistory, useLocation } from 'react-router-dom';
import { Card, Tab, Nav, Row, Col } from "react-bootstrap";
import { useHistory, useLocation } from "react-router-dom";
import { SettingsAboutPanel } from "./SettingsAboutPanel";
import { SettingsConfigurationPanel } from "./SettingsConfigurationPanel";
import { SettingsInterfacePanel } from "./SettingsInterfacePanel";
@@ -11,13 +11,17 @@ import { SettingsTasksPanel } from "./SettingsTasksPanel/SettingsTasksPanel";
export const Settings: React.FC = () => {
const location = useLocation();
const history = useHistory();
const defaultTab = queryString.parse(location.search).tab ?? 'configuration';
const defaultTab = queryString.parse(location.search).tab ?? "configuration";
const onSelect = ((val:string) => history.push(`?tab=${val}`));
const onSelect = (val: string) => history.push(`?tab=${val}`);
return (
<Card id="details-container">
<Tab.Container defaultActiveKey={defaultTab} id="configuration-tabs" onSelect={onSelect}>
<Tab.Container
defaultActiveKey={defaultTab}
id="configuration-tabs"
onSelect={onSelect}
>
<Row>
<Col sm={2}>
<Nav variant="pills" className="flex-column">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import React from "react";
import { Button, Modal } from 'react-bootstrap';
import { Icon } from 'src/components/Shared';
import { IconName } from '@fortawesome/fontawesome-svg-core';
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';
variant?: "danger" | "primary";
onClick?: () => void;
}
@@ -18,27 +18,42 @@ interface IModal {
accept?: IButton;
}
const ModalComponent: React.FC<IModal> = ({ children, show, icon, header, cancel, accept, onHide }) => ((
<Modal
keyboard={false}
onHide={onHide}
show={show}
>
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>
{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>
{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,20 +1,20 @@
import React, { useState, useCallback } from "react";
import Select, { ValueType } from 'react-select';
import CreatableSelect from 'react-select/creatable';
import { debounce } from 'lodash';
import Select, { ValueType } from "react-select";
import CreatableSelect from "react-select/creatable";
import { debounce } from "lodash";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { useToast } from 'src/hooks';
import { useToast } from "src/hooks";
type ValidTypes =
GQL.AllPerformersForFilterAllPerformers |
GQL.AllTagsForFilterAllTags |
GQL.AllStudiosForFilterAllStudios;
type Option = { value:string, label:string };
| GQL.AllPerformersForFilterAllPerformers
| GQL.AllTagsForFilterAllTags
| GQL.AllStudiosForFilterAllStudios;
type Option = { value: string; label: string };
interface ITypeProps {
type?: 'performers' | 'studios' | 'tags';
type?: "performers" | "studios" | "tags";
}
interface IFilterProps {
initialIds: string[];
@@ -40,131 +40,221 @@ interface ISelectProps {
interface ISceneGallerySelect {
initialId?: string;
sceneId: string;
onSelect: (item: GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined) => void;
onSelect: (
item: GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined
) => void;
}
const getSelectedValues = (selectedItems:ValueType<Option>) => (
(Array.isArray(selectedItems) ? selectedItems : [selectedItems])
.map(item => item.value)
);
const getSelectedValues = (selectedItems: ValueType<Option>) =>
(Array.isArray(selectedItems) ? selectedItems : [selectedItems]).map(
item => item.value
);
export const SceneGallerySelect: React.FC<ISceneGallerySelect> = (props) => {
const { data, loading } = StashService.useValidGalleriesForScene(props.sceneId);
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 items = (galleries.length > 0
? [{ path: "None", id: "0" }, ...galleries]
: []
).map(g => ({ label: g.path, value: g.id }));
const onChange = (selectedItems:ValueType<Option>) => {
const onChange = (selectedItems: ValueType<Option>) => {
const selectedItem = getSelectedValues(selectedItems)[0];
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} />
return (
<SelectComponent
onChange={onChange}
isLoading={loading}
items={items}
initialIds={initialId}
/>
);
};
interface IScrapePerformerSuggestProps {
scraperId: string;
onSelectPerformer: (query: GQL.ScrapePerformerListScrapePerformerList) => void;
onSelectPerformer: (
query: GQL.ScrapePerformerListScrapePerformerList
) => void;
placeholder?: string;
}
export const ScrapePerformerSuggest: React.FC<IScrapePerformerSuggestProps> = (props) => {
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 { 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 ?? [];
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} />
}
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;
onChange: (title: string) => void;
}
export const MarkerTitleSuggest: React.FC<IMarkerSuggestProps> = (props) => {
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 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 =>
props.type === "performers" ? (
<PerformerSelect {...(props as IFilterProps)} />
) : props.type === "studios" ? (
<StudioSelect {...(props as IFilterProps)} />
) : (
<TagSelect {...(props as IFilterProps)} />
);
const items = suggestions.map(item => ({ label: item?.title ?? '', value: item?.title ?? '' }));
const initialIds = props.initialMarkerTitle ? [props.initialMarkerTitle] : [];
return <SelectComponent creatable onChange={onChange} isLoading={loading} items={items} initialIds={initialIds} />
}
export const FilterSelect: React.FC<IFilterProps & ITypeProps> = (props) => (
props.type === 'performers' ? <PerformerSelect {...props as IFilterProps} />
: props.type === 'studios' ? <StudioSelect {...props as IFilterProps} />
: <TagSelect {...props as IFilterProps} />
);
export const PerformerSelect: React.FC<IFilterProps> = (props) => {
export const PerformerSelect: React.FC<IFilterProps> = props => {
const { data, loading } = StashService.useAllPerformersForFilter();
const normalizedData = data?.allPerformers ?? [];
const items:Option[] = normalizedData.map(item => ({ value: item.id, label: item.name ?? '' }));
const placeholder = props.noSelectionString ?? "Select performer..."
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 = getSelectedValues(selectedItems);
props.onSelect(normalizedData.filter(item => selectedIds.indexOf(item.id) !== -1));
props.onSelect(
normalizedData.filter(item => selectedIds.indexOf(item.id) !== -1)
);
};
return <SelectComponent {...props} onChange={onChange} type="performers" isLoading={loading} items={items} placeholder={placeholder} />
}
return (
<SelectComponent
{...props}
onChange={onChange}
type="performers"
isLoading={loading}
items={items}
placeholder={placeholder}
/>
);
};
export const StudioSelect: React.FC<IFilterProps> = (props) => {
export const StudioSelect: React.FC<IFilterProps> = props => {
const { data, loading } = StashService.useAllStudiosForFilter();
const normalizedData = data?.allStudios ?? [];
const items:Option[] = normalizedData.map(item => ({ value: item.id, label: item.name }));
const placeholder = props.noSelectionString ?? "Select studio..."
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 = getSelectedValues(selectedItems);
props.onSelect(normalizedData.filter(item => selectedIds.indexOf(item.id) !== -1));
props.onSelect(
normalizedData.filter(item => selectedIds.indexOf(item.id) !== -1)
);
};
return <SelectComponent {...props} onChange={onChange} type="studios" isLoading={loading} items={items} placeholder={placeholder} />
}
return (
<SelectComponent
{...props}
onChange={onChange}
type="studios"
isLoading={loading}
items={items}
placeholder={placeholder}
/>
);
};
export const TagSelect: React.FC<IFilterProps> = (props) => {
export const TagSelect: React.FC<IFilterProps> = props => {
const [loading, setLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const { data, loading: dataLoading } = StashService.useAllTagsForFilter();
const createTag = StashService.useTagCreate({name: ''});
const createTag = StashService.useTagCreate({ name: "" });
const Toast = useToast();
const placeholder = props.noSelectionString ?? "Select tags..."
const placeholder = props.noSelectionString ?? "Select tags...";
const tags = data?.allTags ?? [];
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 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 onCreate = async (tagName: string) => {
try {
setLoading(true);
const result = await createTag({
variables: { name: tagName },
variables: { name: tagName }
});
setSelectedIds([...selectedIds, result.data.tagCreate.id]);
props.onSelect([...tags, result.data.tagCreate].filter(item => selected.indexOf(item.id) !== -1));
props.onSelect(
[...tags, result.data.tagCreate].filter(
item => selected.indexOf(item.id) !== -1
)
);
setLoading(false);
Toast.success({ content: (<span>Created tag: <b>{tagName}</b></span>) });
Toast.success({
content: (
<span>
Created tag: <b>{tagName}</b>
</span>
)
});
} catch (e) {
Toast.error(e);
}
};
const onChange = (selectedItems:ValueType<Option>) => {
const onChange = (selectedItems: ValueType<Option>) => {
const selectedValues = getSelectedValues(selectedItems);
setSelectedIds(selectedValues);
props.onSelect(tags.filter(item => selectedValues.indexOf(item.id) !== -1));
@@ -183,7 +273,7 @@ export const TagSelect: React.FC<IFilterProps> = (props) => {
selectedOptions={selected}
/>
);
}
};
const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
type,
@@ -199,7 +289,8 @@ const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
onInputChange,
placeholder
}) => {
const defaultValue = items.filter(item => initialIds?.indexOf(item.value) !== -1) ?? null;
const defaultValue =
items.filter(item => initialIds?.indexOf(item.value) !== -1) ?? null;
const props = {
className,
@@ -208,14 +299,19 @@ const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
onChange,
isMulti,
defaultValue,
noOptionsMessage: () => (type !== 'tags' ? 'None' : null),
noOptionsMessage: () => (type !== "tags" ? "None" : null),
placeholder,
onInputChange
}
};
return (
creatable
? <CreatableSelect {...props} isLoading={isLoading} isDisabled={isLoading} onCreateOption={onCreateOption} />
: <Select {...props} isLoading={isLoading} />
return creatable ? (
<CreatableSelect
{...props}
isLoading={isLoading}
isDisabled={isLoading}
onCreateOption={onCreateOption}
/>
) : (
<Select {...props} isLoading={isLoading} />
);
};

View File

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

View File

@@ -6,11 +6,11 @@ export {
PerformerSelect,
StudioSelect,
TagSelect
} from './Select';
} 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';
export { HoverPopover } from './HoverPopover';
export { default as Icon } from "./Icon";
export { default as Modal } from "./Modal";
export { DetailsEditNavbar } from "./DetailsEditNavbar";
export { DurationInput } from "./DurationInput";
export { TagLink } from "./TagLink";
export { HoverPopover } from "./HoverPopover";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,17 @@
/* eslint-disable no-param-reassign, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
import React, { useEffect, useState, useCallback } from "react";
import { Badge, Button, Card, Collapse, Dropdown, DropdownButton, Form, Table, Spinner } from 'react-bootstrap';
import {
Badge,
Button,
Card,
Collapse,
Dropdown,
DropdownButton,
Form,
Table,
Spinner
} from "react-bootstrap";
import _ from "lodash";
import { StashService } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
@@ -15,12 +25,12 @@ class ParserResult<T> {
public originalValue: GQL.Maybe<T>;
public set: boolean = false;
public setOriginalValue(v : GQL.Maybe<T>) {
public setOriginalValue(v: GQL.Maybe<T>) {
this.originalValue = v;
this.value = v;
}
public setValue(v : GQL.Maybe<T>) {
public setValue(v: GQL.Maybe<T>) {
if (v) {
this.value = v;
this.set = !_.isEqual(this.value, this.originalValue);
@@ -29,8 +39,8 @@ class ParserResult<T> {
}
class ParserField {
public field : string;
public helperText? : string;
public field: string;
public helperText?: string;
constructor(field: string, helperText?: string) {
this.field = field;
@@ -38,7 +48,7 @@ class ParserField {
}
public getFieldPattern() {
return `{${ this.field }}`;
return `{${this.field}}`;
}
static Title = new ParserField("title");
@@ -83,7 +93,7 @@ class ParserField {
ParserField.DDMMYY,
ParserField.MMDDYYYY,
ParserField.MMDDYY
]
];
static fullDateFields = [
ParserField.YYYYMMDD,
@@ -104,23 +114,27 @@ class SceneParserResult {
public studioId: ParserResult<string> = new ParserResult();
public tags: ParserResult<GQL.SlimSceneDataTags[]> = new ParserResult();
public tagIds: ParserResult<string[]> = new ParserResult();
public performers: ParserResult<GQL.SlimSceneDataPerformers[]> = new ParserResult();
public performers: ParserResult<
GQL.SlimSceneDataPerformers[]
> = new ParserResult();
public performerIds: ParserResult<string[]> = new ParserResult();
public scene : GQL.SlimSceneDataFragment;
public scene: GQL.SlimSceneDataFragment;
constructor(result : GQL.ParseSceneFilenamesResults) {
constructor(result: GQL.ParseSceneFilenamesResults) {
this.scene = result.scene;
this.id = this.scene.id;
this.filename = TextUtils.fileNameFromPath(this.scene.path);
this.title.setOriginalValue(this.scene.title);
this.date.setOriginalValue(this.scene.date);
this.performerIds.setOriginalValue(this.scene.performers.map((p) => p.id));
this.performerIds.setOriginalValue(this.scene.performers.map(p => p.id));
this.performers.setOriginalValue(this.scene.performers);
this.tagIds.setOriginalValue(this.scene.tags.map((t) => t.id));
this.tagIds.setOriginalValue(this.scene.tags.map(t => t.id));
this.tags.setOriginalValue(this.scene.tags);
this.studioId.setOriginalValue(this.scene.studio ? this.scene.studio.id : undefined);
this.studioId.setOriginalValue(
this.scene.studio ? this.scene.studio.id : undefined
);
this.studio.setOriginalValue(this.scene.studio);
this.title.setValue(result.title);
@@ -130,23 +144,27 @@ class SceneParserResult {
this.studioId.setValue(result.studio_id);
if (result.performer_ids) {
this.performers.setValue(result.performer_ids.map((p) => {
this.performers.setValue(
result.performer_ids.map(p => {
return {
id: p,
name: "",
favorite: false,
image_path: ""
};
}));
})
);
}
if (result.tag_ids) {
this.tags.setValue(result.tag_ids.map((t) => {
this.tags.setValue(
result.tag_ids.map(t => {
return {
id: t,
name: "",
name: ""
};
}));
})
);
}
if (result.studio_id) {
@@ -158,7 +176,11 @@ class SceneParserResult {
}
}
private static setInput(obj: any, key: string, parserResult : ParserResult<any>) {
private static setInput(
obj: any,
key: string,
parserResult: ParserResult<any>
) {
if (parserResult.set) {
obj[key] = parserResult.value;
}
@@ -166,7 +188,13 @@ class SceneParserResult {
// returns true if any of its fields have set == true
public isChanged() {
return this.title.set || this.date.set || this.performerIds.set || this.studioId.set || this.tagIds.set;
return (
this.title.set ||
this.date.set ||
this.performerIds.set ||
this.studioId.set ||
this.tagIds.set
);
}
public toSceneUpdateInput() {
@@ -179,8 +207,8 @@ class SceneParserResult {
rating: this.scene.rating,
gallery_id: this.scene.gallery ? this.scene.gallery.id : undefined,
studio_id: this.scene.studio ? this.scene.studio.id : undefined,
performer_ids: this.scene.performers.map((performer) => performer.id),
tag_ids: this.scene.tags.map((tag) => tag.id)
performer_ids: this.scene.performers.map(performer => performer.id),
tag_ids: this.scene.tags.map(tag => tag.id)
};
SceneParserResult.setInput(ret, "title", this.title);
@@ -191,24 +219,24 @@ class SceneParserResult {
return ret;
}
};
}
interface IParserInput {
pattern: string,
ignoreWords: string[],
whitespaceCharacters: string,
capitalizeTitle: boolean,
page: number,
pageSize: number,
findClicked: boolean
pattern: string;
ignoreWords: string[];
whitespaceCharacters: string;
capitalizeTitle: boolean;
page: number;
pageSize: number;
findClicked: boolean;
}
interface IParserRecipe {
pattern: string,
ignoreWords: string[],
whitespaceCharacters: string,
capitalizeTitle: boolean,
description: string
pattern: string;
ignoreWords: string[];
whitespaceCharacters: string;
capitalizeTitle: boolean;
description: string;
}
const builtInRecipes = [
@@ -277,7 +305,9 @@ const initialShowFieldsState = new Map<string, boolean>([
export const SceneFilenameParser: React.FC = () => {
const Toast = useToast();
const [parserResult, setParserResult] = useState<SceneParserResult[]>([]);
const [parserInput, setParserInput] = useState<IParserInput>(initialParserInput);
const [parserInput, setParserInput] = useState<IParserInput>(
initialParserInput
);
const [allTitleSet, setAllTitleSet] = useState<boolean>(false);
const [allDateSet, setAllDateSet] = useState<boolean>(false);
@@ -285,7 +315,9 @@ export const SceneFilenameParser: React.FC = () => {
const [allTagSet, setAllTagSet] = useState<boolean>(false);
const [allStudioSet, setAllStudioSet] = useState<boolean>(false);
const [showFields, setShowFields] = useState<Map<string, boolean>>(initialShowFieldsState);
const [showFields, setShowFields] = useState<Map<string, boolean>>(
initialShowFieldsState
);
const [totalItems, setTotalItems] = useState<number>(0);
@@ -295,12 +327,13 @@ export const SceneFilenameParser: React.FC = () => {
const updateScenes = StashService.useScenesUpdate(getScenesUpdateData());
const determineFieldsToHide = useCallback(() => {
const {pattern} = parserInput;
const { pattern } = parserInput;
const titleSet = pattern.includes("{title}");
const dateSet = pattern.includes("{date}") ||
const dateSet =
pattern.includes("{date}") ||
pattern.includes("{dd}") || // don't worry about other partial date fields since this should be implied
ParserField.fullDateFields.some((f) => {
return pattern.includes(`{${ f.field }}`);
ParserField.fullDateFields.some(f => {
return pattern.includes(`{${f.field}}`);
});
const performerSet = pattern.includes("{performer}");
const tagSet = pattern.includes("{tag}");
@@ -317,19 +350,24 @@ export const SceneFilenameParser: React.FC = () => {
setShowFields(newShowFields);
}, [parserInput]);
const parseResults = useCallback((results : GQL.ParseSceneFilenamesResults[]) => {
const parseResults = useCallback(
(results: GQL.ParseSceneFilenamesResults[]) => {
if (results) {
const result = results.map((r) => {
const result = results
.map(r => {
return new SceneParserResult(r);
}).filter((r) => !!r) as SceneParserResult[];
})
.filter(r => !!r) as SceneParserResult[];
setParserResult(result);
determineFieldsToHide();
}
}, [determineFieldsToHide]);
},
[determineFieldsToHide]
);
useEffect(() => {
if(parserInput.findClicked) {
if (parserInput.findClicked) {
setParserResult([]);
setIsLoading(true);
@@ -338,7 +376,7 @@ export const SceneFilenameParser: React.FC = () => {
page: parserInput.page,
per_page: parserInput.pageSize,
sort: "path",
direction: GQL.SortDirectionEnum.Asc,
direction: GQL.SortDirectionEnum.Asc
};
const parserInputData = {
@@ -348,30 +386,26 @@ export const SceneFilenameParser: React.FC = () => {
};
StashService.queryParseSceneFilenames(parserFilter, parserInputData)
.then((response) => {
.then(response => {
const result = response.data.parseSceneFilenames;
if (result) {
parseResults(result.results);
setTotalItems(result.count);
}
})
.catch((err) => (
Toast.error(err)
))
.finally(() => (
setIsLoading(false)
));
.catch(err => Toast.error(err))
.finally(() => setIsLoading(false));
}
}, [parserInput, parseResults, Toast]);
function onPageSizeChanged(newSize : number) {
function onPageSizeChanged(newSize: number) {
const newInput = _.clone(parserInput);
newInput.page = 1;
newInput.pageSize = newSize;
setParserInput(newInput);
}
function onPageChanged(newPage : number) {
function onPageChanged(newPage: number) {
if (newPage !== parserInput.page) {
const newInput = _.clone(parserInput);
newInput.page = newPage;
@@ -379,7 +413,7 @@ export const SceneFilenameParser: React.FC = () => {
}
}
function onFindClicked(input : IParserInput) {
function onFindClicked(input: IParserInput) {
input.page = 1;
input.findClicked = true;
setParserInput(input);
@@ -387,7 +421,9 @@ export const SceneFilenameParser: React.FC = () => {
}
function getScenesUpdateData() {
return parserResult.filter((result) => result.isChanged()).map((result) => result.toSceneUpdateInput());
return parserResult
.filter(result => result.isChanged())
.map(result => result.toSceneUpdateInput());
}
async function onApply() {
@@ -404,19 +440,19 @@ export const SceneFilenameParser: React.FC = () => {
}
useEffect(() => {
const newAllTitleSet = !parserResult.some((r) => {
const newAllTitleSet = !parserResult.some(r => {
return !r.title.set;
});
const newAllDateSet = !parserResult.some((r) => {
const newAllDateSet = !parserResult.some(r => {
return !r.date.set;
});
const newAllPerformerSet = !parserResult.some((r) => {
const newAllPerformerSet = !parserResult.some(r => {
return !r.performerIds.set;
});
const newAllTagSet = !parserResult.some((r) => {
const newAllTagSet = !parserResult.some(r => {
return !r.tagIds.set;
});
const newAllStudioSet = !parserResult.some((r) => {
const newAllStudioSet = !parserResult.some(r => {
return !r.studioId.set;
});
@@ -427,10 +463,10 @@ export const SceneFilenameParser: React.FC = () => {
setAllStudioSet(newAllStudioSet);
}, [parserResult]);
function onSelectAllTitleSet(selected : boolean) {
function onSelectAllTitleSet(selected: boolean) {
const newResult = [...parserResult];
newResult.forEach((r) => {
newResult.forEach(r => {
r.title.set = selected;
});
@@ -438,10 +474,10 @@ export const SceneFilenameParser: React.FC = () => {
setAllTitleSet(selected);
}
function onSelectAllDateSet(selected : boolean) {
function onSelectAllDateSet(selected: boolean) {
const newResult = [...parserResult];
newResult.forEach((r) => {
newResult.forEach(r => {
r.date.set = selected;
});
@@ -449,10 +485,10 @@ export const SceneFilenameParser: React.FC = () => {
setAllDateSet(selected);
}
function onSelectAllPerformerSet(selected : boolean) {
function onSelectAllPerformerSet(selected: boolean) {
const newResult = [...parserResult];
newResult.forEach((r) => {
newResult.forEach(r => {
r.performerIds.set = selected;
});
@@ -460,10 +496,10 @@ export const SceneFilenameParser: React.FC = () => {
setAllPerformerSet(selected);
}
function onSelectAllTagSet(selected : boolean) {
function onSelectAllTagSet(selected: boolean) {
const newResult = [...parserResult];
newResult.forEach((r) => {
newResult.forEach(r => {
r.tagIds.set = selected;
});
@@ -471,10 +507,10 @@ export const SceneFilenameParser: React.FC = () => {
setAllTagSet(selected);
}
function onSelectAllStudioSet(selected : boolean) {
function onSelectAllStudioSet(selected: boolean) {
const newResult = [...parserResult];
newResult.forEach((r) => {
newResult.forEach(r => {
r.studioId.set = selected;
});
@@ -483,8 +519,8 @@ export const SceneFilenameParser: React.FC = () => {
}
interface IShowFieldsProps {
fields: Map<string, boolean>
onShowFieldsChanged: (fields : Map<string, boolean>) => void
fields: Map<string, boolean>;
onShowFieldsChanged: (fields: Map<string, boolean>) => void;
}
function ShowFields(props: IShowFieldsProps) {
@@ -497,8 +533,13 @@ export const SceneFilenameParser: React.FC = () => {
}
const fieldRows = [...props.fields.entries()].map(([label, enabled]) => (
<div key={label} onClick={() => {handleClick(label)}}>
<Icon icon={enabled ? "check" : "times" } />
<div
key={label}
onClick={() => {
handleClick(label);
}}
>
<Icon icon={enabled ? "check" : "times"} />
<span>{label}</span>
</div>
));
@@ -506,28 +547,32 @@ export const SceneFilenameParser: React.FC = () => {
return (
<div>
<div onClick={() => setOpen(!open)}>
<Icon icon={open ? "chevron-down" : "chevron-right" } />
<Icon icon={open ? "chevron-down" : "chevron-right"} />
<span>Display fields</span>
</div>
<Collapse in={open}>
<div>
{fieldRows}
</div>
<div>{fieldRows}</div>
</Collapse>
</div>
);
}
interface IParserInputProps {
input: IParserInput,
onFind: (input : IParserInput) => void
input: IParserInput;
onFind: (input: IParserInput) => void;
}
function ParserInput(props : IParserInputProps) {
function ParserInput(props: IParserInputProps) {
const [pattern, setPattern] = useState<string>(props.input.pattern);
const [ignoreWords, setIgnoreWords] = useState<string>(props.input.ignoreWords.join(" "));
const [whitespaceCharacters, setWhitespaceCharacters] = useState<string>(props.input.whitespaceCharacters);
const [capitalizeTitle, setCapitalizeTitle] = useState<boolean>(props.input.capitalizeTitle);
const [ignoreWords, setIgnoreWords] = useState<string>(
props.input.ignoreWords.join(" ")
);
const [whitespaceCharacters, setWhitespaceCharacters] = useState<string>(
props.input.whitespaceCharacters
);
const [capitalizeTitle, setCapitalizeTitle] = useState<boolean>(
props.input.capitalizeTitle
);
function onFind() {
props.onFind({
@@ -548,7 +593,9 @@ export const SceneFilenameParser: React.FC = () => {
setCapitalizeTitle(recipe.capitalizeTitle);
}
const validFields = [new ParserField("", "Wildcard")].concat(ParserField.validFields);
const validFields = [new ParserField("", "Wildcard")].concat(
ParserField.validFields
);
function addParserField(field: ParserField) {
setPattern(pattern + field.getFieldPattern());
@@ -564,9 +611,10 @@ export const SceneFilenameParser: React.FC = () => {
value={pattern}
/>
<DropdownButton id="parser-field-select" title="Add Field">
{ validFields.map(item => (
{validFields.map(item => (
<Dropdown.Item onSelect={() => addParserField(item)}>
<span>{item.field}</span><span className="ml-auto">{item.helperText}</span>
<span>{item.field}</span>
<span className="ml-auto">{item.helperText}</span>
</Dropdown.Item>
))}
</DropdownButton>
@@ -586,7 +634,9 @@ export const SceneFilenameParser: React.FC = () => {
<h5>Title</h5>
<Form.Label>Whitespace characters:</Form.Label>
<Form.Control
onChange={(newValue: any) => setWhitespaceCharacters(newValue.target.value)}
onChange={(newValue: any) =>
setWhitespaceCharacters(newValue.target.value)
}
value={whitespaceCharacters}
/>
<Form.Group>
@@ -597,16 +647,19 @@ export const SceneFilenameParser: React.FC = () => {
onChange={() => setCapitalizeTitle(!capitalizeTitle)}
/>
</Form.Group>
<div>These characters will be replaced with whitespace in the title</div>
<div>
These characters will be replaced with whitespace in the title
</div>
</Form.Group>
{/* TODO - mapping stuff will go here */}
<Form.Group>
<DropdownButton id="recipe-select" title="Select Parser Recipe">
{ builtInRecipes.map(item => (
{builtInRecipes.map(item => (
<Dropdown.Item onSelect={() => setParserRecipe(item)}>
<span>{item.pattern}</span><span className="mr-auto">{item.description}</span>
<span>{item.pattern}</span>
<span className="mr-auto">{item.description}</span>
</Dropdown.Item>
))}
</DropdownButton>
@@ -615,7 +668,7 @@ export const SceneFilenameParser: React.FC = () => {
<Form.Group>
<ShowFields
fields={showFields}
onShowFieldsChanged={(fields) => setShowFields(fields)}
onShowFieldsChanged={fields => setShowFields(fields)}
/>
</Form.Group>
@@ -623,13 +676,17 @@ export const SceneFilenameParser: React.FC = () => {
<Button onClick={onFind}>Find</Button>
<Form.Control
as="select"
style={{flexBasis: "min-content"}}
style={{ flexBasis: "min-content" }}
options={PAGE_SIZE_OPTIONS}
onChange={(event: any) => onPageSizeChanged(parseInt(event.target.value, 10))}
onChange={(event: any) =>
onPageSizeChanged(parseInt(event.target.value, 10))
}
defaultValue={props.input.pageSize}
className="filter-item"
>
{ PAGE_SIZE_OPTIONS.map(val => <option value="val">{val}</option>) }
{PAGE_SIZE_OPTIONS.map(val => (
<option value="val">{val}</option>
))}
</Form.Control>
</Form.Group>
</Form.Group>
@@ -637,19 +694,21 @@ export const SceneFilenameParser: React.FC = () => {
}
interface ISceneParserFieldProps {
parserResult : ParserResult<any>
className? : string
fieldName : string
onSetChanged : (set : boolean) => void
onValueChanged : (value : any) => void
originalParserResult? : ParserResult<any>
renderOriginalInputField: (props : ISceneParserFieldProps) => JSX.Element
renderNewInputField: (props : ISceneParserFieldProps, onChange : (event : any) => void) => JSX.Element
parserResult: ParserResult<any>;
className?: string;
fieldName: string;
onSetChanged: (set: boolean) => void;
onValueChanged: (value: any) => void;
originalParserResult?: ParserResult<any>;
renderOriginalInputField: (props: ISceneParserFieldProps) => JSX.Element;
renderNewInputField: (
props: ISceneParserFieldProps,
onChange: (event: any) => void
) => JSX.Element;
}
function SceneParserField(props : ISceneParserFieldProps) {
function maybeValueChanged(value : any) {
function SceneParserField(props: ISceneParserFieldProps) {
function maybeValueChanged(value: any) {
if (value !== props.parserResult.value) {
props.onValueChanged(value);
}
@@ -665,13 +724,17 @@ export const SceneFilenameParser: React.FC = () => {
<Form.Control
type="checkbox"
checked={props.parserResult.set}
onChange={() => {props.onSetChanged(!props.parserResult.set)}}
onChange={() => {
props.onSetChanged(!props.parserResult.set);
}}
/>
</td>
<td>
<Form.Group>
{props.renderOriginalInputField(props)}
{props.renderNewInputField(props, (value) => maybeValueChanged(value))}
{props.renderNewInputField(props, value =>
maybeValueChanged(value)
)}
</Form.Group>
</td>
</>
@@ -691,9 +754,9 @@ export const SceneFilenameParser: React.FC = () => {
}
interface IInputGroupWrapperProps {
parserResult: ParserResult<any>
onChange : (event : any) => void
className? : string
parserResult: ParserResult<any>;
onChange: (event: any) => void;
className?: string;
}
function InputGroupWrapper(props: IInputGroupWrapperProps) {
@@ -707,44 +770,55 @@ export const SceneFilenameParser: React.FC = () => {
);
}
function renderNewInputGroup(props : ISceneParserFieldProps, onChangeHandler : (value : any) => void) {
function renderNewInputGroup(
props: ISceneParserFieldProps,
onChangeHandler: (value: any) => void
) {
return (
<InputGroupWrapper
className={props.className}
onChange={(value : any) => {onChangeHandler(value)}}
onChange={(value: any) => {
onChangeHandler(value);
}}
parserResult={props.parserResult}
/>
);
}
interface IHasName {
name: string
name: string;
}
function renderOriginalSelect(props : ISceneParserFieldProps) {
function renderOriginalSelect(props: ISceneParserFieldProps) {
const result = props.originalParserResult || props.parserResult;
const elements = result.originalValue
? Array.isArray(result.originalValue)
? result.originalValue.map((el:IHasName) => el.name)
? result.originalValue.map((el: IHasName) => el.name)
: [result.originalValue.name]
: [];
return (
<div>
{ elements.map((name:string) => <Badge variant="secondary">{name}</Badge>) }
{elements.map((name: string) => (
<Badge variant="secondary">{name}</Badge>
))}
</div>
);
}
function renderNewMultiSelect(type: "performers" | "tags", props : ISceneParserFieldProps, onChangeHandler : (value : any) => void) {
function renderNewMultiSelect(
type: "performers" | "tags",
props: ISceneParserFieldProps,
onChangeHandler: (value: any) => void
) {
return (
<FilterSelect
className={props.className}
type={type}
isMulti
onSelect={(items) => {
const ids = items.map((i) => i.id);
onSelect={items => {
const ids = items.map(i => i.id);
onChangeHandler(ids);
}}
initialIds={props.parserResult.value}
@@ -752,64 +826,72 @@ export const SceneFilenameParser: React.FC = () => {
);
}
function renderNewPerformerSelect(props : ISceneParserFieldProps, onChangeHandler : (value : any) => void) {
function renderNewPerformerSelect(
props: ISceneParserFieldProps,
onChangeHandler: (value: any) => void
) {
return renderNewMultiSelect("performers", props, onChangeHandler);
}
function renderNewTagSelect(props : ISceneParserFieldProps, onChangeHandler : (value : any) => void) {
function renderNewTagSelect(
props: ISceneParserFieldProps,
onChangeHandler: (value: any) => void
) {
return renderNewMultiSelect("tags", props, onChangeHandler);
}
function renderNewStudioSelect(props : ISceneParserFieldProps, onChangeHandler : (value : any) => void) {
function renderNewStudioSelect(
props: ISceneParserFieldProps,
onChangeHandler: (value: any) => void
) {
return (
<StudioSelect
noSelectionString=""
className={props.className}
onSelect={(items) => onChangeHandler(items[0]?.id)}
onSelect={items => onChangeHandler(items[0]?.id)}
initialIds={props.parserResult.value ? [props.parserResult.value] : []}
/>
);
}
interface ISceneParserRowProps {
scene : SceneParserResult,
onChange: (changedScene : SceneParserResult) => void
scene: SceneParserResult;
onChange: (changedScene: SceneParserResult) => void;
}
function SceneParserRow(props : ISceneParserRowProps) {
function changeParser(result : ParserResult<any>, set : boolean, value : any) {
function SceneParserRow(props: ISceneParserRowProps) {
function changeParser(result: ParserResult<any>, set: boolean, value: any) {
const newParser = _.clone(result);
newParser.set = set;
newParser.value = value;
return newParser;
}
function onTitleChanged(set : boolean, value: string | undefined) {
function onTitleChanged(set: boolean, value: string | undefined) {
const newResult = _.clone(props.scene);
newResult.title = changeParser(newResult.title, set, value);
props.onChange(newResult);
}
function onDateChanged(set : boolean, value: string | undefined) {
function onDateChanged(set: boolean, value: string | undefined) {
const newResult = _.clone(props.scene);
newResult.date = changeParser(newResult.date, set, value);
props.onChange(newResult);
}
function onPerformerIdsChanged(set : boolean, value: string[] | undefined) {
function onPerformerIdsChanged(set: boolean, value: string[] | undefined) {
const newResult = _.clone(props.scene);
newResult.performerIds = changeParser(newResult.performerIds, set, value);
props.onChange(newResult);
}
function onTagIdsChanged(set : boolean, value: string[] | undefined) {
function onTagIdsChanged(set: boolean, value: string[] | undefined) {
const newResult = _.clone(props.scene);
newResult.tagIds = changeParser(newResult.tagIds, set, value);
props.onChange(newResult);
}
function onStudioIdChanged(set : boolean, value: string | undefined) {
function onStudioIdChanged(set: boolean, value: string | undefined) {
const newResult = _.clone(props.scene);
newResult.studioId = changeParser(newResult.studioId, set, value);
props.onChange(newResult);
@@ -817,16 +899,14 @@ export const SceneFilenameParser: React.FC = () => {
return (
<tr className="scene-parser-row">
<td style={{textAlign: "left"}}>
{props.scene.filename}
</td>
<td style={{ textAlign: "left" }}>{props.scene.filename}</td>
<SceneParserField
key="title"
fieldName="Title"
className="parser-field-title"
parserResult={props.scene.title}
onSetChanged={(set) => onTitleChanged(set, props.scene.title.value)}
onValueChanged={(value) => onTitleChanged(props.scene.title.set, value)}
onSetChanged={set => onTitleChanged(set, props.scene.title.value)}
onValueChanged={value => onTitleChanged(props.scene.title.set, value)}
renderOriginalInputField={renderOriginalInputGroup}
renderNewInputField={renderNewInputGroup}
/>
@@ -835,8 +915,8 @@ export const SceneFilenameParser: React.FC = () => {
fieldName="Date"
className="parser-field-date"
parserResult={props.scene.date}
onSetChanged={(set) => onDateChanged(set, props.scene.date.value)}
onValueChanged={(value) => onDateChanged(props.scene.date.set, value)}
onSetChanged={set => onDateChanged(set, props.scene.date.value)}
onValueChanged={value => onDateChanged(props.scene.date.set, value)}
renderOriginalInputField={renderOriginalInputGroup}
renderNewInputField={renderNewInputGroup}
/>
@@ -846,8 +926,12 @@ export const SceneFilenameParser: React.FC = () => {
className="parser-field-performers"
parserResult={props.scene.performerIds}
originalParserResult={props.scene.performers}
onSetChanged={(set) => onPerformerIdsChanged(set, props.scene.performerIds.value)}
onValueChanged={(value) => onPerformerIdsChanged(props.scene.performerIds.set, value)}
onSetChanged={set =>
onPerformerIdsChanged(set, props.scene.performerIds.value)
}
onValueChanged={value =>
onPerformerIdsChanged(props.scene.performerIds.set, value)
}
renderOriginalInputField={renderOriginalSelect}
renderNewInputField={renderNewPerformerSelect}
/>
@@ -857,8 +941,10 @@ export const SceneFilenameParser: React.FC = () => {
className="parser-field-tags"
parserResult={props.scene.tagIds}
originalParserResult={props.scene.tags}
onSetChanged={(set) => onTagIdsChanged(set, props.scene.tagIds.value)}
onValueChanged={(value) => onTagIdsChanged(props.scene.tagIds.set, value)}
onSetChanged={set => onTagIdsChanged(set, props.scene.tagIds.value)}
onValueChanged={value =>
onTagIdsChanged(props.scene.tagIds.set, value)
}
renderOriginalInputField={renderOriginalSelect}
renderNewInputField={renderNewTagSelect}
/>
@@ -868,16 +954,20 @@ export const SceneFilenameParser: React.FC = () => {
className="parser-field-studio"
parserResult={props.scene.studioId}
originalParserResult={props.scene.studio}
onSetChanged={(set) => onStudioIdChanged(set, props.scene.studioId.value)}
onValueChanged={(value) => onStudioIdChanged(props.scene.studioId.set, value)}
onSetChanged={set =>
onStudioIdChanged(set, props.scene.studioId.value)
}
onValueChanged={value =>
onStudioIdChanged(props.scene.studioId.set, value)
}
renderOriginalInputField={renderOriginalSelect}
renderNewInputField={renderNewStudioSelect}
/>
</tr>
)
);
}
function onChange(scene : SceneParserResult, changedScene : SceneParserResult) {
function onChange(scene: SceneParserResult, changedScene: SceneParserResult) {
const newResult = [...parserResult];
const index = newResult.indexOf(scene);
@@ -886,7 +976,11 @@ export const SceneFilenameParser: React.FC = () => {
setParserResult(newResult);
}
function renderHeader(fieldName: string, allSet: boolean, onAllSet: (set: boolean) => void) {
function renderHeader(
fieldName: string,
allSet: boolean,
onAllSet: (set: boolean) => void
) {
if (!showFields.get(fieldName)) {
return null;
}
@@ -897,16 +991,20 @@ export const SceneFilenameParser: React.FC = () => {
<Form.Control
type="checkbox"
checked={allSet}
onChange={() => {onAllSet(!allSet)}}
onChange={() => {
onAllSet(!allSet);
}}
/>
</td>
<th>{fieldName}</th>
</>
)
);
}
function renderTable() {
if (parserResult.length === 0) { return undefined; }
if (parserResult.length === 0) {
return undefined;
}
return (
<>
@@ -918,18 +1016,23 @@ export const SceneFilenameParser: React.FC = () => {
<th>Filename</th>
{renderHeader("Title", allTitleSet, onSelectAllTitleSet)}
{renderHeader("Date", allDateSet, onSelectAllDateSet)}
{renderHeader("Performers", allPerformerSet, onSelectAllPerformerSet)}
{renderHeader(
"Performers",
allPerformerSet,
onSelectAllPerformerSet
)}
{renderHeader("Tags", allTagSet, onSelectAllTagSet)}
{renderHeader("Studio", allStudioSet, onSelectAllStudioSet)}
</tr>
</thead>
<tbody>
{parserResult.map((scene) =>
{parserResult.map(scene => (
<SceneParserRow
scene={scene}
key={scene.id}
onChange={(changedScene) => onChange(scene, changedScene)}/>
)}
onChange={changedScene => onChange(scene, changedScene)}
/>
))}
</tbody>
</Table>
</div>
@@ -937,25 +1040,23 @@ export const SceneFilenameParser: React.FC = () => {
currentPage={parserInput.page}
itemsPerPage={parserInput.pageSize}
totalItems={totalItems}
onChangePage={(page) => onPageChanged(page)}
onChangePage={page => onPageChanged(page)}
/>
<Button variant="primary" onClick={onApply}>Apply</Button>
<Button variant="primary" onClick={onApply}>
Apply
</Button>
</div>
</>
)
);
}
return (
<Card id="parser-container">
<h4>Scene Filename Parser</h4>
<ParserInput
input={parserInput}
onFind={(input) => onFindClicked(input)}
/>
<ParserInput input={parserInput} onFind={input => onFindClicked(input)} />
{isLoading ? <Spinner animation="border" variant="light" /> : undefined}
{renderTable()}
</Card>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
/* eslint-disable consistent-return, default-case */
import { CriterionModifier } from "src/core/generated-graphql";
import {
CriterionModifier,
} from "src/core/generated-graphql";
import { Criterion, CriterionType, StringCriterion, NumberCriterion } from "./criterion";
Criterion,
CriterionType,
StringCriterion,
NumberCriterion
} from "./criterion";
import { FavoriteCriterion } from "./favorite";
import { HasMarkersCriterion } from "./has-markers";
import { IsMissingCriterion } from "./is-missing";
@@ -15,16 +18,26 @@ import { TagsCriterion } from "./tags";
export function makeCriteria(type: CriterionType = "none") {
switch (type) {
case "none": return new NoneCriterion();
case "rating": return new RatingCriterion();
case "resolution": return new ResolutionCriterion();
case "favorite": return new FavoriteCriterion();
case "hasMarkers": return new HasMarkersCriterion();
case "isMissing": return new IsMissingCriterion();
case "tags": return new TagsCriterion("tags");
case "sceneTags": return new TagsCriterion("sceneTags");
case "performers": return new PerformersCriterion();
case "studios": return new StudiosCriterion();
case "none":
return new NoneCriterion();
case "rating":
return new RatingCriterion();
case "resolution":
return new ResolutionCriterion();
case "favorite":
return new FavoriteCriterion();
case "hasMarkers":
return new HasMarkersCriterion();
case "isMissing":
return new IsMissingCriterion();
case "tags":
return new TagsCriterion("tags");
case "sceneTags":
return new TagsCriterion("sceneTags");
case "performers":
return new PerformersCriterion();
case "studios":
return new StudiosCriterion();
case "birth_year":
case "age": {

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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