This commit is contained in:
Infinite
2020-01-28 19:35:09 +01:00
parent 3fa3f61d93
commit ac3d03715f
58 changed files with 1533 additions and 1483 deletions

View File

@@ -3,6 +3,7 @@
"stylelint-order" "stylelint-order"
], ],
"rules": { "rules": {
"indentation": 2,
"at-rule-empty-line-before": [ "always", { "at-rule-empty-line-before": [ "always", {
except: ["after-same-name", "first-nested" ], except: ["after-same-name", "first-nested" ],
ignore: ["after-comment"], ignore: ["after-comment"],
@@ -46,7 +47,6 @@
"function-parentheses-space-inside": "never-single-line", "function-parentheses-space-inside": "never-single-line",
"function-url-quotes": "always", "function-url-quotes": "always",
"function-whitespace-after": "always", "function-whitespace-after": "always",
"indentation": 4,
"length-zero-no-unit": true, "length-zero-no-unit": true,
"max-empty-lines": 1, "max-empty-lines": 1,
"max-nesting-depth": 3, "max-nesting-depth": 3,

View File

@@ -12,7 +12,7 @@ import Scenes from "./components/scenes/scenes";
import { Settings } from "./components/Settings/Settings"; import { Settings } from "./components/Settings/Settings";
import { Stats } from "./components/Stats"; import { Stats } from "./components/Stats";
import Studios from "./components/Studios/Studios"; import Studios from "./components/Studios/Studios";
import Tags from "./components/Tags/Tags"; import { TagList } from "./components/Tags/TagList";
import { SceneFilenameParser } from "./components/scenes/SceneFilenameParser"; import { SceneFilenameParser } from "./components/scenes/SceneFilenameParser";
library.add(fas); library.add(fas);
@@ -20,8 +20,8 @@ library.add(fas);
export const App: React.FC = () => ( export const App: React.FC = () => (
<div className="bp3-dark"> <div className="bp3-dark">
<ErrorBoundary> <ErrorBoundary>
<MainNavbar />
<ToastProvider> <ToastProvider>
<MainNavbar />
<div className="main"> <div className="main">
<Switch> <Switch>
<Route exact path="/" component={Stats} /> <Route exact path="/" component={Stats} />
@@ -29,7 +29,7 @@ export const App: React.FC = () => (
{/* <Route path="/scenes/:id" component={Scene} /> */} {/* <Route path="/scenes/:id" component={Scene} /> */}
<Route path="/galleries" component={Galleries} /> <Route path="/galleries" component={Galleries} />
<Route path="/performers" component={Performers} /> <Route path="/performers" component={Performers} />
<Route path="/tags" component={Tags} /> <Route path="/tags" component={TagList} />
<Route path="/studios" component={Studios} /> <Route path="/studios" component={Studios} />
<Route path="/settings" component={Settings} /> <Route path="/settings" component={Settings} />
<Route <Route

View File

@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { Button, Table, Spinner } from "react-bootstrap"; import { Button, Table } from "react-bootstrap";
import { LoadingIndicator } from 'src/components/Shared';
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
export const SettingsAboutPanel: React.FC = () => { export const SettingsAboutPanel: React.FC = () => {
@@ -26,10 +27,8 @@ export const SettingsAboutPanel: React.FC = () => {
function maybeRenderLatestVersion() { function maybeRenderLatestVersion() {
if ( if (
!dataLatest || !dataLatest?.latestversion.shorthash ||
!dataLatest.latestversion || !dataLatest?.latestversion.url
!dataLatest.latestversion.shorthash ||
!dataLatest.latestversion.url
) { ) {
return; return;
} }
@@ -149,12 +148,12 @@ export const SettingsAboutPanel: React.FC = () => {
</tr> </tr>
</tbody> </tbody>
</Table> </Table>
{!data || loading ? <Spinner animation="border" variant="light" /> : ""} {!data || loading ? <LoadingIndicator inline /> : ""}
{error && <span>{error.message}</span>} {error && <span>{error.message}</span>}
{errorLatest && <span>{errorLatest.message}</span>} {errorLatest && <span>{errorLatest.message}</span>}
{renderVersion()} {renderVersion()}
{!dataLatest || loadingLatest || networkStatus === 4 ? ( {!dataLatest || loadingLatest || networkStatus === 4 ? (
<Spinner animation="border" variant="light" /> <LoadingIndicator inline />
) : ( ) : (
<>{renderLatestVersion()}</> <>{renderLatestVersion()}</>
)} )}

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, Form, InputGroup, Spinner } from "react-bootstrap"; import { Button, Form, InputGroup } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { Icon } from "src/components/Shared"; import { Icon, LoadingIndicator } from "src/components/Shared";
import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect"; import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect";
export const SettingsConfigurationPanel: React.FC = () => { export const SettingsConfigurationPanel: React.FC = () => {
@@ -154,7 +154,7 @@ export const SettingsConfigurationPanel: React.FC = () => {
if (error) return <h1>{error.message}</h1>; if (error) return <h1>{error.message}</h1>;
if (!data?.configuration || loading) if (!data?.configuration || loading)
return <Spinner animation="border" variant="light" />; return <LoadingIndicator />;
return ( return (
<> <>

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, Form, Spinner } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { LoadingIndicator } from 'src/components/Shared';
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
@@ -52,7 +53,7 @@ export const SettingsInterfacePanel: React.FC = () => {
<> <>
{config.error ? <h1>{config.error.message}</h1> : ""} {config.error ? <h1>{config.error.message}</h1> : ""}
{!config?.data?.configuration || config.loading ? ( {!config?.data?.configuration || config.loading ? (
<Spinner animation="border" variant="light" /> <LoadingIndicator />
) : ( ) : (
"" ""
)} )}

View File

@@ -1,16 +1,10 @@
import { import {
Button, Button,
Form,
Modal, Modal,
Nav,
Navbar,
OverlayTrigger,
Popover
} from "react-bootstrap"; } from "react-bootstrap";
import React, { useState } from "react"; import React, { useState } from "react";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { NavUtils } from "src/utils"; import { ImageInput } from 'src/components/Shared';
interface IProps { interface IProps {
performer?: Partial<GQL.PerformerDataFragment>; performer?: Partial<GQL.PerformerDataFragment>;
@@ -22,102 +16,46 @@ interface IProps {
onDelete: () => void; onDelete: () => void;
onAutoTag?: () => void; onAutoTag?: () => void;
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void; onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
// TODO: only for performers. make generic
scrapers?: Pick<GQL.Scraper, "id" | "name">[];
onDisplayScraperDialog?: (scraper: Pick<GQL.Scraper, "id" | "name">) => void;
} }
export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => { export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
function renderEditButton() { function renderEditButton() {
if (props.isNew) { if (props.isNew) return;
return;
}
return ( return (
<Button variant="primary" onClick={() => props.onToggleEdit()}> <Button variant="primary" className="edit" onClick={() => props.onToggleEdit()}>
{props.isEditing ? "Cancel" : "Edit"} {props.isEditing ? "Cancel" : "Edit"}
</Button> </Button>
); );
} }
function renderSaveButton() { function renderSaveButton() {
if (!props.isEditing) { if (!props.isEditing) return;
return;
}
return ( return (
<Button variant="success" onClick={() => props.onSave()}> <Button variant="success" className="save" onClick={() => props.onSave()}>
Save Save
</Button> </Button>
); );
} }
function renderDeleteButton() { function renderDeleteButton() {
if (props.isNew || props.isEditing) { if (props.isNew || props.isEditing) return;
return;
}
return ( return (
<Button variant="danger" onClick={() => setIsDeleteAlertOpen(true)}> <Button variant="danger" className="delete" onClick={() => setIsDeleteAlertOpen(true)}>
Delete Delete
</Button> </Button>
); );
} }
function renderImageInput() {
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.Group>
);
}
function renderScraperMenu() {
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?.(s)}
>
{s.name}
</Button>
))
: ""}
</div>
</Popover.Content>
</Popover>
);
return (
<OverlayTrigger trigger="click" placement="bottom" overlay={popover}>
<Button>Scrape with...</Button>
</OverlayTrigger>
);
}
function renderAutoTagButton() { function renderAutoTagButton() {
if (props.isNew || props.isEditing) { if (props.isNew || props.isEditing) return;
return;
}
if (props.onAutoTag) { if (props.onAutoTag) {
return ( return (
<Button <Button
variant="secondary"
onClick={() => { onClick={() => {
if (props.onAutoTag) { if (props.onAutoTag) {
props.onAutoTag(); props.onAutoTag();
@@ -130,19 +68,6 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
} }
} }
function renderScenesButton() {
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>;
}
function renderDeleteAlert() { function renderDeleteAlert() {
const name = props?.studio?.name ?? props?.performer?.name; const name = props?.studio?.name ?? props?.performer?.name;
@@ -165,20 +90,13 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
} }
return ( return (
<> <div className="details-edit">
{renderDeleteAlert()}
<Navbar bg="dark">
<Nav className="mr-auto ml-auto">
{renderEditButton()} {renderEditButton()}
{renderScraperMenu()} <ImageInput isEditing={props.isEditing} onImageChange={props.onImageChange} />
{renderImageInput()}
{renderSaveButton()}
{renderAutoTagButton()} {renderAutoTagButton()}
{renderScenesButton()} {renderSaveButton()}
{renderDeleteButton()} {renderDeleteButton()}
</Nav> {renderDeleteAlert()}
</Navbar> </div>
</>
); );
}; };

View File

@@ -34,10 +34,10 @@ export const DurationInput: React.FC<IProps> = (props: IProps) => {
function renderButtons() { function renderButtons() {
return ( return (
<ButtonGroup vertical> <ButtonGroup vertical>
<Button disabled={props.disabled} onClick={() => increment()}> <Button variant="secondary" className="duration-button" disabled={props.disabled} onClick={() => increment()}>
<Icon icon="chevron-up" /> <Icon icon="chevron-up" />
</Button> </Button>
<Button disabled={props.disabled} onClick={() => decrement()}> <Button variant="secondary" className="duration-button" disabled={props.disabled} onClick={() => decrement()}>
<Icon icon="chevron-down" /> <Icon icon="chevron-down" />
</Button> </Button>
</ButtonGroup> </ButtonGroup>
@@ -53,7 +53,7 @@ export const DurationInput: React.FC<IProps> = (props: IProps) => {
function maybeRenderReset() { function maybeRenderReset() {
if (props.onReset) { if (props.onReset) {
return ( return (
<Button onClick={() => onReset()}> <Button variant="secondary" onClick={onReset}>
<Icon icon="clock" /> <Icon icon="clock" />
</Button> </Button>
); );

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, InputGroup, Form, Modal, Spinner } from "react-bootstrap"; import { Button, InputGroup, Form, Modal } from "react-bootstrap";
import { LoadingIndicator } from 'src/components/Shared';
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
interface IProps { interface IProps {
@@ -55,7 +56,7 @@ export const FolderSelect: React.FC<IProps> = (props: IProps) => {
/> />
<InputGroup.Append> <InputGroup.Append>
{!data || !data.directories || loading ? ( {!data || !data.directories || loading ? (
<Spinner animation="border" variant="light" /> <LoadingIndicator inline />
) : ( ) : (
"" ""
)} )}

View File

@@ -0,0 +1,23 @@
import React from "react";
import { Button, Form } from 'react-bootstrap';
interface IImageInput {
isEditing: boolean;
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
}
export const ImageInput: React.FC<IImageInput> = ({ isEditing, onImageChange }) => {
if (!isEditing) return <div />;
return (
<Form.Label className="image-input">
<Button variant="secondary">Browse for image...</Button>
<Form.Control
type="file"
onChange={onImageChange}
accept=".jpg,.jpeg,.png"
/>
</Form.Label>
);
}

View File

@@ -1,19 +1,21 @@
import React from "react"; import React from "react";
import { Spinner } from "react-bootstrap"; import { Spinner } from "react-bootstrap";
import cx from 'classnames';
interface ILoadingProps { interface ILoadingProps {
message: string; message?: string;
inline?: boolean;
} }
const CLASSNAME = "LoadingIndicator"; const CLASSNAME = "LoadingIndicator";
const CLASSNAME_MESSAGE = `${CLASSNAME}-message`; const CLASSNAME_MESSAGE = `${CLASSNAME}-message`;
const LoadingIndicator: React.FC<ILoadingProps> = ({ message }) => ( const LoadingIndicator: React.FC<ILoadingProps> = ({ message, inline = false }) => (
<div className={CLASSNAME}> <div className={cx(CLASSNAME, { inline }) }>
<Spinner animation="border" role="status"> <Spinner animation="border" role="status">
<span className="sr-only">Loading...</span> <span className="sr-only">Loading...</span>
</Spinner> </Spinner>
<h4 className={CLASSNAME_MESSAGE}>{message}</h4> <h4 className={CLASSNAME_MESSAGE}>{message ?? "Loading..."}</h4>
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from "react"; import React, { useState, useCallback, CSSProperties } from "react";
import Select, { ValueType } from "react-select"; import Select, { ValueType } from "react-select";
import CreatableSelect from "react-select/creatable"; import CreatableSelect from "react-select/creatable";
import { debounce } from "lodash"; import { debounce } from "lodash";
@@ -38,6 +38,8 @@ interface ISelectProps {
isClearable?: boolean, isClearable?: boolean,
onInputChange?: (input: string) => void; onInputChange?: (input: string) => void;
placeholder?: string; placeholder?: string;
showDropdown?: boolean;
groupHeader?: string;
} }
interface ISceneGallerySelect { interface ISceneGallerySelect {
@@ -120,6 +122,8 @@ export const ScrapePerformerSuggest: React.FC<IScrapePerformerSuggestProps> = pr
items={items} items={items}
initialIds={[]} initialIds={[]}
placeholder={props.placeholder} placeholder={props.placeholder}
className="select-suggest"
showDropdown={false}
/> />
); );
}; };
@@ -147,10 +151,13 @@ export const MarkerTitleSuggest: React.FC<IMarkerSuggestProps> = props => {
isLoading={loading} isLoading={loading}
items={items} items={items}
initialIds={initialIds} initialIds={initialIds}
placeholder="Marker title..."
className="select-suggest"
showDropdown={false}
groupHeader="Previously used titles..."
/> />
); );
}; };
export const FilterSelect: React.FC<IFilterProps & ITypeProps> = props => export const FilterSelect: React.FC<IFilterProps & ITypeProps> = props =>
props.type === "performers" ? ( props.type === "performers" ? (
<PerformerSelect {...(props as IFilterProps)} /> <PerformerSelect {...(props as IFilterProps)} />
@@ -304,44 +311,28 @@ const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
creatable = false, creatable = false,
isMulti = false, isMulti = false,
onInputChange, onInputChange,
placeholder placeholder,
showDropdown = true,
groupHeader
}) => { }) => {
const defaultValue = const defaultValue =
items.filter(item => initialIds?.indexOf(item.value) !== -1) ?? null; items.filter(item => initialIds?.indexOf(item.value) !== -1) ?? null;
const options = groupHeader ? [{
label: groupHeader,
options: items
}] : items;
const styles = { const styles = {
control: (provided:any) => ({ option: (provided:CSSProperties) => ({
...provided, ...provided,
background: '#394b59', color: "#000"
borderColor: 'rgba(16,22,26,.4)' })
}),
singleValue: (provided:any) => ({
...provided,
color: 'f5f8fa',
}),
placeholder: (provided:any) => ({
...provided,
color: 'f5f8fa',
}),
menu: (provided:any) => ({
...provided,
color: 'f5f8fa',
background: '#394b59',
borderColor: 'rgba(16,22,26,.4)',
zIndex: 3
}),
option: (provided:any, state:any ) => (
state.isFocused ? { ...provided, backgroundColor: '#137cbd' } : provided
),
multiValueRemove: (provided:any, state:any) => (
{ ...provided, color: 'black' }
)
}; };
const props = { const props = {
options: items, options,
value: selectedOptions, value: selectedOptions,
styles,
className, className,
onChange, onChange,
isMulti, isMulti,
@@ -351,7 +342,11 @@ const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
placeholder, placeholder,
onInputChange, onInputChange,
isLoading, isLoading,
components: { IndicatorSeparator: () => null } styles,
components: {
IndicatorSeparator: () => null,
...(!showDropdown && { DropdownIndicator: () => null })
}
}; };
return creatable ? ( return creatable ? (

View File

@@ -15,3 +15,4 @@ export { DurationInput } from "./DurationInput";
export { TagLink } from "./TagLink"; export { TagLink } from "./TagLink";
export { HoverPopover } from "./HoverPopover"; export { HoverPopover } from "./HoverPopover";
export { default as LoadingIndicator } from "./LoadingIndicator"; export { default as LoadingIndicator } from "./LoadingIndicator";
export { ImageInput } from './ImageInput';

View File

@@ -14,4 +14,36 @@
height: 3rem; height: 3rem;
width: 3rem; width: 3rem;
} }
&.inline {
height: inherit;
}
}
.details-edit {
display: flex;
justify-content: left;
.btn {
margin-right: .5rem;
}
.delete,
.save {
margin-left: auto;
}
}
.select-suggest {
&:hover {
cursor: text;
}
}
.duration-button {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
line-height: 10px;
margin-left: 0 !important;
padding: 1px 7px;
} }

View File

@@ -9,14 +9,14 @@ interface IProps {
export const StudioCard: React.FC<IProps> = ({ studio }) => { export const StudioCard: React.FC<IProps> = ({ studio }) => {
return ( return (
<Card className="col-4"> <Card className="studio-card">
<Link <Link
to={`/studios/${studio.id}`} to={`/studios/${studio.id}`}
className="studio previewable image" className="studio previewable image"
style={{ backgroundImage: `url(${studio.image_path})` }} style={{ backgroundImage: `url(${studio.image_path})` }}
/> />
<div className="card-section"> <div className="card-section">
<h4 className="text-truncate">{studio.name}</h4> <h5 className="text-truncate">{studio.name}</h5>
<span>{studio.scene_count} scenes.</span> <span>{studio.scene_count} scenes.</span>
</div> </div>
</Card> </Card>

View File

@@ -1,14 +1,16 @@
/* eslint-disable react/no-this-in-sfc */ /* eslint-disable react/no-this-in-sfc */
import { Form, Spinner, Table } from "react-bootstrap"; import { Table } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useHistory } from "react-router-dom"; import { useParams, useHistory } from "react-router-dom";
import cx from 'classnames';
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { ImageUtils, TableUtils } from "src/utils"; import { ImageUtils, TableUtils } from "src/utils";
import { DetailsEditNavbar } from "src/components/Shared"; import { DetailsEditNavbar, Modal, LoadingIndicator } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { StudioScenesPanel } from './StudioScenesPanel';
export const Studio: React.FC = () => { export const Studio: React.FC = () => {
const history = useHistory(); const history = useHistory();
@@ -18,17 +20,16 @@ export const Studio: React.FC = () => {
// Editing state // Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew); const [isEditing, setIsEditing] = useState<boolean>(isNew);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Editing studio state // Editing studio state
const [image, setImage] = useState<string | undefined>(undefined); const [image, setImage] = useState<string>();
const [name, setName] = useState<string | undefined>(undefined); const [name, setName] = useState<string>();
const [url, setUrl] = useState<string | undefined>(undefined); const [url, setUrl] = useState<string>();
// Studio state // Studio state
const [studio, setStudio] = useState<Partial<GQL.StudioDataFragment>>({}); const [studio, setStudio] = useState<Partial<GQL.StudioDataFragment>>({});
const [imagePreview, setImagePreview] = useState<string | undefined>( const [imagePreview, setImagePreview] = useState<string>();
undefined
);
const { data, error, loading } = StashService.useFindStudio(id); const { data, error, loading } = StashService.useFindStudio(id);
const [updateStudio] = StashService.useStudioUpdate( const [updateStudio] = StashService.useStudioUpdate(
@@ -71,7 +72,7 @@ export const Studio: React.FC = () => {
if (!isNew && !isEditing) { if (!isNew && !isEditing) {
if (!data?.findStudio || loading) if (!data?.findStudio || loading)
return <Spinner animation="border" variant="light" />; return <LoadingIndicator />;
if (error) return <div>{error.message}</div>; if (error) return <div>{error.message}</div>;
} }
@@ -98,8 +99,10 @@ export const Studio: React.FC = () => {
} }
} else { } else {
const result = await createStudio(); const result = await createStudio();
if (result.data?.studioCreate?.id) if (result.data?.studioCreate?.id) {
history.push(`/studios/${result.data.studioCreate.id}`); history.push(`/studios/${result.data.studioCreate.id}`);
setIsEditing(false);
}
} }
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
@@ -107,9 +110,7 @@ export const Studio: React.FC = () => {
} }
async function onAutoTag() { async function onAutoTag() {
if (!studio || !studio.id) { if (!studio.id) return;
return;
}
try { try {
await StashService.queryMetadataAutoTag({ studios: [studio.id] }); await StashService.queryMetadataAutoTag({ studios: [studio.id] });
Toast.success({ content: "Started auto tagging" }); Toast.success({ content: "Started auto tagging" });
@@ -133,52 +134,57 @@ export const Studio: React.FC = () => {
ImageUtils.onImageChange(event, onImageLoad); ImageUtils.onImageChange(event, onImageLoad);
} }
// TODO: CSS class function renderDeleteAlert() {
return ( return (
<div className="columns is-multiline no-spacing"> <Modal
<div className="column is-half details-image-container"> show={isDeleteAlertOpen}
<img className="studio" alt={name} src={imagePreview} /> icon="trash-alt"
</div> accept={{ text: "Delete", variant: "danger", onClick: onDelete }}
<div className="column is-half details-detail-container"> cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
>
<p>Are you sure you want to delete {studio.name ?? 'studio'}?</p>
</Modal>
);
}
return (
<div className="row">
<div className={cx('studio-details', { 'col-4': !isNew, 'col-8': isNew})}>
{ isNew && <h2>Add Studio</h2> }
<img className="logo" alt={name} src={imagePreview} />
<Table id="performer-details" style={{ width: "100%" }}>
<tbody>
{TableUtils.renderInputGroup({
title: "Name",
value: studio.name ?? '',
isEditing: !!isEditing,
onChange: setName
})}
{TableUtils.renderInputGroup({
title: "URL",
value: url,
isEditing: !!isEditing,
onChange: setUrl
})}
</tbody>
</Table>
<DetailsEditNavbar <DetailsEditNavbar
studio={studio} studio={studio}
isNew={isNew} isNew={isNew}
isEditing={isEditing} isEditing={isEditing}
onToggleEdit={() => { onToggleEdit={() => setIsEditing(!isEditing)}
setIsEditing(!isEditing);
updateStudioEditState(studio);
}}
onSave={onSave} onSave={onSave}
onDelete={onDelete}
onAutoTag={onAutoTag}
onImageChange={onImageChangeHandler} onImageChange={onImageChangeHandler}
onAutoTag={onAutoTag}
onDelete={onDelete}
/> />
<h1>
{!isEditing ? (
<span>{studio.name}</span>
) : (
<Form.Group controlId="studio-name">
<Form.Label>Name</Form.Label>
<Form.Control
defaultValue={studio.name || ""}
placeholder="Name"
onChange={(event: any) => setName(event.target.value)}
/>
</Form.Group>
)}
</h1>
<Table style={{ width: "100%" }}>
<tbody>
{TableUtils.renderInputGroup({
title: "URL",
value: studio.url ?? undefined,
isEditing,
onChange: (val: string) => setUrl(val)
})}
</tbody>
</Table>
</div> </div>
{ !isNew && (
<div className="col-8">
<StudioScenesPanel studio={studio} />
</div>
)}
{renderDeleteAlert()}
</div> </div>
); );
}; };

View File

@@ -0,0 +1,47 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
import { ListFilterModel } from "src/models/list-filter/filter";
import { SceneList } from "../../scenes/SceneList";
interface IStudioScenesPanel {
studio: Partial<GQL.StudioDataFragment>;
}
export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({
studio
}) => {
function filterHook(filter: ListFilterModel) {
const studioValue = { id: studio.id!, label: studio.name! };
// if studio is already present, then we modify it, otherwise add
let studioCriterion = filter.criteria.find(c => {
return c.type === "studios";
});
if (
studioCriterion &&
(studioCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
studioCriterion.modifier === GQL.CriterionModifier.Includes)
) {
// add the studio if not present
if (
!studioCriterion.value.find((p: any) => {
return p.id === studio.id;
})
) {
studioCriterion.value.push(studioValue);
}
studioCriterion.modifier = GQL.CriterionModifier.IncludesAll;
} else {
// overwrite
studioCriterion = new StudiosCriterion();
studioCriterion.value = [studioValue];
filter.criteria.push(studioCriterion);
}
return filter;
}
return <SceneList subComponent filterHook={filterHook} />;
};

View File

@@ -14,9 +14,9 @@ export const StudioList: React.FC = () => {
result: FindStudiosQueryResult, result: FindStudiosQueryResult,
filter: ListFilterModel filter: ListFilterModel
) { ) {
if (!result.data || !result.data.findStudios) { if (!result.data?.findStudios)
return; return;
}
if (filter.displayMode === DisplayMode.Grid) { if (filter.displayMode === DisplayMode.Grid) {
return ( return (
<div className="grid"> <div className="grid">

View File

@@ -0,0 +1,8 @@
.studio-details {
padding-left: 4rem;
.logo {
margin: 4rem 0;
width: 100%;
}
}

View File

@@ -1,10 +1,10 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Form, Spinner } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { NavUtils } from "src/utils"; import { NavUtils } from "src/utils";
import { Icon, Modal } from "src/components/Shared"; import { Icon, Modal, LoadingIndicator } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
export const TagList: React.FC = () => { export const TagList: React.FC = () => {
@@ -93,25 +93,28 @@ export const TagList: React.FC = () => {
</Modal> </Modal>
); );
if (!data?.allTags) return <Spinner animation="border" variant="light" />; if (!data?.allTags)
return <LoadingIndicator />;
if (error) return <div>{error.message}</div>; if (error) return <div>{error.message}</div>;
const tagElements = data.allTags.map(tag => { const tagElements = data.allTags.map(tag => {
return ( return (
<>
{deleteAlert}
<div key={tag.id} className="tag-list-row"> <div key={tag.id} className="tag-list-row">
<Button variant="link" onClick={() => setEditingTag(tag)}> <Button variant="link" onClick={() => setEditingTag(tag)}>
{tag.name} {tag.name}
</Button> </Button>
<div style={{ float: "right" }}> <div style={{ float: "right" }}>
<Button onClick={() => onAutoTag(tag)}>Auto Tag</Button> <Button variant="secondary" onClick={() => onAutoTag(tag)}>Auto Tag</Button>
<Button variant="secondary">
<Link to={NavUtils.makeTagScenesUrl(tag)}> <Link to={NavUtils.makeTagScenesUrl(tag)}>
Scenes: {tag.scene_count} Scenes: {tag.scene_count}
</Link> </Link>
</Button>
<Button variant="secondary">
<Link to={NavUtils.makeTagSceneMarkersUrl(tag)}> <Link to={NavUtils.makeTagSceneMarkersUrl(tag)}>
Markers: {tag.scene_marker_count} Markers: {tag.scene_marker_count}
</Link> </Link>
</Button>
<span> <span>
Total: {(tag.scene_count || 0) + (tag.scene_marker_count || 0)} Total: {(tag.scene_count || 0) + (tag.scene_marker_count || 0)}
</span> </span>
@@ -120,7 +123,6 @@ export const TagList: React.FC = () => {
</Button> </Button>
</div> </div>
</div> </div>
</>
); );
}); });
@@ -154,6 +156,7 @@ export const TagList: React.FC = () => {
</Modal> </Modal>
{tagElements} {tagElements}
{deleteAlert}
</div> </div>
); );
}; };

View File

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

View File

@@ -0,0 +1,28 @@
#tag-list-container {
display: flex;
flex-direction: column;
margin: 0 auto;
width: 50vw;
a,
.btn {
color: $text-color;
text-decoration: none;
}
.tag-list-row {
cursor: pointer;
margin: 10px;
.btn {
margin: 0 10px;
}
}
.tag-list-row:hover {
text-decoration: underline;
}
}

View File

@@ -1,35 +1,38 @@
.wall-overlay { .wall-overlay {
background-color: rgba(0, 0, 0, .8); background-color: rgba(0, 0, 0, .8);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0; bottom: 0;
z-index: 1; left: 0;
pointer-events: none; pointer-events: none;
position: fixed;
right: 0;
top: 0;
transition: transform .5s ease-in-out; transition: transform .5s ease-in-out;
z-index: 1;
} }
.visible { .visible {
opacity: 1; opacity: 1;
transition: opacity .5s ease-in-out; transition: opacity .5s ease-in-out;
} }
.hidden { .hidden {
opacity: 0; opacity: 0;
transition: opacity .5s ease-in-out; transition: opacity .5s ease-in-out;
} }
.visible-unanimated { .visible-unanimated {
opacity: 1; opacity: 1;
} }
.hidden-unanimated { .hidden-unanimated {
opacity: 0; opacity: 0;
} }
.double-scale { .double-scale {
position: absolute;
z-index: 2;
transform: scale(2);
background-color: black; background-color: black;
position: absolute;
transform: scale(2);
z-index: 2;
} }
.double-scale img { .double-scale img {
@@ -38,60 +41,61 @@
.scene-wall-item-container { .scene-wall-item-container {
display: flex; display: flex;
justify-content: center;
position: relative;
width: 100%;
height: 100%; height: 100%;
transition: transform .5s; justify-content: center;
max-height: 253px; max-height: 253px;
position: relative;
transition: transform .5s;
width: 100%;
} }
.scene-wall-item-container video { .scene-wall-item-container video {
height: 100%;
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%;
z-index: -1; z-index: -1;
} }
.scene-wall-item-text-container { .scene-wall-item-text-container {
position: absolute; background: linear-gradient(rgba(255, 255, 255, .25), rgba(255, 255, 255, .65));
font-weight: 700;
color: #444;
padding: 5px;
width: 100%;
bottom: 0; bottom: 0;
color: #444;
font-weight: 700;
left: 0; left: 0;
background: linear-gradient(rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0.65));
overflow: hidden; overflow: hidden;
padding: 5px;
position: absolute;
text-align: center; text-align: center;
width: 100%;
& span { & span {
line-height: 1;
font-weight: 400;
font-size: 10px; font-size: 10px;
font-weight: 400;
line-height: 1;
margin: 0 3px; margin: 0 3px;
} }
} }
.scene-wall-item-blur { .scene-wall-item-blur {
position: absolute;
top: -5px;
left: -5px;
right: -5px;
bottom: -5px; bottom: -5px;
left: -5px;
position: absolute;
right: -5px;
top: -5px;
z-index: -1; z-index: -1;
} }
.wall-item video, .wall-item img { .wall-item video,
width: 100%; .wall-item img {
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
width: 100%;
} }
.wall-item { .wall-item {
width: 20%;
padding: 0 !important;
line-height: 0; line-height: 0;
overflow: visible; overflow: visible;
padding: 0 !important;
position: relative; position: relative;
width: 20%;
} }

View File

@@ -62,7 +62,7 @@ export const WallItem: React.FC<IWallItemProps> = (
} }
let linkSrc: string = "#"; let linkSrc: string = "#";
if (props.clickHandler) { if (!props.clickHandler) {
if (props.scene) { if (props.scene) {
linkSrc = `/scenes/${props.scene.id}`; linkSrc = `/scenes/${props.scene.id}`;
} else if (props.sceneMarker) { } else if (props.sceneMarker) {
@@ -100,7 +100,6 @@ export const WallItem: React.FC<IWallItemProps> = (
setPreviewPath(props.scene.paths.webp || ""); setPreviewPath(props.scene.paths.webp || "");
setScreenshotPath(props.scene.paths.screenshot || ""); setScreenshotPath(props.scene.paths.screenshot || "");
setTitle(props.scene.title || ""); setTitle(props.scene.title || "");
// tags = props.scene.tags.map((tag) => (<span key={tag.id}>{tag.name}</span>));
} }
}, [props.sceneMarker, props.scene]); }, [props.sceneMarker, props.scene]);
@@ -128,7 +127,7 @@ export const WallItem: React.FC<IWallItemProps> = (
onMouseMove={() => debouncedOnMouseEnter.current()} onMouseMove={() => debouncedOnMouseEnter.current()}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
> >
<Link onClick={() => onClick()} to={linkSrc}> <Link onClick={onClick} to={linkSrc}>
<video <video
src={videoPath} src={videoPath}
poster={screenshotPath} poster={screenshotPath}

View File

@@ -117,17 +117,15 @@ export const AddFilter: React.FC<IAddFilterProps> = (
return; return;
} }
return ( return (
<div>
<Form.Control <Form.Control
as="select" as="select"
onChange={onChangedModifierSelect} onChange={onChangedModifierSelect}
value={criterion.modifier} value={criterion.modifier}
> >
{criterion.modifierOptions.map(c => ( {criterion.modifierOptions.map(c => (
<option value={c.value}>{c.label}</option> <option key={c.value} value={c.value}>{c.label}</option>
))} ))}
</Form.Control> </Form.Control>
</div>
); );
} }
@@ -155,10 +153,13 @@ export const AddFilter: React.FC<IAddFilterProps> = (
return ( return (
<FilterSelect <FilterSelect
type={type} type={type}
isMulti
onSelect={items => { onSelect={items => {
criterion.value = items.map(i => ({ id: i.id, label: i.name! })); const newCriterion = _.cloneDeep(criterion);
newCriterion.value = items.map(i => ({ id: i.id, label: i.name! }));
setCriterion(newCriterion);
}} }}
initialIds={criterion.value.map((labeled: any) => labeled.id)} ids={criterion.value.map((labeled: any) => labeled.id)}
/> />
); );
} }
@@ -171,7 +172,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
value={criterion.value} value={criterion.value}
> >
{criterion.options.map(c => ( {criterion.options.map(c => (
<option value={c}>{c}</option> <option key={c} value={c}>{c}</option>
))} ))}
</Form.Control> </Form.Control>
); );
@@ -215,7 +216,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
value={criterion.type} value={criterion.type}
> >
{props.filter.criterionOptions.map(c => ( {props.filter.criterionOptions.map(c => (
<option value={c.value}>{c.label}</option> <option key={c.value} value={c.value}>{c.label}</option>
))} ))}
</Form.Control> </Form.Control>
</Form.Group> </Form.Group>

View File

@@ -134,13 +134,13 @@ export const ListFilter: React.FC<IListFilterProps> = (
} }
return props.filter.displayModeOptions.map(option => ( return props.filter.displayModeOptions.map(option => (
<OverlayTrigger <OverlayTrigger
key={option}
overlay={ overlay={
<Tooltip id="display-mode-tooltip">{getLabel(option)}</Tooltip> <Tooltip id="display-mode-tooltip">{getLabel(option)}</Tooltip>
} }
> >
<Button <Button
variant="secondary" variant="secondary"
key={option}
active={props.filter.displayMode === option} active={props.filter.displayMode === option}
onClick={() => onChangeDisplayMode(option)} onClick={() => onChangeDisplayMode(option)}
> >
@@ -180,7 +180,7 @@ export const ListFilter: React.FC<IListFilterProps> = (
function renderSelectAll() { function renderSelectAll() {
if (props.onSelectAll) { if (props.onSelectAll) {
return ( return (
<Dropdown.Item onClick={() => onSelectAll()}>Select All</Dropdown.Item> <Dropdown.Item key="select-all" onClick={() => onSelectAll()}>Select All</Dropdown.Item>
); );
} }
} }
@@ -188,7 +188,7 @@ export const ListFilter: React.FC<IListFilterProps> = (
function renderSelectNone() { function renderSelectNone() {
if (props.onSelectNone) { if (props.onSelectNone) {
return ( return (
<Dropdown.Item onClick={() => onSelectNone()}> <Dropdown.Item key="select-none" onClick={() => onSelectNone()}>
Select None Select None
</Dropdown.Item> </Dropdown.Item>
); );
@@ -201,7 +201,7 @@ export const ListFilter: React.FC<IListFilterProps> = (
if (props.otherOperations) { if (props.otherOperations) {
props.otherOperations.forEach(o => { props.otherOperations.forEach(o => {
options.push( options.push(
<Dropdown.Item onClick={o.onClick}>{o.text}</Dropdown.Item> <Dropdown.Item key={o.text} onClick={o.onClick}>{o.text}</Dropdown.Item>
); );
}); });
} }
@@ -232,6 +232,7 @@ export const ListFilter: React.FC<IListFilterProps> = (
type="range" type="range"
min={0} min={0}
max={3} max={3}
defaultValue={1}
onChange={(event: any) => onChange={(event: any) =>
onChangeZoom(Number.parseInt(event.target.value, 10)) onChangeZoom(Number.parseInt(event.target.value, 10))
} }
@@ -246,7 +247,7 @@ export const ListFilter: React.FC<IListFilterProps> = (
<div className="filter-container"> <div className="filter-container">
<Form.Control <Form.Control
placeholder="Search..." placeholder="Search..."
value={props.filter.searchTerm} defaultValue={props.filter.searchTerm}
onChange={onChangeQuery} onChange={onChangeQuery}
className="filter-item" className="filter-item"
style={{ width: "inherit" }} style={{ width: "inherit" }}
@@ -258,7 +259,7 @@ export const ListFilter: React.FC<IListFilterProps> = (
className="filter-item" className="filter-item"
> >
{PAGE_SIZE_OPTIONS.map(s => ( {PAGE_SIZE_OPTIONS.map(s => (
<option value={s}>{s}</option> <option value={s} key={s}>{s}</option>
))} ))}
</Form.Control> </Form.Control>
<ButtonGroup className="filter-item"> <ButtonGroup className="filter-item">

View File

@@ -48,6 +48,9 @@ export const Pagination: React.FC<IPaginationProps> = ({
</Button> </Button>
)); ));
if(pages.length <= 1)
return <div />;
return ( return (
<ButtonGroup className="filter-container pagination"> <ButtonGroup className="filter-container pagination">
<Button variant="secondary" disabled={currentPage === 1} onClick={() => onChangePage(1)}> <Button variant="secondary" disabled={currentPage === 1} onClick={() => onChangePage(1)}>

View File

@@ -1,10 +1,10 @@
.pagination { .pagination {
.btn { .btn {
border-left: 1px solid $body-bg;
border-right: 1px solid $body-bg;
flex-grow: 0; flex-grow: 0;
padding-left: 15px; padding-left: 15px;
padding-right: 15px; padding-right: 15px;
border-left: 1px solid $body-bg;
border-right: 1px solid $body-bg;
transition: none; transition: none;
} }
} }

View File

@@ -25,7 +25,7 @@ export const PerformerCard: React.FC<IPerformerCardProps> = (
} }
return ( return (
<Card className="grid-item"> <Card className="performer-card">
<Link <Link
to={`/performers/${props.performer.id}`} to={`/performers/${props.performer.id}`}
className="performer previewable image" className="performer previewable image"

View File

@@ -1,15 +1,15 @@
/* eslint-disable react/no-this-in-sfc */ /* eslint-disable react/no-this-in-sfc */
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, Spinner, Tabs, Tab } from "react-bootstrap"; import { Button, Tabs, Tab } from "react-bootstrap";
import { useParams, useHistory } from "react-router-dom"; import { useParams, useHistory } from "react-router-dom";
import cx from 'classnames'
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { Icon } from "src/components/Shared"; import { Icon, LoadingIndicator } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import Lightbox from "react-images"; import Lightbox from "react-images";
import { IconName } from "@fortawesome/fontawesome-svg-core";
import { PerformerDetailsPanel } from "./PerformerDetailsPanel"; import { PerformerDetailsPanel } from "./PerformerDetailsPanel";
import { PerformerOperationsPanel } from "./PerformerOperationsPanel"; import { PerformerOperationsPanel } from "./PerformerOperationsPanel";
import { PerformerScenesPanel } from "./PerformerScenesPanel"; import { PerformerScenesPanel } from "./PerformerScenesPanel";
@@ -49,7 +49,7 @@ export const Performer: React.FC = () => {
} }
if ((!isNew && (!data || !data.findPerformer)) || isLoading) if ((!isNew && (!data || !data.findPerformer)) || isLoading)
return <Spinner animation="border" variant="light" />; return <LoadingIndicator />;
if (error) return <div>{error.message}</div>; if (error) return <div>{error.message}</div>;
@@ -163,42 +163,46 @@ export const Performer: React.FC = () => {
onSave(performer); onSave(performer);
} }
function renderIcons() { const renderIcons = () => (
function maybeRenderURL(url?: string, icon: IconName = "link") {
if (performer.url) {
return (
<Button>
<a href={performer.url}>
<Icon icon={icon} />
</a>
</Button>
);
}
}
return (
<>
<span className="name-icons"> <span className="name-icons">
<Button <Button
className={performer.favorite ? "favorite" : "not-favorite"} className={cx('minimal', performer.favorite ? "favorite" : "not-favorite")}
onClick={() => setFavorite(!performer.favorite)} onClick={() => setFavorite(!performer.favorite)}
> >
<Icon icon="heart" /> <Icon icon="heart" />
</Button> </Button>
{maybeRenderURL(performer.url ?? undefined)} { performer.url && (
{/* TODO - render instagram and twitter links with icons */} <Button className="minimal">
<a href={performer.url} className="link" target="_blank" rel="noopener noreferrer">
<Icon icon="link" />
</a>
</Button>
)}
{ performer.twitter && (
<Button className="minimal">
<a href={`https://www.twitter.com/${performer.twitter}`} className="twitter" target="_blank" rel="noopener noreferrer">
<Icon icon="dove" />
</a>
</Button>
)}
{ performer.instagram && (
<Button className="minimal">
<a href={`https://www.instagram.com/${performer.instagram}`} className="instagram" target="_blank" rel="noopener noreferrer">
<Icon icon="camera" />
</a>
</Button>
)}
</span> </span>
</> )
);
}
function renderNewView() { function renderNewView() {
return ( return (
<div className="columns is-multiline no-spacing"> <div className="row new-view">
<div className="column is-half details-image-container"> <div className="col-4">
<img className="performer" src={imagePreview} alt="Performer" /> <img className="photo" src={imagePreview} alt="Performer" />
</div> </div>
<div className="column is-half details-detail-container"> <div className="col-6">
<h2>Create Performer</h2>
{renderTabs()} {renderTabs()}
</div> </div>
</div> </div>
@@ -207,47 +211,38 @@ export const Performer: React.FC = () => {
const photos = [{ src: imagePreview || "", caption: "Image" }]; const photos = [{ src: imagePreview || "", caption: "Image" }];
function openLightbox() {
setLightboxIsOpen(true);
}
function closeLightbox() {
setLightboxIsOpen(false);
}
if (isNew) { if (isNew) {
return renderNewView(); return renderNewView();
} }
return ( return (
<> <div id="performer-page" className="row">
<div id="performer-page"> <div className="image-container col-4 offset-1">
<div className="details-image-container"> <Button variant="link" onClick={() => setLightboxIsOpen(true)}>
<Button variant="link" onClick={openLightbox}>
<img className="performer" src={imagePreview} alt="Performer" /> <img className="performer" src={imagePreview} alt="Performer" />
</Button> </Button>
</div> </div>
<div className="col-6">
<div className="performer-head"> <div className="performer-head">
<h1 className="bp3-heading"> <h2>
{performer.name} {performer.name}
{renderIcons()} {renderIcons()}
</h1> </h2>
{maybeRenderAliases()} {maybeRenderAliases()}
{maybeRenderAge()} {maybeRenderAge()}
</div> </div>
<div className="performer-body"> <div className="performer-body">
<div className="details-detail-container">{renderTabs()}</div> <div className="performer-tabs">{renderTabs()}</div>
</div> </div>
</div> </div>
<Lightbox <Lightbox
images={photos} images={photos}
onClose={closeLightbox} onClose={() => setLightboxIsOpen(false)}
currentImage={0} currentImage={0}
isOpen={lightboxIsOpen} isOpen={lightboxIsOpen}
onClickImage={() => window.open(imagePreview, "_blank")} onClickImage={() => window.open(imagePreview, "_blank")}
width={9999} width={9999}
/> />
</> </div>
); );
}; };

View File

@@ -6,12 +6,11 @@ import {
Form, Form,
Popover, Popover,
OverlayTrigger, OverlayTrigger,
Spinner,
Table Table
} from "react-bootstrap"; } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { Icon, Modal, ScrapePerformerSuggest } from "src/components/Shared"; import { Icon, Modal, ImageInput, ScrapePerformerSuggest, LoadingIndicator } from "src/components/Shared";
import { ImageUtils, TableUtils } from "src/utils"; import { ImageUtils, TableUtils } from "src/utils";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
@@ -117,7 +116,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
setQueryableScrapers(newQueryableScrapers); setQueryableScrapers(newQueryableScrapers);
}, [Scrapers]); }, [Scrapers]);
if (isLoading) return <Spinner animation="border" variant="light" />; if (isLoading) return <LoadingIndicator />;
function getPerformerInput() { function getPerformerInput() {
const performerInput: Partial< const performerInput: Partial<
@@ -237,7 +236,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
return ( return (
<OverlayTrigger trigger="click" placement="bottom" overlay={popover}> <OverlayTrigger trigger="click" placement="bottom" overlay={popover}>
<Button>Scrape with...</Button> <Button variant="secondary">Scrape with...</Button>
</OverlayTrigger> </OverlayTrigger>
); );
} }
@@ -277,7 +276,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
return undefined; return undefined;
} }
return ( return (
<Button id="scrape-url-button" onClick={() => onScrapePerformerURL()}> <Button className="minimal scrape-url-button" onClick={() => onScrapePerformerURL()}>
<Icon icon="file-upload" /> <Icon icon="file-upload" />
</Button> </Button>
); );
@@ -346,24 +345,6 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
); );
} }
function renderImageInput() {
if (!isEditing) {
return;
}
return (
<tr>
<td>Image</td>
<td>
<Form.Control
type="file"
onChange={onImageChangeHandler}
accept=".jpg,.jpeg"
/>
</td>
</tr>
);
}
function maybeRenderName() { function maybeRenderName() {
if (isEditing) { if (isEditing) {
return TableUtils.renderInputGroup({ return TableUtils.renderInputGroup({
@@ -465,7 +446,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
isEditing: !!isEditing, isEditing: !!isEditing,
onChange: setInstagram onChange: setInstagram
})} })}
{renderImageInput()} <ImageInput isEditing={!!isEditing} onImageChange={onImageChangeHandler} />
</tbody> </tbody>
</Table> </Table>

View File

@@ -0,0 +1,89 @@
.performer.image {
background-position: center !important;
background-repeat: no-repeat !important;
background-size: cover !important;
height: 50vh;
min-height: 400px;
}
#performer-details {
td {
padding: 2px 0;
vertical-align: middle;
}
td:first-child {
min-width: 10rem;
}
.form-control {
width: 100%;
}
#url-field {
line-height: 30px;
}
.scrape-url-button {
color: $text-color;
float: right;
margin-right: .5rem;
}
&-tabpane-scenes {
.grid {
margin-right: 0;
padding: 0;
}
}
}
#performer-page {
flex-direction: row;
margin: 10px auto;
overflow: hidden;
.image-container img {
max-height: 960px;
max-width: 100%;
}
.performer-head {
display: inline-block;
margin-bottom: 2rem;
vertical-align: top;
.name-icons {
margin-left: 10px;
.not-favorite {
color: rgba(191, 204, 214, .5) !important;
}
.favorite {
color: #ff7373 !important;
}
.link {
color: rgb(191, 204, 214);
}
.instagram {
color: pink;
}
}
}
.alias {
font-weight: bold;
}
}
.new-view {
margin-bottom: 2rem;
.photo {
padding: 1rem 1rem 1rem 2rem;
width: 100%;
}
}

View File

@@ -103,7 +103,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
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"> <div className="performer-tag-container" key="performer">
<Link <Link
to={`/performers/${performer.id}`} to={`/performers/${performer.id}`}
className="performer-tag previewable image" className="performer-tag previewable image"
@@ -182,7 +182,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
return ( return (
<Card <Card
className={`zoom-${props.zoomIndex}`} className={`zoom-${props.zoomIndex} scene-card`}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
> >
@@ -197,6 +197,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
event.stopPropagation(); event.stopPropagation();
}} }}
/> />
{maybeRenderSceneStudioOverlay()}
<Link <Link
to={`/scenes/${props.scene.id}`} to={`/scenes/${props.scene.id}`}
className={cx("image", "previewable", { portrait: isPortrait() })} className={cx("image", "previewable", { portrait: isPortrait() })}
@@ -204,7 +205,6 @@ export const SceneCard: React.FC<ISceneCardProps> = (
<div className="video-container"> <div className="video-container">
{maybeRenderRatingBanner()} {maybeRenderRatingBanner()}
{maybeRenderSceneSpecsOverlay()} {maybeRenderSceneSpecsOverlay()}
{maybeRenderSceneStudioOverlay()}
<video <video
loop loop
className={cx("preview", { portrait: isPortrait() })} className={cx("preview", { portrait: isPortrait() })}

View File

@@ -0,0 +1,70 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { Button, Badge, Card } from 'react-bootstrap';
import { TextUtils } from "src/utils";
interface IPrimaryTags {
sceneMarkers: GQL.SceneMarkerDataFragment[];
onClickMarker: (marker:GQL.SceneMarkerDataFragment) => void;
onEdit: (marker:GQL.SceneMarkerDataFragment) =>void;
}
export const PrimaryTags: React.FC<IPrimaryTags> = ({ sceneMarkers, onClickMarker, onEdit }) => {
if (!sceneMarkers?.length) return <div />;
const primaries:Record<string, GQL.Tag> = {};
const primaryTags:Record<string, GQL.SceneMarkerDataFragment[]> = {};
sceneMarkers.forEach(m => {
if(primaryTags[m.primary_tag.id])
primaryTags[m.primary_tag.id].push(m);
else {
primaryTags[m.primary_tag.id] = [m];
primaries[m.primary_tag.id] = m.primary_tag;
}
});
const primaryCards = Object.keys(primaryTags).map(id => {
const markers = primaryTags[id].map(marker => {
const tags = 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>
<Button
variant="link"
style={{ float: "right" }}
onClick={() => onEdit(marker)}
>
Edit
</Button>
</div>
<div>{TextUtils.secondsToTimestamp(marker.seconds)}</div>
<div className="card-section centered">{tags}</div>
</div>
);
});
return (
<Card className="primary-card col-3" key={id}>
<h3>{primaries[id].name}</h3>
<Card.Body className="primary-card-body">
{ markers }
</Card.Body>
</Card>
);
});
return (
<div className="primary-tag row">
{ primaryCards }
</div>
);
};

View File

@@ -1,29 +1,33 @@
import { Card, Spinner, Tab, Tabs } from "react-bootstrap"; import { Tab, Tabs } from "react-bootstrap";
import queryString from "query-string"; import queryString from "query-string";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams, useLocation, useHistory } from "react-router-dom"; import { useParams, useLocation, useHistory } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { GalleryViewer } from "src/components/Galleries/GalleryViewer"; import { GalleryViewer } from "src/components/Galleries/GalleryViewer";
import { ScenePlayer } from "../ScenePlayer/ScenePlayer"; import { LoadingIndicator } from 'src/components/Shared';
import { SceneDetailPanel } from "./SceneDetailPanel"; import { ScenePlayer } from "src/components/scenes/ScenePlayer/ScenePlayer";
import { SceneEditPanel } from "./SceneEditPanel";
import { SceneFileInfoPanel } from "./SceneFileInfoPanel";
import { SceneMarkersPanel } from "./SceneMarkersPanel";
import { ScenePerformerPanel } from "./ScenePerformerPanel"; import { ScenePerformerPanel } from "./ScenePerformerPanel";
import { SceneMarkersPanel } from "./SceneMarkersPanel";
import { SceneFileInfoPanel } from "./SceneFileInfoPanel";
import { SceneEditPanel } from "./SceneEditPanel";
import { SceneDetailPanel } from "./SceneDetailPanel";
export const Scene: React.FC = () => { export const Scene: React.FC = () => {
const { id = "new" } = useParams(); const { id = "new" } = useParams();
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp()); const [timestamp, setTimestamp] = useState<number>(getInitialTimestamp());
const [scene, setScene] = useState<Partial<GQL.SceneDataFragment>>({}); const [scene, setScene] = useState<GQL.SceneDataFragment | undefined>();
const { data, error, loading } = StashService.useFindScene(id); const { data, error, loading } = StashService.useFindScene(id);
const queryParams = queryString.parse(location.search); const queryParams = queryString.parse(location.search);
const autoplay = queryParams?.autoplay === "true"; const autoplay = queryParams?.autoplay === "true";
useEffect(() => setScene(data?.findScene ?? {}), [data]); useEffect(() => {
if(data?.findScene)
setScene(data.findScene)
}, [data]);
function getInitialTimestamp() { function getInitialTimestamp() {
const params = queryString.parse(location.search); const params = queryString.parse(location.search);
@@ -38,61 +42,56 @@ export const Scene: React.FC = () => {
setTimestamp(marker.seconds); setTimestamp(marker.seconds);
} }
if (!data?.findScene || loading || Object.keys(scene).length === 0) { if (loading || !scene || !data?.findScene) {
return <Spinner animation="border" />; return <LoadingIndicator />;
} }
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
return ( return (
<> <>
<ScenePlayer <ScenePlayer
scene={modifiedScene} scene={scene}
timestamp={timestamp} timestamp={timestamp}
autoplay={autoplay} autoplay={autoplay}
/> />
<Card id="details-container"> <div id="details-container">
<Tabs id="scene-tabs" mountOnEnter> <Tabs id="scene-tabs" mountOnEnter>
<Tab eventKey="scene-details-panel" title="Details"> <Tab eventKey="scene-details-panel" title="Details">
<SceneDetailPanel scene={modifiedScene} /> <SceneDetailPanel scene={scene} />
</Tab> </Tab>
<Tab eventKey="scene-markers-panel" title="Markers"> <Tab eventKey="scene-markers-panel" title="Markers">
<SceneMarkersPanel <SceneMarkersPanel
scene={modifiedScene} scene={scene}
onClickMarker={onClickMarker} onClickMarker={onClickMarker}
/> />
</Tab> </Tab>
{modifiedScene.performers.length > 0 ? ( {scene.performers.length > 0 ? (
<Tab eventKey="scene-performer-panel" title="Performers"> <Tab eventKey="scene-performer-panel" title="Performers">
<ScenePerformerPanel scene={modifiedScene} /> <ScenePerformerPanel scene={scene} />
</Tab> </Tab>
) : ( ) : (
"" ""
)} )}
{modifiedScene.gallery ? ( {scene.gallery ? (
<Tab eventKey="scene-gallery-panel" title="Gallery"> <Tab eventKey="scene-gallery-panel" title="Gallery">
<GalleryViewer gallery={modifiedScene.gallery} /> <GalleryViewer gallery={scene.gallery} />
</Tab> </Tab>
) : ( ) : (
"" ""
)} )}
<Tab eventKey="scene-file-info-panel" title="File Info"> <Tab className="file-info-panel" eventKey="scene-file-info-panel" title="File Info">
<SceneFileInfoPanel scene={modifiedScene} /> <SceneFileInfoPanel scene={scene} />
</Tab> </Tab>
<Tab eventKey="scene-edit-panel" title="Edit"> <Tab eventKey="scene-edit-panel" title="Edit">
<SceneEditPanel <SceneEditPanel
scene={modifiedScene} scene={scene}
onUpdate={newScene => setScene(newScene)} onUpdate={newScene => setScene(newScene)}
onDelete={() => history.push("/scenes")} onDelete={() => history.push("/scenes")}
/> />
</Tab> </Tab>
</Tabs> </Tabs>
</Card> </div>
</> </>
); );
}; };

View File

@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import { TagLink } from "src/components/Shared"; import { TagLink } from "src/components/Shared";
import { SceneHelpers } from "../helpers";
interface ISceneDetailProps { interface ISceneDetailProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
@@ -10,9 +10,7 @@ interface ISceneDetailProps {
export const SceneDetailPanel: React.FC<ISceneDetailProps> = props => { export const SceneDetailPanel: React.FC<ISceneDetailProps> = props => {
function renderDetails() { function renderDetails() {
if (!props.scene.details || props.scene.details === "") { if (!props.scene.details || props.scene.details === "")return;
return;
}
return ( return (
<> <>
<h6>Details</h6> <h6>Details</h6>
@@ -22,9 +20,7 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = props => {
} }
function renderTags() { function renderTags() {
if (props.scene.tags.length === 0) { if (props.scene.tags.length === 0) return;
return;
}
const tags = props.scene.tags.map(tag => ( const tags = props.scene.tags.map(tag => (
<TagLink key={tag.id} tag={tag} /> <TagLink key={tag.id} tag={tag} />
)); ));
@@ -37,22 +33,26 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = props => {
} }
return ( return (
<> <div className="row">
{SceneHelpers.maybeRenderStudio(props.scene, 70)} <h1 className="col scene-header">
<h1> {props.scene.title ?? TextUtils.fileNameFromPath(props.scene.path)}
{props.scene.title
? props.scene.title
: TextUtils.fileNameFromPath(props.scene.path)}
</h1> </h1>
{props.scene.date ? <h4>{props.scene.date}</h4> : ""} <div className="col-6 scene-details">
<h4>{props.scene.date ?? ''}</h4>
{props.scene.rating ? <h6>Rating: {props.scene.rating}</h6> : ""} {props.scene.rating ? <h6>Rating: {props.scene.rating}</h6> : ""}
{props.scene.file.height ? ( {props.scene.file.height && (
<h6>Resolution: {TextUtils.resolution(props.scene.file.height)}</h6> <h6>Resolution: {TextUtils.resolution(props.scene.file.height)}</h6>
) : (
""
)} )}
{renderDetails()} {renderDetails()}
{renderTags()} {renderTags()}
</> </div>
<div className="col-4 offset-2">
{ props.scene.studio && (
<Link className="studio-logo" to={`/studios/${props.scene.studio.id}`}>
<img src={props.scene.studio.image_path ?? ''} alt={`${props.scene.studio.name} logo`} />
</Link>
)}
</div>
</div>
); );
}; };

View File

@@ -2,24 +2,26 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { import {
Collapse, Button,
Dropdown, Dropdown,
DropdownButton, DropdownButton,
Form, Form,
Button, Table
Spinner
} from "react-bootstrap"; } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { import {
FilterSelect, PerformerSelect,
TagSelect,
StudioSelect, StudioSelect,
SceneGallerySelect, SceneGallerySelect,
Modal, Modal,
Icon Icon,
LoadingIndicator,
ImageInput
} from "src/components/Shared"; } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { ImageUtils } from "src/utils"; import { ImageUtils, TableUtils } from "src/utils";
interface IProps { interface IProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
@@ -47,11 +49,10 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
const [deleteFile, setDeleteFile] = useState<boolean>(false); const [deleteFile, setDeleteFile] = useState<boolean>(false);
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true); const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
const [isCoverImageOpen, setIsCoverImageOpen] = useState<boolean>(false);
const [coverImagePreview, setCoverImagePreview] = useState<string>(); const [coverImagePreview, setCoverImagePreview] = useState<string>();
// Network state // Network state
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(true);
const [updateScene] = StashService.useSceneUpdate(getSceneInput()); const [updateScene] = StashService.useSceneUpdate(getSceneInput());
const [deleteScene] = StashService.useSceneDestroy(getSceneDeleteInput()); const [deleteScene] = StashService.useSceneDestroy(getSceneDeleteInput());
@@ -65,9 +66,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
}, [Scrapers]); }, [Scrapers]);
function updateSceneEditState(state: Partial<GQL.SceneDataFragment>) { function updateSceneEditState(state: Partial<GQL.SceneDataFragment>) {
const perfIds = state.performers const perfIds = state.performers?.map(performer => performer.id);
? state.performers.map(performer => performer.id)
: undefined;
const tIds = state.tags ? state.tags.map(tag => tag.id) : undefined; const tIds = state.tags ? state.tags.map(tag => tag.id) : undefined;
setTitle(state.title ?? undefined); setTitle(state.title ?? undefined);
@@ -84,6 +83,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
useEffect(() => { useEffect(() => {
updateSceneEditState(props.scene); updateSceneEditState(props.scene);
setCoverImagePreview(props.scene?.paths?.screenshot ?? undefined); setCoverImagePreview(props.scene?.paths?.screenshot ?? undefined);
setIsLoading(false);
}, [props.scene]); }, [props.scene]);
ImageUtils.usePasteImage(onImageLoad); ImageUtils.usePasteImage(onImageLoad);
@@ -136,34 +136,9 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
Toast.error(e); Toast.error(e);
} }
setIsLoading(false); setIsLoading(false);
props.onDelete(); props.onDelete();
} }
function renderMultiSelect(
type: "performers" | "tags",
initialIds: string[] = []
) {
return (
<FilterSelect
type={type}
isMulti
onSelect={items => {
const ids = items.map(i => i.id);
switch (type) {
case "performers":
setPerformerIds(ids);
break;
case "tags":
setTagIds(ids);
break;
}
}}
initialIds={initialIds}
/>
);
}
function renderDeleteAlert() { function renderDeleteAlert() {
return ( return (
<Modal <Modal
@@ -278,11 +253,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
} }
} }
if ( if (!tagIds?.length && scene?.tags?.length) {
(!tagIds || tagIds.length === 0) &&
scene.tags &&
scene.tags.length > 0
) {
const idTags = scene.tags.filter(p => { const idTags = scene.tags.filter(p => {
return p.id !== undefined && p.id !== null; return p.id !== undefined && p.id !== null;
}); });
@@ -323,115 +294,110 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
); );
} }
if(isLoading)
return <LoadingIndicator />;
return ( return (
<> <div className="form-container row">
{renderDeleteAlert()} <div className="col-6">
{isLoading ? <Spinner animation="border" variant="light" /> : undefined} <Table id="scene-details">
<div className="form-container " style={{ width: "50%" }}> <tbody>
<Form.Group controlId="title"> {TableUtils.renderInputGroup({
<Form.Label>Title</Form.Label> title: "Title",
<Form.Control value: title,
onChange={(newValue: any) => setTitle(newValue.target.value)} onChange: setTitle,
value={title} isEditing: true
/> })}
</Form.Group> <tr>
<td>URL</td>
<Form.Group controlId="details"> <td>
<Form.Label>Details</Form.Label>
<Form.Control
as="textarea"
onChange={(newValue: any) => setDetails(newValue.target.value)}
value={details}
/>
</Form.Group>
<Form.Group controlId="url">
<Form.Label>URL</Form.Label>
<Form.Control <Form.Control
onChange={(newValue: any) => setUrl(newValue.target.value)} onChange={(newValue: any) => setUrl(newValue.target.value)}
value={url} value={url}
placeholder="URL"
/> />
{maybeRenderScrapeButton()} {maybeRenderScrapeButton()}
</Form.Group> </td>
</tr>
<Form.Group controlId="date"> {TableUtils.renderInputGroup({
<Form.Label>Date</Form.Label> title: "Date (YYYY-MM-DD)",
<Form.Control value: date,
onChange={(newValue: any) => setDate(newValue.target.value)} isEditing: true,
value={date} onChange: setDate
/> })}
<div>YYYY-MM-DD</div> {TableUtils.renderHtmlSelect({
</Form.Group> title: "Rating",
value: rating,
<Form.Group controlId="rating"> isEditing: true,
<Form.Label>Rating</Form.Label> onChange: (value: string) => setRating(Number.parseInt(value, 10)),
<Form.Control selectOptions: ['', 1, 2, 3, 4, 5]
as="select" })}
onChange={(event: any) => <tr>
setRating(parseInt(event.target.value, 10)) <td>Gallery</td>
} <td>
>
{["", 1, 2, 3, 4, 5].map(opt => (
<option selected={opt === rating} value={opt}>
{opt}
</option>
))}
</Form.Control>
</Form.Group>
<Form.Group controlId="gallery">
<Form.Label>Gallery</Form.Label>
<SceneGallerySelect <SceneGallerySelect
sceneId={props.scene.id} sceneId={props.scene.id}
initialId={galleryId} initialId={galleryId}
onSelect={item => setGalleryId(item ? item.id : undefined)} onSelect={item => setGalleryId(item ? item.id : undefined)}
/> />
</Form.Group> </td>
</tr>
<Form.Group controlId="studio"> <tr>
<Form.Label>Studio</Form.Label> <td>Studio</td>
<td>
<StudioSelect <StudioSelect
onSelect={items => items.length && setStudioId(items[0]?.id)} onSelect={items => items.length && setStudioId(items[0]?.id)}
initialIds={studioId ? [studioId] : []} ids={studioId ? [studioId] : []}
/>
</td>
</tr>
<tr>
<td>Performers</td>
<td>
<PerformerSelect
isMulti
onSelect={items => setPerformerIds(items.map(item => item.id))}
ids={performerIds}
/>
</td>
</tr>
<tr>
<td>Tags</td>
<td>
<TagSelect
isMulti
onSelect={items => setTagIds(items.map(item => item.id))}
ids={tagIds}
/>
</td>
</tr>
</tbody>
</Table>
</div>
<div className="col-5 offset-1">
<Form.Group controlId="details">
<Form.Label>Details</Form.Label>
<Form.Control
as="textarea"
className="scene-description"
onChange={(newValue: any) => setDetails(newValue.target.value)}
value={details}
/> />
</Form.Group> </Form.Group>
<Form.Group controlId="performers">
<Form.Label>Performers</Form.Label>
{renderMultiSelect("performers", performerIds)}
</Form.Group>
<Form.Group controlId="tags">
<Form.Label>Tags</Form.Label>
{renderMultiSelect("tags", tagIds)}
</Form.Group>
<div>
<Button
variant="link"
onClick={() => setIsCoverImageOpen(!isCoverImageOpen)}
>
<Icon icon={isCoverImageOpen ? "chevron-down" : "chevron-right"} />
<span>Cover Image</span>
</Button>
<Collapse in={isCoverImageOpen}>
<div> <div>
<Form.Group className="test" controlId="cover">
<Form.Label>Cover Image</Form.Label>
<img <img
className="scene-cover" className="scene-cover"
src={coverImagePreview} src={coverImagePreview}
alt="Scene cover" alt="Scene cover"
/> />
<Form.Group className="test" controlId="cover"> <ImageInput isEditing onImageChange={onCoverImageChange} />
<Form.Control
type="file"
onChange={onCoverImageChange}
accept=".jpg,.jpeg,.png"
/>
</Form.Group> </Form.Group>
</div> </div>
</Collapse>
</div>
</div> </div>
<div className="col edit-buttons">
<Button className="edit-button" variant="primary" onClick={onSave}> <Button className="edit-button" variant="primary" onClick={onSave}>
Save Save
</Button> </Button>
@@ -442,7 +408,9 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
> >
Delete Delete
</Button> </Button>
</div>
{renderScraperMenu()} {renderScraperMenu()}
</> {renderDeleteAlert()}
</div>
); );
}; };

View File

@@ -0,0 +1,177 @@
import React from "react";
import {
Button,
Form
} from "react-bootstrap";
import { Field, FieldProps, Form as FormikForm, Formik } from "formik";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import {
DurationInput,
TagSelect,
MarkerTitleSuggest
} from "src/components/Shared";
import { useToast } from "src/hooks";
interface IFormFields {
title: string;
seconds: string;
primaryTagId: string;
tagIds: string[];
}
interface ISceneMarkerForm {
sceneID: string;
editingMarker?: GQL.SceneMarkerDataFragment;
playerPosition?: number;
onClose: () => void;
}
export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({ sceneID, editingMarker, playerPosition, onClose }) => {
const [sceneMarkerCreate] = StashService.useSceneMarkerCreate();
const [sceneMarkerUpdate] = StashService.useSceneMarkerUpdate();
const [sceneMarkerDestroy] = StashService.useSceneMarkerDestroy();
const Toast = useToast();
const onSubmit = (values: IFormFields) => {
const variables:
| GQL.SceneMarkerUpdateInput
| GQL.SceneMarkerCreateInput = {
title: values.title,
seconds: parseFloat(values.seconds),
scene_id: sceneID,
primary_tag_id: values.primaryTagId,
tag_ids: values.tagIds
};
if (!editingMarker) {
sceneMarkerCreate({ variables })
.then(onClose)
.catch(err => Toast.error(err));
} else {
const updateVariables = variables as GQL.SceneMarkerUpdateInput;
updateVariables.id = editingMarker!.id;
sceneMarkerUpdate({ variables: updateVariables })
.then(onClose)
.catch(err => Toast.error(err));
}
}
const onDelete = () => {
if (!editingMarker) return;
sceneMarkerDestroy({ variables: { id: editingMarker.id } })
.then(onClose)
.catch(err => Toast.error(err));
}
const renderTitleField = (fieldProps: FieldProps<string>) => (
<div className="col-6">
<MarkerTitleSuggest
initialMarkerTitle={fieldProps.field.value}
onChange={(query: string) =>
fieldProps.form.setFieldValue("title", query)
}
/>
</div>
);
const renderSecondsField = (fieldProps: FieldProps<string>) => (
<DurationInput
onValueChange={s => fieldProps.form.setFieldValue("seconds", s)}
onReset={() =>
fieldProps.form.setFieldValue(
"seconds",
Math.round(playerPosition ?? 0)
)
}
numericValue={Number.parseInt(fieldProps.field.value ?? '0', 10)}
/>
);
const renderPrimaryTagField = (fieldProps: FieldProps<string>) => (
<TagSelect
onSelect={tags =>
fieldProps.form.setFieldValue("primaryTagId", tags[0]?.id)
}
ids={fieldProps.field.value ? [fieldProps.field.value] : []}
noSelectionString="Select or create tag..."
/>
);
const renderTagsField = (fieldProps: FieldProps<string[]>) => (
<TagSelect
isMulti
onSelect={tags =>
fieldProps.form.setFieldValue(
"tagIds",
tags.map(tag => tag.id)
)
}
ids={fieldProps.field.value}
noSelectionString="Select or create tags..."
/>
);
const values:IFormFields = {
title: editingMarker?.title ?? '',
seconds: (editingMarker?.seconds ?? Math.round(playerPosition ?? 0)).toString(),
primaryTagId: editingMarker?.primary_tag.id ?? '',
tagIds: editingMarker?.tags.map(tag => tag.id) ?? []
};
return (
<Formik
initialValues={values}
onSubmit={onSubmit}
>
<FormikForm>
<div>
<Form.Group className="row">
<Form.Label htmlFor="title" className="col-2">
Scene Marker Title
</Form.Label>
<Field name="title" className="col-6">
{renderTitleField}
</Field>
<Form.Label htmlFor="seconds" className="col-1">Time</Form.Label>
<Field name="seconds" className="col-2">
{renderSecondsField}
</Field>
</Form.Group>
<Form.Group className="row">
<Form.Label htmlFor="primaryTagId" className="col-2">
Primary Tag
</Form.Label>
<div className="col-4">
<Field name="primaryTagId">
{renderPrimaryTagField}
</Field>
</div>
<Form.Label htmlFor="tagIds" className="col-2">Tags</Form.Label>
<div className="col-4">
<Field name="tagIds">
{renderTagsField}
</Field>
</div>
</Form.Group>
</div>
<div className="buttons-container">
<Button variant="primary" type="submit">
Submit
</Button>
<Button type="button" onClick={onClose}>
Cancel
</Button>
{ editingMarker && (
<Button
variant="danger"
style={{ float: "right", marginRight: "10px" }}
onClick={() => onDelete()}
>
Delete
</Button>
)}
</div>
</FormikForm>
</Formik>
);
}

View File

@@ -1,312 +1,62 @@
import React, { CSSProperties, useState } from "react"; import React, { useState } from "react";
import { import {
Badge,
Button, Button,
Card,
Collapse,
Form as BootstrapForm
} from "react-bootstrap"; } from "react-bootstrap";
import { Field, FieldProps, Form, Formik } from "formik";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { TextUtils } from "src/utils";
import { useToast } from "src/hooks";
import {
DurationInput,
TagSelect,
MarkerTitleSuggest
} from "src/components/Shared";
import { WallPanel } from "src/components/Wall/WallPanel"; import { WallPanel } from "src/components/Wall/WallPanel";
import { SceneHelpers } from "../helpers"; import { JWUtils } from "src/utils";
import { PrimaryTags } from './PrimaryTags';
import { SceneMarkerForm } from './SceneMarkerForm';
interface ISceneMarkersPanelProps { interface ISceneMarkersPanelProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void; onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;
} }
interface IFormFields {
title: string;
seconds: string;
primaryTagId: string;
tagIds: string[];
}
export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = ( export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
props: ISceneMarkersPanelProps props: ISceneMarkersPanelProps
) => { ) => {
const Toast = useToast();
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false); const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
const [ const [
editingMarker, editingMarker,
setEditingMarker setEditingMarker
] = useState<GQL.SceneMarkerDataFragment | null>(null); ] = useState<GQL.SceneMarkerDataFragment>();
const [sceneMarkerCreate] = StashService.useSceneMarkerCreate(); const jwplayer = JWUtils.getPlayer();
const [sceneMarkerUpdate] = StashService.useSceneMarkerUpdate();
const [sceneMarkerDestroy] = StashService.useSceneMarkerDestroy();
const jwplayer = SceneHelpers.getPlayer();
function onOpenEditor(marker?: GQL.SceneMarkerDataFragment) { function onOpenEditor(marker?: GQL.SceneMarkerDataFragment) {
setIsEditorOpen(true); setIsEditorOpen(true);
setEditingMarker(marker ?? null); setEditingMarker(marker ?? undefined);
} }
function onClickMarker(marker: GQL.SceneMarkerDataFragment) { function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
props.onClickMarker(marker); props.onClickMarker(marker);
} }
function renderTags() { const closeEditor = () => {
function renderMarkers(primaryTag: GQL.SceneMarkerTag) { setEditingMarker(undefined);
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}</div>
</div>
);
});
return markers;
}
const style: CSSProperties = {
height: "300px",
overflowY: "auto",
overflowX: "hidden",
display: "inline-block",
margin: "5px",
width: "300px",
flex: "0 0 auto"
};
const tags = (props.scene as any).scene_marker_tags.map(
(primaryTag: GQL.SceneMarkerTag) => {
return (
<div key={primaryTag.tag.id} style={{ padding: "1px" }}>
<Card style={style}>
<div className="content" style={{ whiteSpace: "normal" }}>
<h3>{primaryTag.tag.name}</h3>
{renderMarkers(primaryTag)}
</div>
</Card>
</div>
);
}
);
return tags;
}
function renderForm() {
function onSubmit(values: IFormFields) {
const isEditing = !!editingMarker;
const variables:
| GQL.SceneMarkerUpdateInput
| GQL.SceneMarkerCreateInput = {
title: values.title,
seconds: parseFloat(values.seconds),
scene_id: props.scene.id,
primary_tag_id: values.primaryTagId,
tag_ids: values.tagIds
};
if (!isEditing) {
sceneMarkerCreate({ variables })
.then(() => {
setIsEditorOpen(false); setIsEditorOpen(false);
setEditingMarker(null);
})
.catch(err => Toast.error(err));
} else {
const updateVariables = variables as GQL.SceneMarkerUpdateInput;
updateVariables.id = editingMarker!.id;
sceneMarkerUpdate({ variables: updateVariables })
.then(() => {
setIsEditorOpen(false);
setEditingMarker(null);
})
.catch(err => Toast.error(err));
}
}
function onDelete() {
if (!editingMarker) {
return;
}
sceneMarkerDestroy({ variables: { id: editingMarker.id } })
// eslint-disable-next-line no-console
.catch(err => console.error(err));
setIsEditorOpen(false);
setEditingMarker(null);
}
function renderTitleField(fieldProps: FieldProps<IFormFields>) {
return (
<MarkerTitleSuggest
initialMarkerTitle={editingMarker?.title}
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())
)
}
numericValue={Number.parseInt(fieldProps.field.value.seconds, 10)}
/>
);
}
function renderPrimaryTagField(fieldProps: FieldProps<IFormFields>) {
return (
<TagSelect
onSelect={tags =>
fieldProps.form.setFieldValue("primaryTagId", tags[0]?.id)
}
initialIds={editingMarker ? [editingMarker.primary_tag.id] : []}
/>
);
}
function renderTagsField(fieldProps: FieldProps<IFormFields>) {
return (
<TagSelect
isMulti
onSelect={tags =>
fieldProps.form.setFieldValue(
"tagIds",
tags.map(tag => tag.id)
)
}
initialIds={editingMarker ? fieldProps.form.values.tagIds : []}
/>
);
}
function renderFormFields() {
let deleteButton: JSX.Element | undefined;
if (editingMarker) {
deleteButton = (
<Button
variant="danger"
style={{ float: "right", marginRight: "10px" }}
onClick={() => onDelete()}
>
Delete
</Button>
);
}
return (
<Form style={{ marginTop: "10px" }}>
<div className="columns is-multiline is-gapless">
<BootstrapForm.Group>
<BootstrapForm.Label htmlFor="title">
Scene Marker Title
</BootstrapForm.Label>
<Field name="title" render={renderTitleField} />
</BootstrapForm.Group>
<BootstrapForm.Group>
<BootstrapForm.Label htmlFor="seconds">Time</BootstrapForm.Label>
<Field name="seconds" render={renderSecondsField} />
</BootstrapForm.Group>
<BootstrapForm.Group>
<BootstrapForm.Label htmlFor="primaryTagId">
Primary Tag
</BootstrapForm.Label>
<Field name="primaryTagId" render={renderPrimaryTagField} />
</BootstrapForm.Group>
<BootstrapForm.Group>
<BootstrapForm.Label htmlFor="tagIds">Tags</BootstrapForm.Label>
<Field name="tagIds" render={renderTagsField} />
</BootstrapForm.Group>
</div>
<div className="buttons-container">
<Button variant="primary" type="submit">
Submit
</Button>
<Button type="button" onClick={() => setIsEditorOpen(false)}>
Cancel
</Button>
{deleteButton}
</div>
</Form>
);
}
let initialValues: any;
if (editingMarker) {
initialValues = {
title: editingMarker.title,
seconds: editingMarker.seconds,
primaryTagId: editingMarker.primary_tag.id,
tagIds: editingMarker.tags.map(tag => tag.id)
}; };
} else {
initialValues = {
title: "",
seconds: Math.round(jwplayer.getPosition()),
primaryTagId: "",
tagIds: []
};
}
return (
<Collapse in={isEditorOpen}>
<div className="">
<Formik
initialValues={initialValues}
onSubmit={onSubmit}
render={renderFormFields}
/>
</div>
</Collapse>
);
}
function render() { if(isEditorOpen)
const newMarkerForm = ( return (
<div style={{ margin: "5px" }}> <SceneMarkerForm
<Button onClick={() => onOpenEditor()}>Create</Button> sceneID={props.scene.id}
{renderForm()} editingMarker={editingMarker}
</div> playerPosition={jwplayer.getPlayer?.().playerPosition}
onClose={closeEditor}
/>
); );
if (props.scene.scene_markers.length === 0) {
return newMarkerForm;
}
const containerStyle: CSSProperties = {
overflowY: "hidden",
overflowX: "scroll",
whiteSpace: "nowrap",
display: "flex",
flexWrap: "nowrap",
marginBottom: "20px"
};
return ( return (
<> <>
{newMarkerForm} <Button onClick={() => onOpenEditor()}>Create Marker</Button>
<div style={containerStyle}>{renderTags()}</div> <PrimaryTags
sceneMarkers={props.scene.scene_markers ?? []}
onClickMarker={onClickMarker}
onEdit={onOpenEditor}
/>
<div className="row">
<WallPanel <WallPanel
sceneMarkers={props.scene.scene_markers} sceneMarkers={props.scene.scene_markers}
clickHandler={marker => { clickHandler={marker => {
@@ -314,9 +64,7 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
onClickMarker(marker as any); onClickMarker(marker as any);
}} }}
/> />
</div>
</> </>
); );
}
return render();
}; };

View File

@@ -9,13 +9,12 @@ import {
Dropdown, Dropdown,
DropdownButton, DropdownButton,
Form, Form,
Table, Table
Spinner
} from "react-bootstrap"; } from "react-bootstrap";
import _ from "lodash"; import _ from "lodash";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { FilterSelect, Icon, StudioSelect } from "src/components/Shared"; import { FilterSelect, Icon, StudioSelect, LoadingIndicator } from "src/components/Shared";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { Pagination } from "../list/Pagination"; import { Pagination } from "../list/Pagination";
@@ -1065,7 +1064,7 @@ export const SceneFilenameParser: React.FC = () => {
<h4>Scene Filename Parser</h4> <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} {isLoading && <LoadingIndicator />}
{renderTable()} {renderTable()}
</Card> </Card>
); );

View File

@@ -39,7 +39,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
function renderTags(tags: GQL.Tag[]) { function renderTags(tags: GQL.Tag[]) {
return tags.map(tag => ( return tags.map(tag => (
<Link to={NavUtils.makeTagScenesUrl(tag)}> <Link key={tag.id} to={NavUtils.makeTagScenesUrl(tag)}>
<h6>{tag.name}</h6> <h6>{tag.name}</h6>
</Link> </Link>
)); ));
@@ -47,7 +47,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
function renderPerformers(performers: Partial<GQL.Performer>[]) { function renderPerformers(performers: Partial<GQL.Performer>[]) {
return performers.map(performer => ( return performers.map(performer => (
<Link to={NavUtils.makePerformerScenesUrl(performer)}> <Link key={performer.id} to={NavUtils.makePerformerScenesUrl(performer)}>
<h6>{performer.name}</h6> <h6>{performer.name}</h6>
</Link> </Link>
)); ));
@@ -65,7 +65,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
function renderSceneRow(scene: GQL.SlimSceneDataFragment) { function renderSceneRow(scene: GQL.SlimSceneDataFragment) {
return ( return (
<tr> <tr key={scene.id}>
<td>{renderSceneImage(scene)}</td> <td>{renderSceneImage(scene)}</td>
<td style={{ textAlign: "left" }}> <td style={{ textAlign: "left" }}>
<Link to={`/scenes/${scene.id}`}> <Link to={`/scenes/${scene.id}`}>

View File

@@ -3,7 +3,7 @@ import ReactJWPlayer from "react-jw-player";
import { HotKeys } from "react-hotkeys"; import { HotKeys } from "react-hotkeys";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { SceneHelpers } from "../helpers"; import { JWUtils } from 'src/utils';
import { ScenePlayerScrubber } from "./ScenePlayerScrubber"; import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
interface IScenePlayerProps { interface IScenePlayerProps {
@@ -84,7 +84,7 @@ export class ScenePlayerImpl extends React.Component<
} }
private onReady() { private onReady() {
this.player = SceneHelpers.getPlayer(); this.player = JWUtils.getPlayer();
if (this.props.timestamp > 0) { if (this.props.timestamp > 0) {
this.player.seek(this.props.timestamp); this.player.seek(this.props.timestamp);
} }
@@ -193,8 +193,8 @@ export class ScenePlayerImpl extends React.Component<
const config = this.makeJWPlayerConfig(this.props.scene); const config = this.makeJWPlayerConfig(this.props.scene);
return ( return (
<ReactJWPlayer <ReactJWPlayer
playerId={SceneHelpers.getJWPlayerId()} playerId={JWUtils.playerID}
playerScript="/jwplayer/jwplayer.js" playerScript="http://192.168.1.65:9999/jwplayer/jwplayer.js"
customProps={config} customProps={config}
onReady={this.onReady} onReady={this.onReady}
onSeeked={this.onSeeked} onSeeked={this.onSeeked}

View File

@@ -1,7 +1,7 @@
.scrubber-wrapper { .scrubber-wrapper {
position: relative;
overflow: hidden;
margin: 5px 0; margin: 5px 0;
overflow: hidden;
position: relative;
} }
#scrubber-back { #scrubber-back {
@@ -13,116 +13,119 @@
} }
.scrubber-button { .scrubber-button {
width: 1.5%; border: 1px solid #555;
color: #fff;
cursor: pointer;
font-size: 20px;
font-weight: 800;
height: 100%; height: 100%;
line-height: 120px; line-height: 120px;
padding: 0; padding: 0;
text-align: center; text-align: center;
border: 1px solid #555; width: 1.5%;
font-weight: 800;
font-size: 20px;
color: #FFF;
cursor: pointer;
} }
.scrubber-content { .scrubber-content {
-webkit-user-select: none; cursor: grab;
-webkit-overflow-scrolling: touch;
cursor: -webkit-grab;
height: 120px;
width: 96%;
margin: 0 0.5%;
display: inline-block; display: inline-block;
position: relative; height: 120px;
margin: 0 .5%;
overflow: hidden; overflow: hidden;
-webkit-overflow-scrolling: touch;
position: relative;
-webkit-user-select: none;
width: 96%;
} }
.scrubber-content.dragging { .scrubber-content.dragging {
cursor: -webkit-grabbing; cursor: grabbing;
} }
.scrubber-tags-background { .scrubber-tags-background {
background-color: #555; background-color: #555;
position: absolute;
left: 0;
right: 0;
height: 20px; height: 20px;
left: 0;
position: absolute;
right: 0;
} }
#scrubber-position-indicator { #scrubber-position-indicator {
background-color: #CCC; background-color: #ccc;
width: 100%;
left: -100%;
height: 20px; height: 20px;
z-index: 0; left: -100%;
position: absolute; position: absolute;
width: 100%;
z-index: 0;
} }
#scrubber-current-position { #scrubber-current-position {
background-color: #FFF; background-color: #fff;
width: 2px;
height: 30px; height: 30px;
left: 50%; left: 50%;
z-index: 1;
position: absolute; position: absolute;
width: 2px;
z-index: 1;
} }
.scrubber-viewport { .scrubber-viewport {
position: static;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
position: static;
} }
.scrubber-slider { .scrubber-slider {
position: absolute;
width: 100%;
height: 100%; height: 100%;
left: 0; left: 0;
position: absolute;
transition: 333ms ease-out; transition: 333ms ease-out;
width: 100%;
} }
.scrubber-tags { .scrubber-tags {
height: 20px; height: 20px;
position: relative;
margin-bottom: 10px; margin-bottom: 10px;
position: relative;
} }
.scrubber-tag { .scrubber-tag {
position: absolute;
background-color: #000; background-color: #000;
font-size: 10px;
white-space: nowrap;
padding: 0 10px;
cursor: pointer; cursor: pointer;
} font-size: 10px;
.scrubber-tag:hover { padding: 0 10px;
z-index: 1;
background-color: #444;
}
.scrubber-tag:after {
content: "";
position: absolute; position: absolute;
bottom: -5px; white-space: nowrap;
left: 50%; }
margin-left: -5px;
border-top: solid 5px #000; .scrubber-tag:hover {
background-color: #444;
z-index: 1;
}
.scrubber-tag::after {
border-left: solid 5px transparent; border-left: solid 5px transparent;
border-right: solid 5px transparent; border-right: solid 5px transparent;
border-top: solid 5px #000;
bottom: -5px;
content: "";
left: 50%;
margin-left: -5px;
position: absolute;
} }
.scrubber-item { .scrubber-item {
position: absolute;
display: flex;
margin-right: 10px;
cursor: pointer;
color: white; color: white;
text-shadow: 1px 1px black; cursor: pointer;
text-align: center; display: flex;
font-size: 10px; font-size: 10px;
margin-right: 10px;
position: absolute;
text-align: center;
text-shadow: 1px 1px black;
} }
.scrubber-item span { .scrubber-item span {
display: inline-block;
align-self: flex-end; align-self: flex-end;
display: inline-block;
width: 100%; width: 100%;
} }

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, ButtonGroup, Form, Spinner } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import _ from "lodash"; import _ from "lodash";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { FilterSelect, StudioSelect } from "src/components/Shared"; import { FilterSelect, StudioSelect, LoadingIndicator } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
interface IListOperationProps { interface IListOperationProps {
@@ -245,7 +245,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
const itemIDs = items.map(i => i.id); const itemIDs = items.map(i => i.id);
switch (type) { switch (type) {
case "performers": case "performers":
setPerformerIds(itemIDS); setPerformerIds(itemIDs);
break; break;
case "tags": case "tags":
setTagIds(itemIDs); setTagIds(itemIDs);
@@ -258,7 +258,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
} }
if(isLoading) if(isLoading)
return <Spinner animation="border" variant="light" />; return <LoadingIndicator />;
function render() { function render() {
return ( return (

View File

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

View File

@@ -4,8 +4,8 @@
margin-bottom: 10px; margin-bottom: 10px;
button { button {
padding-top: 3px;
padding-bottom: 3px; padding-bottom: 3px;
padding-top: 3px;
} }
svg { svg {
@@ -13,9 +13,29 @@
} }
} }
.grid {
.scene-card {
padding-bottom: 0;
}
}
.performer-tag-container {
display: inline-block;
margin: 5px;
}
.performer-tag.image {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
height: 150px;
margin: 0 auto;
width: 100%;
}
.operation-container { .operation-container {
.operation-item { .operation-item {
min-width: 200px; min-width: 240px;
} }
.rating-operation { .rating-operation {
@@ -26,3 +46,62 @@
margin-top: 2rem; margin-top: 2rem;
} }
} }
.marker-container {
display: "flex";
flex-wrap: "nowrap";
margin-bottom: "20px";
overflow-x: "scroll";
overflow-y: "hidden";
white-space: "nowrap";
}
.studio-logo {
img {
margin-top: 1rem;
max-width: 100%;
}
}
.scene-header {
flex-basis: auto;
}
#details-container {
.tab-content {
min-height: 15rem;
}
.scene-description {
width: 100%;
}
}
.file-info-panel {
td {
padding: .4rem;
}
}
#scene-details {
input {
width: 100%;
}
}
#details {
min-height: 150px;
}
.edit-buttons {
padding-left: 2rem;
}
.primary-card {
margin: 1rem 0;
&-body {
max-height: 15rem;
overflow-y: auto;
}
}

View File

@@ -1,7 +1,6 @@
import _ from "lodash"; import _ from "lodash";
import queryString from "query-string"; import queryString from "query-string";
import React, { useState } from "react"; import React, { useState } from "react";
import { Spinner } from "react-bootstrap";
import { ApolloError } from "apollo-client"; import { ApolloError } from "apollo-client";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { import {
@@ -16,12 +15,13 @@ import {
FindStudiosQueryResult, FindStudiosQueryResult,
FindPerformersQueryResult FindPerformersQueryResult
} from "src/core/generated-graphql"; } from "src/core/generated-graphql";
import { ListFilter } from "../components/list/ListFilter"; import { LoadingIndicator } from 'src/components/Shared';
import { Pagination } from "../components/list/Pagination"; import { ListFilter } from "src/components/list/ListFilter";
import { StashService } from "../core/StashService"; import { Pagination } from "src/components/list/Pagination";
import { Criterion } from "../models/list-filter/criteria/criterion"; import { StashService } from "src/core/StashService";
import { ListFilterModel } from "../models/list-filter/filter"; import { Criterion } from "src/models/list-filter/criteria/criterion";
import { DisplayMode, FilterMode } from "../models/list-filter/types"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode, FilterMode } from "src/models/list-filter/types";
interface IListHookData { interface IListHookData {
filter: ListFilterModel; filter: ListFilterModel;
@@ -294,12 +294,8 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
{options.renderSelectedOptions && selectedIds.size > 0 {options.renderSelectedOptions && selectedIds.size > 0
? options.renderSelectedOptions(result, selectedIds) ? options.renderSelectedOptions(result, selectedIds)
: undefined} : undefined}
{result.loading ? ( {result.loading && <LoadingIndicator />}
<Spinner animation="border" variant="light" /> {result.error && <h1>{result.error.message}</h1>}
) : (
undefined
)}
{result.error ? <h1>{result.error.message}</h1> : undefined}
{options.renderContent(result, filter, selectedIds, zoomIndex)} {options.renderContent(result, filter, selectedIds, zoomIndex)}
<Pagination <Pagination
itemsPerPage={filter.itemsPerPage} itemsPerPage={filter.itemsPerPage}

View File

@@ -1,28 +1,30 @@
@import "styles/theme"; @import "styles/theme";
@import "styles/form/grid";
@import "styles/shared/details";
@import "styles/range"; @import "styles/range";
@import "styles/scrollbars"; @import "styles/scrollbars";
@import "styles/variables"; @import "styles/variables";
@import "./components/**/*.scss"; @import "./components/**/*.scss";
body { body {
margin: 0; font-family:
padding: $pt-navbar-height 0 0 0; -apple-system,
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', BlinkMacSystemFont,
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', "Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Fira Sans",
"Droid Sans",
"Helvetica Neue",
sans-serif; sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
height: 100vh; margin: 0;
padding: $pt-navbar-height 0 0 0;
} }
code { code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
monospace;
} }
.grid { .grid {
@@ -30,24 +32,24 @@ code {
flex-flow: row wrap; flex-flow: row wrap;
justify-content: center; justify-content: center;
margin: $pt-grid-size $pt-grid-size 0 0; margin: $pt-grid-size $pt-grid-size 0 0;
padding: 0 100px; padding: 0;
&.wall { &.wall {
padding: 0;
margin: 0; margin: 0;
padding: 0;
} }
& .performer-list-thumbnail { & .performer-list-thumbnail {
min-width: 50px;
height: 100px; height: 100px;
min-width: 50px;
} }
& .scene-list-thumbnail { & .scene-list-thumbnail {
width: 150px;
min-height: 50px; min-height: 50px;
width: 150px;
} }
& table td { table td {
text-align: center; text-align: center;
vertical-align: middle; vertical-align: middle;
} }
@@ -55,55 +57,60 @@ code {
.card { .card {
margin: 0 0 10px 10px; margin: 0 0 10px 10px;
overflow: hidden; overflow: hidden;
width: 20rem;
&.zoom-0 { &.zoom-0 {
width: 15rem; width: 15rem;
& .previewable { .previewable {
max-height: 11.25rem; max-height: 11.25rem;
} }
& .previewable.portrait {
.previewable.portrait {
max-height: 11.25rem; max-height: 11.25rem;
} }
} }
&.zoom-1 { &.zoom-1 {
width: 20rem; width: 20rem;
& .previewable { .previewable {
max-height: 15rem; max-height: 15rem;
} }
& .previewable.portrait {
.previewable.portrait {
height: 15rem; height: 15rem;
} }
} }
&.zoom-2 { &.zoom-2 {
width: 30rem; width: 30rem;
& .previewable { .previewable {
max-height: 22.5rem; max-height: 22.5rem;
} }
& .previewable.portrait {
.previewable.portrait {
height: 22.5rem; height: 22.5rem;
} }
} }
&.zoom-3 { &.zoom-3 {
width: 40rem; width: 40rem;
& .previewable { .previewable {
max-height: 30rem; max-height: 30rem;
} }
& .previewable.portrait {
.previewable.portrait {
height: 30rem; height: 30rem;
} }
} }
.card-select { .card-select {
position: absolute;
padding-left: 15px;
margin-top: -12px; margin-top: -12px;
z-index: 1; opacity: .5;
opacity: 0.5; padding-left: 15px;
position: absolute;
width: 1.2rem; width: 1.2rem;
z-index: 1;
} }
} }
} }
@@ -111,11 +118,11 @@ code {
.previewable { .previewable {
display: block; display: block;
line-height: 0; line-height: 0;
overflow: hidden;
width: calc(100% + 40px);
margin: -20px 0 0 -20px; margin: -20px 0 0 -20px;
position: relative;
max-height: 240px; max-height: 240px;
overflow: hidden;
position: relative;
width: calc(100% + 40px);
} }
.previewable.portrait { .previewable.portrait {
@@ -123,18 +130,15 @@ code {
} }
.video-container { .video-container {
width: 100%;
height: 100%; height: 100%;
width: 100%;
} }
video.preview { video.preview {
// height: 225px; // slows down the page
width: 100%;
// width: calc(100% + 40px);
// margin: -20px 0 0 -20px;
object-fit: cover;
margin: 0 auto;
display: block; display: block;
margin: 0 auto;
object-fit: cover;
width: 100%;
} }
video.preview.portrait { video.preview.portrait {
@@ -142,7 +146,8 @@ video.preview.portrait {
width: auto; width: auto;
} }
.filter-item, .operation-item { .filter-item,
.operation-item {
margin: 0 10px; margin: 0 10px;
} }
@@ -151,19 +156,48 @@ video.preview.portrait {
} }
.tag-item { .tag-item {
background-color: #bfccd6;
color: #182026;
font-size: 12px;
font-weight: 400;
line-height: 16px;
margin: 5px; margin: 5px;
padding: 2px 6px;
&:hover {
cursor: pointer;
}
.btn {
background: none;
border: none;
bottom: 2px;
color: #182026;
font-size: 12px;
line-height: 1rem;
margin-left: .5rem;
opacity: .5;
padding: 0;
position: relative;
&:active,
&:hover {
opacity: 1;
}
}
a { a {
color: unset; color: unset;
&:hover { &:hover {
text-decoration: none;
color: unset; color: unset;
text-decoration: none;
} }
} }
} }
.filter-container, .operation-container { .filter-container,
.operation-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin: 10px auto; margin: 10px auto;
@@ -171,93 +205,111 @@ video.preview.portrait {
.card-section { .card-section {
padding: 10px 0 0 0; padding: 10px 0 0 0;
&.centered { &.centered {
display: flex; display: flex;
justify-content: center;
flex-flow: wrap; flex-flow: wrap;
justify-content: center;
} }
} }
.rating-5 { background: #FF2F39; } .rating-5 {
.rating-4 { background: $red1; } background: #FF2F39;
.rating-3 { background: $orange1; } }
.rating-2 { background: $sepia1; }
.rating-1 { background: $dark-gray5; } .rating-4 {
background: $red1;
}
.rating-3 {
background: $orange1;
}
.rating-2 {
background: $sepia1;
}
.rating-1 {
background: $dark-gray5;
}
.rating-banner { .rating-banner {
transform: rotate(-36deg);
display: block;
padding: 6px 45px;
font-weight: 400;
top: 14px;
position: absolute;
left: -46px;
color: #fff; color: #fff;
display: block;
font-size: .86rem;
font-weight: 400;
left: -46px;
letter-spacing: 1px; letter-spacing: 1px;
text-size-adjust: none; line-height: 1.6rem;
font-size: .85714em; padding: 6px 45px;
line-height: 1.6em; position: absolute;
text-align: center; text-align: center;
text-size-adjust: none;
top: 14px;
transform: rotate(-36deg);
} }
.scene-specs-overlay { .scene-specs-overlay {
display: block; bottom: 1rem;
position: absolute;
bottom: 1em;
right: .7em;
font-weight: 400;
color: #f5f8fa; color: #f5f8fa;
letter-spacing: -.03em; display: block;
font-weight: 400;
letter-spacing: -.03rem;
position: absolute;
right: .7rem;
text-shadow: 0 0 3px #000; text-shadow: 0 0 3px #000;
} }
.scene-studio-overlay { .scene-studio-overlay {
display: block; display: block;
position: absolute;
top: .7em;
right: .7em;
font-weight: 900; font-weight: 900;
width: 40%;
height: 20%; height: 20%;
opacity: 0.75; opacity: .75;
position: absolute;
right: .7rem;
top: .7rem;
width: 40%;
z-index: 9; z-index: 9;
}
.scene-studio-overlay a { a {
width: 100%;
height: 100%;
background-size: contain;
display: inline-block;
background-position: right top; background-position: right top;
background-repeat: no-repeat; background-repeat: no-repeat;
letter-spacing: -.03em; background-size: contain;
text-shadow: 0 0 3px #000; color: #f5f8fa;
display: inline-block;
height: 100%;
letter-spacing: -.03rem;
text-align: right; text-align: right;
text-decoration: none; text-decoration: none;
color: #f5f8fa; text-shadow: 0 0 3px #000;
width: 100%;
}
} }
.overlay-resolution { .overlay-resolution {
font-weight: 900; font-weight: 900;
margin-right: .3rem;
text-transform: uppercase; text-transform: uppercase;
margin-right:.3em;
} }
.scene-card { .scene-card {
& .scene-specs-overlay, .rating-banner, .scene-studio-overlay { .scene-specs-overlay,
transition: opacity 0.5s; .rating-banner,
.scene-studio-overlay {
transition: opacity .5s;
} }
} }
.scene-card:hover { .scene-card:hover {
& .scene-specs-overlay, .rating-banner, .scene-studio-overlay { .scene-specs-overlay,
.rating-banner,
.scene-studio-overlay {
opacity: 0; opacity: 0;
transition: opacity 0.5s; transition: opacity .5s;
} }
.scene-studio-overlay:hover { .scene-studio-overlay:hover {
opacity: 0.75; opacity: .75;
transition: opacity 0.5s; transition: opacity .5s;
} }
} }
@@ -267,8 +319,8 @@ video.preview.portrait {
} }
.video-js { .video-js {
width: 100%;
height: 90vh; height: 90vh;
width: 100%;
} }
#details-container { #details-container {
@@ -281,14 +333,14 @@ video.preview.portrait {
} }
.logs { .logs {
white-space: pre-wrap; font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
word-break: break-all;
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
font-size: smaller; font-size: smaller;
padding-right: 10px;
overflow-y: auto;
max-height: 100vh; max-height: 100vh;
overflow-y: auto;
padding-right: 10px;
white-space: pre-wrap;
width: 120ch; width: 120ch;
word-break: break-all;
.debug { .debug {
color: lightgreen; color: lightgreen;
@@ -311,192 +363,70 @@ video.preview.portrait {
} }
} }
span.block { .studio {
display: block; .image {
}
.performer.image {
height: 50vh;
min-height: 400px;
background-size: cover !important;
background-position: center !important; background-position: center !important;
background-repeat: no-repeat !important; background-repeat: no-repeat !important;
}
.performer-tag-container {
margin: 5px;
display: inline-block;
}
.performer-tag.image {
height: 150px;
width: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
margin: 0 auto;
}
.studio.image {
height: 100px;
background-size: contain !important; background-size: contain !important;
background-position: center !important; height: 100px;
background-repeat: no-repeat !important;
} }
.no-spacing {
padding: 0;
margin: 0;
} }
.react-photo-gallery--gallery { .react-photo-gallery--gallery {
& img { img {
object-fit: contain; object-fit: contain;
} }
} }
#tag-list-container {
width: 50vw;
margin: 0 auto;
display: flex;
flex-direction: column;
& .tag-list-row {
margin: 10px;
cursor: pointer;
& .bp3-button {
margin: 0 10px;
}
}
& .tag-list-row:hover {
text-decoration: underline;
}
}
#parser-container { #parser-container {
margin: 10px auto; margin: 10px auto;
width: 75%; width: 75%;
& .inputs label { .inputs label {
width: 12em; width: 12rem;
} }
& .inputs .bp3-input-group { .scene-parser-results {
width: 80ch;
}
& .scene-parser-results {
overflow-x: auto; overflow-x: auto;
} }
& .scene-parser-row .bp3-checkbox { .scene-parser-row .parser-field-title input {
margin: 0px -20px 0px 0px;
}
& .scene-parser-row .parser-field-title input {
width: 50ch; width: 50ch;
} }
& .scene-parser-row .parser-field-date input { .scene-parser-row .parser-field-date input {
width: 13ch; width: 13ch;
} }
& .scene-parser-row .parser-field-performers input { .scene-parser-row .parser-field-performers input {
width: 20ch; width: 20ch;
} }
& .scene-parser-row .parser-field-tags input { .scene-parser-row .parser-field-tags input {
width: 20ch; width: 20ch;
} }
& .scene-parser-row .parser-field-studio input { .scene-parser-row .parser-field-studio input {
width: 15ch; width: 15ch;
} }
& .scene-parser-row input { .scene-parser-row input {
min-width: 10ch; min-width: 10ch;
} }
& .scene-parser-row .bp3-form-group { .scene-parser-row div:first-child > input {
margin-bottom: 0px;
}
& .scene-parser-row div:first-child > input {
margin-bottom: 5px; margin-bottom: 5px;
} }
} }
#performer-details {
& td {
vertical-align: middle;
}
& td:first-child {
width: 30ch;
}
& #url-field {
line-height: 30px;
}
& #scrape-url-button {
float: right;
height: 30px;
}
}
#performer-page {
margin: 10px auto;
width: 75%;
& .details-image-container {
max-height: 400px;
display: inline-block;
margin-right: 20px;
}
& .performer-head {
display: inline-block;
vertical-align: top;
font-size: 1.2em;
& .name-icons {
margin-left: 10px;
& .not-favorite .bp3-icon {
color: rgba(191, 204, 214, 0.5) !important;
}
& .favorite .bp3-icon {
color: #ff7373 !important;
}
}
}
& .alias {
font-weight: bold;
}
}
.zoom-slider {
margin: auto 5px;
width: 100px;
& .bp3-slider {
min-width: 100%;
}
}
.aliases-field > label { .aliases-field > label {
font-weight: 300; font-weight: 300;
} }
.scene-cover { .scene-cover {
display: block; display: block;
margin-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
margin-top: 10px;
max-width: 100%; max-width: 100%;
} }
@@ -505,10 +435,10 @@ span.block {
} }
.label-icon { .label-icon {
margin-right: 0.3em; margin-right: .3rem;
vertical-align: middle; vertical-align: middle;
& +span { + span {
vertical-align: middle; vertical-align: middle;
} }
} }
@@ -527,7 +457,7 @@ span.block {
} }
.table-striped tbody tr:nth-child(odd) td { .table-striped tbody tr:nth-child(odd) td {
background:rgba(92, 112, 128, 0.15); background: rgba(92, 112, 128, .15);
} }
.tab-pane { .tab-pane {
@@ -538,15 +468,15 @@ span.block {
background-color: #30404d; background-color: #30404d;
border-radius: 3px; border-radius: 3px;
box-shadow: 0 0 0 1px rgba(16, 22, 26, .4), 0 0 0 rgba(16, 22, 26, 0), 0 0 0 rgba(16, 22, 26, 0); box-shadow: 0 0 0 1px rgba(16, 22, 26, .4), 0 0 0 rgba(16, 22, 26, 0), 0 0 0 rgba(16, 22, 26, 0);
padding: 20px 20px 0px 20px; padding: 20px;
} }
.toast-container { .toast-container {
z-index: 1031;
position: fixed;
top: 2rem;
left: 45%; left: 45%;
max-width: 350px; max-width: 350px;
position: fixed;
top: 2rem;
z-index: 1031;
.toast { .toast {
width: 350px; width: 350px;
@@ -555,8 +485,8 @@ span.block {
.button-link { .button-link {
background-color: transparent; background-color: transparent;
color: #48aff0;
border-width: 0; border-width: 0;
color: #48aff0;
cursor: pointer; cursor: pointer;
display: inline; display: inline;
padding: 0; padding: 0;
@@ -578,4 +508,59 @@ span.block {
/* BOOTSTRAP OVERRIDES */ /* BOOTSTRAP OVERRIDES */
.form-control { .form-control {
width: inherit; width: inherit;
&-plaintext {
color: $text-color;
&::placeholder {
color: transparent;
}
&:hover {
cursor: default;
}
}
}
.popover {
&-body {
.btn {
color: $text-color;
}
}
}
.modal {
.modal-body,
.modal-footer {
background-color: rgb(235, 241, 245);
}
}
.image-input {
margin-bottom: 0;
overflow: hidden;
position: relative;
&:hover {
cursor: pointer;
}
[type=file] {
cursor: inherit;
display: block;
filter: alpha(opacity=0);
font-size: 999px;
min-height: 100%;
min-width: 100%;
opacity: 0;
position: absolute;
right: 0;
text-align: right;
top: 0;
}
}
.tab-content {
padding-bottom: 2rem;
} }

View File

@@ -1,7 +1,7 @@
/* eslint-disable consistent-return */ /* eslint-disable consistent-return */
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { DurationUtils } from "src/utils"; import DurationUtils from "src/utils/duration";
import { ILabeledId, ILabeledValue } from "../types"; import { ILabeledId, ILabeledValue } from "../types";
export type CriterionType = export type CriterionType =

View File

@@ -1,94 +1,106 @@
input[type=range] { input[type=range] {
height: 22px;
-webkit-appearance: none; -webkit-appearance: none;
margin: 10px 0;
background-color: transparent; background-color: transparent;
border-color: transparent; border-color: transparent;
} height: 22px;
input[type=range]:focus { margin: 10px 0;
&:focus {
background-color: transparent;
border: inherit; border: inherit;
border-color: transparent;
box-shadow: none; box-shadow: none;
outline: none; outline: none;
background-color: transparent;
border-color: transparent;
} }
input[type=range]::-webkit-slider-runnable-track {
width: 100%; &::-webkit-slider-runnable-track {
height: 6px; animate: .2s;
cursor: pointer;
animate: 0.2s;
box-shadow: 0px 0px 0px #000000;
background: #137cbd; background: #137cbd;
border: 0 solid #000101;
border-radius: 25px; border-radius: 25px;
border: 0px solid #000101; box-shadow: 0 0 0 #000;
}
input[type=range]::-webkit-slider-thumb {
box-shadow: 0px 0px 0px #000000;
border: 0px solid #000000;
height: 16px;
width: 16px;
border-radius: 5px;
background: #394B59;
cursor: pointer; cursor: pointer;
height: 6px;
width: 100%;
}
&::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
margin-top: -5px; background: #394b59;
} border: 0 solid #000;
input[type=range]:focus::-webkit-slider-runnable-track { border-radius: 5px;
background: #137cbd; box-shadow: 0 0 0 #000;
}
input[type=range]::-moz-range-track {
width: 100%;
height: 6px;
cursor: pointer; cursor: pointer;
animate: 0.2s; height: 16px;
box-shadow: 0px 0px 0px #000000; margin-top: -5px;
background: #137cbd; width: 16px;
border-radius: 25px;
border: 0px solid #000101;
} }
input[type=range]::-moz-range-thumb {
box-shadow: 0px 0px 0px #000000; &:focus::-webkit-slider-runnable-track {
border: 0px solid #000000; background: #137cbd;
}
&::-moz-range-track {
animate: .2s;
background: #137cbd;
border: 0 solid #000101;
border-radius: 25px;
box-shadow: 0 0 0 #000;
cursor: pointer;
height: 6px;
width: 100%;
}
&::-moz-range-thumb {
background: #394b59;
border: 0 solid #000;
border-radius: 5px;
box-shadow: 0 0 0 #000;
cursor: pointer;
height: 16px; height: 16px;
width: 16px; width: 16px;
border-radius: 5px;
background: #394B59;
cursor: pointer;
} }
input[type=range]::-ms-track {
width: 100%; &::-ms-track {
height: 6px; animate: .2s;
cursor: pointer;
animate: 0.2s;
background: transparent; background: transparent;
border-color: transparent; border-color: transparent;
color: transparent; color: transparent;
}
input[type=range]::-ms-fill-lower {
background: #137cbd;
border: 0px solid #000101;
border-radius: 50px;
box-shadow: 0px 0px 0px #000000;
}
input[type=range]::-ms-fill-upper {
background: #137cbd;
border: 0px solid #000101;
border-radius: 50px;
box-shadow: 0px 0px 0px #000000;
}
input[type=range]::-ms-thumb {
margin-top: 1px;
box-shadow: 0px 0px 0px #000000;
border: 0px solid #000000;
height: 16px;
width: 16px;
border-radius: 5px;
background: #394B59;
cursor: pointer; cursor: pointer;
height: 6px;
width: 100%;
} }
input[type=range]:focus::-ms-fill-lower {
&::-ms-fill-lower {
background: #137cbd;
border: 0 solid #000101;
border-radius: 50px;
box-shadow: 0 0 0 #000;
}
&::-ms-fill-upper {
background: #137cbd;
border: 0 solid #000101;
border-radius: 50px;
box-shadow: 0 0 0 #000;
}
&::-ms-thumb {
background: #394b59;
border: 0 solid #000;
border-radius: 5px;
box-shadow: 0 0 0 #000;
cursor: pointer;
height: 16px;
margin-top: 1px;
width: 16px;
}
&:focus::-ms-fill-lower {
background: #137cbd; background: #137cbd;
} }
input[type=range]:focus::-ms-fill-upper {
&:focus::-ms-fill-upper {
background: #137cbd; background: #137cbd;
} }
}

View File

@@ -2,66 +2,43 @@
/* Site */ /* Site */
::-webkit-selection {
background-color: #CCE2FF;
color: rgba(0, 0, 0, 0.87);
}
::-moz-selection {
background-color: #CCE2FF;
color: rgba(0, 0, 0, 0.87);
}
::selection { ::selection {
background-color: #CCE2FF; background-color: #cce2ff;
color: rgba(0, 0, 0, 0.87); color: rgba(0, 0, 0, .87);
} }
/* Form */ /* Form */
textarea::-webkit-selection,
input::-webkit-selection {
background-color: rgba(100, 100, 100, 0.4);
color: rgba(0, 0, 0, 0.87);
}
textarea::-moz-selection,
input::-moz-selection {
background-color: rgba(100, 100, 100, 0.4);
color: rgba(0, 0, 0, 0.87);
}
textarea::selection, textarea::selection,
input::selection { input::selection {
background-color: rgba(100, 100, 100, 0.4); background-color: rgba(100, 100, 100, .4);
color: rgba(0, 0, 0, 0.87); color: rgba(0, 0, 0, .87);
} }
/* Force Simple Scrollbars */ /* Force Simple Scrollbars */
body ::-webkit-scrollbar { body ::-webkit-scrollbar {
-webkit-appearance: none; -webkit-appearance: none;
width: 10px;
height: 10px; height: 10px;
width: 10px;
} }
body ::-webkit-scrollbar-track { body ::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1); background: rgba(0, 0, 0, .1);
border-radius: 0px; border-radius: 0;
} }
body ::-webkit-scrollbar-thumb { body ::-webkit-scrollbar-thumb {
cursor: pointer; background: rgba(0, 0, 0, .25);
border-radius: 5px; border-radius: 5px;
background: rgba(0, 0, 0, 0.25); cursor: pointer;
-webkit-transition: color 0.2s ease; transition: color .2s ease;
transition: color 0.2s ease;
} }
body ::-webkit-scrollbar-thumb:window-inactive { body ::-webkit-scrollbar-thumb:window-inactive {
background: rgba(0, 0, 0, 0.15); background: rgba(0, 0, 0, .15);
} }
body ::-webkit-scrollbar-thumb:hover { body ::-webkit-scrollbar-thumb:hover {
background: rgba(128, 135, 139, 0.8); background: rgba(128, 135, 139, .8);
} }

View File

@@ -19,8 +19,6 @@ $link-hover-color: #48aff0;
$text-color: #f5f8fa; $text-color: #f5f8fa;
$pre-color: $text-color; $pre-color: $text-color;
$navbar-dark-color: rgb(245, 248, 250); $navbar-dark-color: rgb(245, 248, 250);
$input-bg: $secondary;
$input-color: #f5f8fa;
$popover-bg: $secondary; $popover-bg: $secondary;
@import "node_modules/bootstrap/scss/bootstrap"; @import "node_modules/bootstrap/scss/bootstrap";
@@ -42,20 +40,14 @@ button.minimal {
background: rgba(138, 155, 168, .15); background: rgba(138, 155, 168, .15);
color: $text-color; color: $text-color;
} }
&:active { &:active {
background: rgba(138, 155, 168, .3); background: rgba(138, 155, 168, .3);
color: $text-color; color: $text-color;
} }
} }
input.form-control { .dropdown-toggle::after {
background-color: rgba(16, 22, 26, 0.3);
}
.form-control {
border-color: rgba(16,22,26,.4);
}
.dropdown-toggle:after {
content: none; content: none;
} }
@@ -63,18 +55,40 @@ nav .svg-inline--fa {
margin-right: 7px; margin-right: 7px;
} }
.nav-tabs {
border-bottom-color: gray;
.nav-link.active {
border-color: gray;
border-bottom-color: transparent;
color: $text-color;
&:hover {
cursor: default;
}
}
}
hr { hr {
margin: 5px 0; margin: 5px 0;
} }
.table { .table {
th { border: none;
border-top: none;
}
thead { thead {
th { th {
border-bottom-width: 1px; border: none;
}
}
td {
a {
color: $text-color;
} }
} }
} }
.popover {
max-width: inherit;
}

View File

@@ -1,27 +0,0 @@
.bp3-form-group .bp3-popover-target {
width: 100%;
}
.bp3-html-table, .form-container {
& .bp3-popover-target, & textarea {
width: 100%;
}
& textarea {
min-height: 250px;
resize: vertical;
}
}
form .columns.is-gapless > .column {
margin: 5px 0;
padding: 0 10px !important;
}
form .columns {
margin-bottom: 5px !important;
}
form .buttons-container button {
margin-left: 10px;
}

View File

@@ -1,56 +0,0 @@
.details-image-container {
margin: 0;
padding: 0;
display: flex;
height: calc(100vh - 50px); // 50px for navbar
align-items: center;
justify-content: center;
& img.performer {
height: 98%;
max-width: 98%;
object-fit: cover;
box-shadow: 0px 0px 10px black;
}
& img.studio {
height: 50%;
max-width: 50%;
object-fit: contain;
box-shadow: 0px 0px 10px black;
}
}
.details-detail-container {
padding: 10px !important;
& .bp3-navbar {
margin: 0 0 10px 0;
z-index: 1;
& .bp3-button {
margin: 0 10px;
}
& .bp3-file-input {
width: 190px;
& input {
min-width: unset;
max-width: 190px;
}
}
}
& .bp3-button.favorite .bp3-icon {
color: #ff7373 !important
}
}
.dialog-content {
padding: 10px;
& .bp3-popover-target {
width: 100%;
}
}

View File

@@ -3,3 +3,4 @@ export { default as NavUtils } from "./navigation";
export { default as TableUtils } from "./table"; export { default as TableUtils } from "./table";
export { default as TextUtils } from "./text"; export { default as TextUtils } from "./text";
export { default as DurationUtils } from "./duration"; export { default as DurationUtils } from "./duration";
export { default as JWUtils } from './jwplayer';

View File

@@ -0,0 +1,9 @@
const playerID = "main-jwplayer";
const getPlayer = () => (
(window as any).jwplayer(playerID)
)
export default {
playerID,
getPlayer
};

View File

@@ -1,9 +1,9 @@
import * as GQL from "../core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { PerformersCriterion } from "../models/list-filter/criteria/performers"; import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
import { StudiosCriterion } from "../models/list-filter/criteria/studios"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
import { TagsCriterion } from "../models/list-filter/criteria/tags"; import { TagsCriterion } from "src/models/list-filter/criteria/tags";
import { ListFilterModel } from "../models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { FilterMode } from "../models/list-filter/types"; import { FilterMode } from "src/models/list-filter/types";
const makePerformerScenesUrl = ( const makePerformerScenesUrl = (
performer: Partial<GQL.PerformerDataFragment> performer: Partial<GQL.PerformerDataFragment>
@@ -54,11 +54,10 @@ const makeSceneMarkerUrl = (
return `/scenes/${sceneMarker.scene.id}?t=${sceneMarker.seconds}`; return `/scenes/${sceneMarker.scene.id}?t=${sceneMarker.seconds}`;
}; };
const Nav = { export default {
makePerformerScenesUrl, makePerformerScenesUrl,
makeStudioScenesUrl, makeStudioScenesUrl,
makeTagSceneMarkersUrl, makeTagSceneMarkersUrl,
makeTagScenesUrl, makeTagScenesUrl,
makeSceneMarkerUrl makeSceneMarkerUrl
}; };
export default Nav;

View File

@@ -89,10 +89,13 @@ const renderHtmlSelect = (options: {
as="select" as="select"
readOnly={!options.isEditing} readOnly={!options.isEditing}
plaintext={!options.isEditing} plaintext={!options.isEditing}
value={options.value?.toString()}
onChange={(event: React.FormEvent<HTMLSelectElement>) => onChange={(event: React.FormEvent<HTMLSelectElement>) =>
options.onChange(event.currentTarget.value) options.onChange(event.currentTarget.value)
} }
/> >
{ options.selectOptions.map(opt => <option value={opt} key={opt}>{opt}</option>)}
</Form.Control>
</td> </td>
</tr> </tr>
); );

View File

@@ -6,7 +6,6 @@
"dom.iterable", "dom.iterable",
"esnext" "esnext"
], ],
"allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
@@ -15,7 +14,6 @@
"module": "esnext", "module": "esnext",
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react", "jsx": "react",
"downlevelIteration": true, "downlevelIteration": true,