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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
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 { StashService } from "src/core/StashService";
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";
export const SettingsConfigurationPanel: React.FC = () => {
@ -154,7 +154,7 @@ export const SettingsConfigurationPanel: React.FC = () => {
if (error) return <h1>{error.message}</h1>;
if (!data?.configuration || loading)
return <Spinner animation="border" variant="light" />;
return <LoadingIndicator />;
return (
<>

View file

@ -1,5 +1,6 @@
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 { useToast } from "src/hooks";
@ -52,7 +53,7 @@ export const SettingsInterfacePanel: React.FC = () => {
<>
{config.error ? <h1>{config.error.message}</h1> : ""}
{!config?.data?.configuration || config.loading ? (
<Spinner animation="border" variant="light" />
<LoadingIndicator />
) : (
""
)}

View file

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

View file

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

View file

@ -1,5 +1,6 @@
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";
interface IProps {
@ -55,7 +56,7 @@ export const FolderSelect: React.FC<IProps> = (props: IProps) => {
/>
<InputGroup.Append>
{!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 { Spinner } from "react-bootstrap";
import cx from 'classnames';
interface ILoadingProps {
message: string;
message?: string;
inline?: boolean;
}
const CLASSNAME = "LoadingIndicator";
const CLASSNAME_MESSAGE = `${CLASSNAME}-message`;
const LoadingIndicator: React.FC<ILoadingProps> = ({ message }) => (
<div className={CLASSNAME}>
const LoadingIndicator: React.FC<ILoadingProps> = ({ message, inline = false }) => (
<div className={cx(CLASSNAME, { inline }) }>
<Spinner animation="border" role="status">
<span className="sr-only">Loading...</span>
</Spinner>
<h4 className={CLASSNAME_MESSAGE}>{message}</h4>
<h4 className={CLASSNAME_MESSAGE}>{message ?? "Loading..."}</h4>
</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 CreatableSelect from "react-select/creatable";
import { debounce } from "lodash";
@ -38,6 +38,8 @@ interface ISelectProps {
isClearable?: boolean,
onInputChange?: (input: string) => void;
placeholder?: string;
showDropdown?: boolean;
groupHeader?: string;
}
interface ISceneGallerySelect {
@ -120,6 +122,8 @@ export const ScrapePerformerSuggest: React.FC<IScrapePerformerSuggestProps> = pr
items={items}
initialIds={[]}
placeholder={props.placeholder}
className="select-suggest"
showDropdown={false}
/>
);
};
@ -147,10 +151,13 @@ export const MarkerTitleSuggest: React.FC<IMarkerSuggestProps> = props => {
isLoading={loading}
items={items}
initialIds={initialIds}
placeholder="Marker title..."
className="select-suggest"
showDropdown={false}
groupHeader="Previously used titles..."
/>
);
};
export const FilterSelect: React.FC<IFilterProps & ITypeProps> = props =>
props.type === "performers" ? (
<PerformerSelect {...(props as IFilterProps)} />
@ -304,44 +311,28 @@ const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
creatable = false,
isMulti = false,
onInputChange,
placeholder
placeholder,
showDropdown = true,
groupHeader
}) => {
const defaultValue =
items.filter(item => initialIds?.indexOf(item.value) !== -1) ?? null;
const options = groupHeader ? [{
label: groupHeader,
options: items
}] : items;
const styles = {
control: (provided:any) => ({
option: (provided:CSSProperties) => ({
...provided,
background: '#394b59',
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' }
)
color: "#000"
})
};
const props = {
options: items,
options,
value: selectedOptions,
styles,
className,
onChange,
isMulti,
@ -351,7 +342,11 @@ const SelectComponent: React.FC<ISelectProps & ITypeProps> = ({
placeholder,
onInputChange,
isLoading,
components: { IndicatorSeparator: () => null }
styles,
components: {
IndicatorSeparator: () => null,
...(!showDropdown && { DropdownIndicator: () => null })
}
};
return creatable ? (

View file

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

View file

@ -1,17 +1,49 @@
.LoadingIndicator {
align-items: center;
display: flex;
flex-direction: column;
height: 70vh;
justify-content: center;
width: 100%;
align-items: center;
display: flex;
flex-direction: column;
height: 70vh;
justify-content: center;
width: 100%;
&-message {
margin-top: 1rem;
}
&-message {
margin-top: 1rem;
}
.spinner-border {
height: 3rem;
width: 3rem;
}
.spinner-border {
height: 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 }) => {
return (
<Card className="col-4">
<Card className="studio-card">
<Link
to={`/studios/${studio.id}`}
className="studio previewable image"
style={{ backgroundImage: `url(${studio.image_path})` }}
/>
<div className="card-section">
<h4 className="text-truncate">{studio.name}</h4>
<h5 className="text-truncate">{studio.name}</h5>
<span>{studio.scene_count} scenes.</span>
</div>
</Card>

View file

@ -1,14 +1,16 @@
/* 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 { useParams, useHistory } from "react-router-dom";
import cx from 'classnames';
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { ImageUtils, TableUtils } from "src/utils";
import { DetailsEditNavbar } from "src/components/Shared";
import { DetailsEditNavbar, Modal, LoadingIndicator } from "src/components/Shared";
import { useToast } from "src/hooks";
import { StudioScenesPanel } from './StudioScenesPanel';
export const Studio: React.FC = () => {
const history = useHistory();
@ -18,17 +20,16 @@ export const Studio: React.FC = () => {
// Editing state
const [isEditing, setIsEditing] = useState<boolean>(isNew);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Editing studio state
const [image, setImage] = useState<string | undefined>(undefined);
const [name, setName] = useState<string | undefined>(undefined);
const [url, setUrl] = useState<string | undefined>(undefined);
const [image, setImage] = useState<string>();
const [name, setName] = useState<string>();
const [url, setUrl] = useState<string>();
// Studio state
const [studio, setStudio] = useState<Partial<GQL.StudioDataFragment>>({});
const [imagePreview, setImagePreview] = useState<string | undefined>(
undefined
);
const [imagePreview, setImagePreview] = useState<string>();
const { data, error, loading } = StashService.useFindStudio(id);
const [updateStudio] = StashService.useStudioUpdate(
@ -71,7 +72,7 @@ export const Studio: React.FC = () => {
if (!isNew && !isEditing) {
if (!data?.findStudio || loading)
return <Spinner animation="border" variant="light" />;
return <LoadingIndicator />;
if (error) return <div>{error.message}</div>;
}
@ -98,8 +99,10 @@ export const Studio: React.FC = () => {
}
} else {
const result = await createStudio();
if (result.data?.studioCreate?.id)
if (result.data?.studioCreate?.id) {
history.push(`/studios/${result.data.studioCreate.id}`);
setIsEditing(false);
}
}
} catch (e) {
Toast.error(e);
@ -107,9 +110,7 @@ export const Studio: React.FC = () => {
}
async function onAutoTag() {
if (!studio || !studio.id) {
return;
}
if (!studio.id) return;
try {
await StashService.queryMetadataAutoTag({ studios: [studio.id] });
Toast.success({ content: "Started auto tagging" });
@ -133,52 +134,57 @@ export const Studio: React.FC = () => {
ImageUtils.onImageChange(event, onImageLoad);
}
// TODO: CSS class
function renderDeleteAlert() {
return (
<Modal
show={isDeleteAlertOpen}
icon="trash-alt"
accept={{ text: "Delete", variant: "danger", onClick: onDelete }}
cancel={{ onClick: () => setIsDeleteAlertOpen(false) }}
>
<p>Are you sure you want to delete {studio.name ?? 'studio'}?</p>
</Modal>
);
}
return (
<div className="columns is-multiline no-spacing">
<div className="column is-half details-image-container">
<img className="studio" alt={name} src={imagePreview} />
</div>
<div className="column is-half details-detail-container">
<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
studio={studio}
isNew={isNew}
isEditing={isEditing}
onToggleEdit={() => {
setIsEditing(!isEditing);
updateStudioEditState(studio);
}}
onToggleEdit={() => setIsEditing(!isEditing)}
onSave={onSave}
onDelete={onDelete}
onAutoTag={onAutoTag}
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>
{ !isNew && (
<div className="col-8">
<StudioScenesPanel studio={studio} />
</div>
)}
{renderDeleteAlert()}
</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,
filter: ListFilterModel
) {
if (!result.data || !result.data.findStudios) {
if (!result.data?.findStudios)
return;
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<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 { Button, Form, Spinner } from "react-bootstrap";
import { Button, Form } from "react-bootstrap";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { NavUtils } from "src/utils";
import { Icon, Modal } from "src/components/Shared";
import { Icon, Modal, LoadingIndicator } from "src/components/Shared";
import { useToast } from "src/hooks";
export const TagList: React.FC = () => {
@ -93,34 +93,36 @@ export const TagList: React.FC = () => {
</Modal>
);
if (!data?.allTags) return <Spinner animation="border" variant="light" />;
if (!data?.allTags)
return <LoadingIndicator />;
if (error) return <div>{error.message}</div>;
const tagElements = data.allTags.map(tag => {
return (
<>
{deleteAlert}
<div key={tag.id} className="tag-list-row">
<Button variant="link" onClick={() => setEditingTag(tag)}>
{tag.name}
</Button>
<div style={{ float: "right" }}>
<Button onClick={() => onAutoTag(tag)}>Auto Tag</Button>
<div key={tag.id} className="tag-list-row">
<Button variant="link" onClick={() => setEditingTag(tag)}>
{tag.name}
</Button>
<div style={{ float: "right" }}>
<Button variant="secondary" onClick={() => onAutoTag(tag)}>Auto Tag</Button>
<Button variant="secondary">
<Link to={NavUtils.makeTagScenesUrl(tag)}>
Scenes: {tag.scene_count}
</Link>
</Button>
<Button variant="secondary">
<Link to={NavUtils.makeTagSceneMarkersUrl(tag)}>
Markers: {tag.scene_marker_count}
</Link>
<span>
Total: {(tag.scene_count || 0) + (tag.scene_marker_count || 0)}
</span>
<Button variant="danger" onClick={() => setDeletingTag(tag)}>
<Icon icon="trash-alt" color="danger" />
</Button>
</div>
</Button>
<span>
Total: {(tag.scene_count || 0) + (tag.scene_marker_count || 0)}
</span>
<Button variant="danger" onClick={() => setDeletingTag(tag)}>
<Icon icon="trash-alt" color="danger" />
</Button>
</div>
</>
</div>
);
});
@ -154,6 +156,7 @@ export const TagList: React.FC = () => {
</Modal>
{tagElements}
{deleteAlert}
</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 {
background-color: rgba(0,0,0,.8);
position: fixed;
top: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, .8);
bottom: 0;
z-index: 1;
left: 0;
pointer-events: none;
position: fixed;
right: 0;
top: 0;
transition: transform .5s ease-in-out;
z-index: 1;
}
.visible {
opacity: 1;
transition: opacity .5s ease-in-out;
}
.hidden {
opacity: 0;
transition: opacity .5s ease-in-out;
}
.visible-unanimated {
opacity: 1;
}
.hidden-unanimated {
opacity: 0;
}
.double-scale {
position: absolute;
z-index: 2;
transform: scale(2);
background-color: black;
position: absolute;
transform: scale(2);
z-index: 2;
}
.double-scale img {
@ -38,60 +41,61 @@
.scene-wall-item-container {
display: flex;
justify-content: center;
position: relative;
width: 100%;
height: 100%;
transition: transform .5s;
justify-content: center;
max-height: 253px;
position: relative;
transition: transform .5s;
width: 100%;
}
.scene-wall-item-container video {
height: 100%;
position: absolute;
width: 100%;
height: 100%;
z-index: -1;
}
.scene-wall-item-text-container {
position: absolute;
font-weight: 700;
color: #444;
padding: 5px;
width: 100%;
background: linear-gradient(rgba(255, 255, 255, .25), rgba(255, 255, 255, .65));
bottom: 0;
color: #444;
font-weight: 700;
left: 0;
background: linear-gradient(rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0.65));
overflow: hidden;
padding: 5px;
position: absolute;
text-align: center;
width: 100%;
& span {
line-height: 1;
font-weight: 400;
font-size: 10px;
font-weight: 400;
line-height: 1;
margin: 0 3px;
}
}
.scene-wall-item-blur {
position: absolute;
top: -5px;
left: -5px;
right: -5px;
bottom: -5px;
left: -5px;
position: absolute;
right: -5px;
top: -5px;
z-index: -1;
}
.wall-item video, .wall-item img {
width: 100%;
.wall-item video,
.wall-item img {
height: 100%;
object-fit: contain;
width: 100%;
}
.wall-item {
width: 20%;
padding: 0 !important;
line-height: 0;
overflow: visible;
padding: 0 !important;
position: relative;
width: 20%;
}

View file

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

View file

@ -117,17 +117,15 @@ export const AddFilter: React.FC<IAddFilterProps> = (
return;
}
return (
<div>
<Form.Control
as="select"
onChange={onChangedModifierSelect}
value={criterion.modifier}
>
{criterion.modifierOptions.map(c => (
<option value={c.value}>{c.label}</option>
))}
</Form.Control>
</div>
<Form.Control
as="select"
onChange={onChangedModifierSelect}
value={criterion.modifier}
>
{criterion.modifierOptions.map(c => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</Form.Control>
);
}
@ -155,10 +153,13 @@ export const AddFilter: React.FC<IAddFilterProps> = (
return (
<FilterSelect
type={type}
isMulti
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}
>
{criterion.options.map(c => (
<option value={c}>{c}</option>
<option key={c} value={c}>{c}</option>
))}
</Form.Control>
);
@ -215,7 +216,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
value={criterion.type}
>
{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.Group>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,12 +6,11 @@ import {
Form,
Popover,
OverlayTrigger,
Spinner,
Table
} from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
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 { useToast } from "src/hooks";
@ -117,7 +116,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
setQueryableScrapers(newQueryableScrapers);
}, [Scrapers]);
if (isLoading) return <Spinner animation="border" variant="light" />;
if (isLoading) return <LoadingIndicator />;
function getPerformerInput() {
const performerInput: Partial<
@ -237,7 +236,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
return (
<OverlayTrigger trigger="click" placement="bottom" overlay={popover}>
<Button>Scrape with...</Button>
<Button variant="secondary">Scrape with...</Button>
</OverlayTrigger>
);
}
@ -277,7 +276,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
return undefined;
}
return (
<Button id="scrape-url-button" onClick={() => onScrapePerformerURL()}>
<Button className="minimal scrape-url-button" onClick={() => onScrapePerformerURL()}>
<Icon icon="file-upload" />
</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() {
if (isEditing) {
return TableUtils.renderInputGroup({
@ -465,7 +446,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
isEditing: !!isEditing,
onChange: setInstagram
})}
{renderImageInput()}
<ImageInput isEditing={!!isEditing} onImageChange={onImageChangeHandler} />
</tbody>
</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;
const popoverContent = props.scene.performers.map(performer => (
<div className="performer-tag-container">
<div className="performer-tag-container" key="performer">
<Link
to={`/performers/${performer.id}`}
className="performer-tag previewable image"
@ -182,7 +182,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
return (
<Card
className={`zoom-${props.zoomIndex}`}
className={`zoom-${props.zoomIndex} scene-card`}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
@ -197,6 +197,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
event.stopPropagation();
}}
/>
{maybeRenderSceneStudioOverlay()}
<Link
to={`/scenes/${props.scene.id}`}
className={cx("image", "previewable", { portrait: isPortrait() })}
@ -204,7 +205,6 @@ export const SceneCard: React.FC<ISceneCardProps> = (
<div className="video-container">
{maybeRenderRatingBanner()}
{maybeRenderSceneSpecsOverlay()}
{maybeRenderSceneStudioOverlay()}
<video
loop
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 React, { useEffect, useState } from "react";
import { useParams, useLocation, useHistory } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { GalleryViewer } from "src/components/Galleries/GalleryViewer";
import { ScenePlayer } from "../ScenePlayer/ScenePlayer";
import { SceneDetailPanel } from "./SceneDetailPanel";
import { SceneEditPanel } from "./SceneEditPanel";
import { SceneFileInfoPanel } from "./SceneFileInfoPanel";
import { SceneMarkersPanel } from "./SceneMarkersPanel";
import { LoadingIndicator } from 'src/components/Shared';
import { ScenePlayer } from "src/components/scenes/ScenePlayer/ScenePlayer";
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 = () => {
const { id = "new" } = useParams();
const location = useLocation();
const history = useHistory();
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 queryParams = queryString.parse(location.search);
const autoplay = queryParams?.autoplay === "true";
useEffect(() => setScene(data?.findScene ?? {}), [data]);
useEffect(() => {
if(data?.findScene)
setScene(data.findScene)
}, [data]);
function getInitialTimestamp() {
const params = queryString.parse(location.search);
@ -38,61 +42,56 @@ export const Scene: React.FC = () => {
setTimestamp(marker.seconds);
}
if (!data?.findScene || loading || Object.keys(scene).length === 0) {
return <Spinner animation="border" />;
if (loading || !scene || !data?.findScene) {
return <LoadingIndicator />;
}
if (error) return <div>{error.message}</div>;
const modifiedScene = {
scene_marker_tags: data.sceneMarkerTags,
...scene
} as GQL.SceneDataFragment; // TODO Hack from angular
return (
<>
<ScenePlayer
scene={modifiedScene}
scene={scene}
timestamp={timestamp}
autoplay={autoplay}
/>
<Card id="details-container">
<div id="details-container">
<Tabs id="scene-tabs" mountOnEnter>
<Tab eventKey="scene-details-panel" title="Details">
<SceneDetailPanel scene={modifiedScene} />
<SceneDetailPanel scene={scene} />
</Tab>
<Tab eventKey="scene-markers-panel" title="Markers">
<SceneMarkersPanel
scene={modifiedScene}
scene={scene}
onClickMarker={onClickMarker}
/>
</Tab>
{modifiedScene.performers.length > 0 ? (
{scene.performers.length > 0 ? (
<Tab eventKey="scene-performer-panel" title="Performers">
<ScenePerformerPanel scene={modifiedScene} />
<ScenePerformerPanel scene={scene} />
</Tab>
) : (
""
)}
{modifiedScene.gallery ? (
{scene.gallery ? (
<Tab eventKey="scene-gallery-panel" title="Gallery">
<GalleryViewer gallery={modifiedScene.gallery} />
<GalleryViewer gallery={scene.gallery} />
</Tab>
) : (
""
)}
<Tab eventKey="scene-file-info-panel" title="File Info">
<SceneFileInfoPanel scene={modifiedScene} />
<Tab className="file-info-panel" eventKey="scene-file-info-panel" title="File Info">
<SceneFileInfoPanel scene={scene} />
</Tab>
<Tab eventKey="scene-edit-panel" title="Edit">
<SceneEditPanel
scene={modifiedScene}
scene={scene}
onUpdate={newScene => setScene(newScene)}
onDelete={() => history.push("/scenes")}
/>
</Tab>
</Tabs>
</Card>
</div>
</>
);
};

View file

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

View file

@ -2,24 +2,26 @@
import React, { useEffect, useState } from "react";
import {
Collapse,
Button,
Dropdown,
DropdownButton,
Form,
Button,
Spinner
Table
} from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import {
FilterSelect,
PerformerSelect,
TagSelect,
StudioSelect,
SceneGallerySelect,
Modal,
Icon
Icon,
LoadingIndicator,
ImageInput
} from "src/components/Shared";
import { useToast } from "src/hooks";
import { ImageUtils } from "src/utils";
import { ImageUtils, TableUtils } from "src/utils";
interface IProps {
scene: GQL.SceneDataFragment;
@ -47,11 +49,10 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
const [deleteFile, setDeleteFile] = useState<boolean>(false);
const [deleteGenerated, setDeleteGenerated] = useState<boolean>(true);
const [isCoverImageOpen, setIsCoverImageOpen] = useState<boolean>(false);
const [coverImagePreview, setCoverImagePreview] = useState<string>();
// Network state
const [isLoading, setIsLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [updateScene] = StashService.useSceneUpdate(getSceneInput());
const [deleteScene] = StashService.useSceneDestroy(getSceneDeleteInput());
@ -65,9 +66,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
}, [Scrapers]);
function updateSceneEditState(state: Partial<GQL.SceneDataFragment>) {
const perfIds = state.performers
? state.performers.map(performer => performer.id)
: undefined;
const perfIds = state.performers?.map(performer => performer.id);
const tIds = state.tags ? state.tags.map(tag => tag.id) : undefined;
setTitle(state.title ?? undefined);
@ -84,6 +83,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
useEffect(() => {
updateSceneEditState(props.scene);
setCoverImagePreview(props.scene?.paths?.screenshot ?? undefined);
setIsLoading(false);
}, [props.scene]);
ImageUtils.usePasteImage(onImageLoad);
@ -136,34 +136,9 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
Toast.error(e);
}
setIsLoading(false);
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() {
return (
<Modal
@ -278,11 +253,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
}
}
if (
(!tagIds || tagIds.length === 0) &&
scene.tags &&
scene.tags.length > 0
) {
if (!tagIds?.length && scene?.tags?.length) {
const idTags = scene.tags.filter(p => {
return p.id !== undefined && p.id !== null;
});
@ -323,126 +294,123 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
);
}
return (
<>
{renderDeleteAlert()}
{isLoading ? <Spinner animation="border" variant="light" /> : undefined}
<div className="form-container " style={{ width: "50%" }}>
<Form.Group controlId="title">
<Form.Label>Title</Form.Label>
<Form.Control
onChange={(newValue: any) => setTitle(newValue.target.value)}
value={title}
/>
</Form.Group>
if(isLoading)
return <LoadingIndicator />;
return (
<div className="form-container row">
<div className="col-6">
<Table id="scene-details">
<tbody>
{TableUtils.renderInputGroup({
title: "Title",
value: title,
onChange: setTitle,
isEditing: true
})}
<tr>
<td>URL</td>
<td>
<Form.Control
onChange={(newValue: any) => setUrl(newValue.target.value)}
value={url}
placeholder="URL"
/>
{maybeRenderScrapeButton()}
</td>
</tr>
{TableUtils.renderInputGroup({
title: "Date (YYYY-MM-DD)",
value: date,
isEditing: true,
onChange: setDate
})}
{TableUtils.renderHtmlSelect({
title: "Rating",
value: rating,
isEditing: true,
onChange: (value: string) => setRating(Number.parseInt(value, 10)),
selectOptions: ['', 1, 2, 3, 4, 5]
})}
<tr>
<td>Gallery</td>
<td>
<SceneGallerySelect
sceneId={props.scene.id}
initialId={galleryId}
onSelect={item => setGalleryId(item ? item.id : undefined)}
/>
</td>
</tr>
<tr>
<td>Studio</td>
<td>
<StudioSelect
onSelect={items => items.length && setStudioId(items[0]?.id)}
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 controlId="url">
<Form.Label>URL</Form.Label>
<Form.Control
onChange={(newValue: any) => setUrl(newValue.target.value)}
value={url}
/>
{maybeRenderScrapeButton()}
</Form.Group>
<Form.Group controlId="date">
<Form.Label>Date</Form.Label>
<Form.Control
onChange={(newValue: any) => setDate(newValue.target.value)}
value={date}
/>
<div>YYYY-MM-DD</div>
</Form.Group>
<Form.Group controlId="rating">
<Form.Label>Rating</Form.Label>
<Form.Control
as="select"
onChange={(event: any) =>
setRating(parseInt(event.target.value, 10))
}
>
{["", 1, 2, 3, 4, 5].map(opt => (
<option selected={opt === rating} value={opt}>
{opt}
</option>
))}
</Form.Control>
</Form.Group>
<Form.Group controlId="gallery">
<Form.Label>Gallery</Form.Label>
<SceneGallerySelect
sceneId={props.scene.id}
initialId={galleryId}
onSelect={item => setGalleryId(item ? item.id : undefined)}
/>
</Form.Group>
<Form.Group controlId="studio">
<Form.Label>Studio</Form.Label>
<StudioSelect
onSelect={items => items.length && setStudioId(items[0]?.id)}
initialIds={studioId ? [studioId] : []}
/>
</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>
<img
className="scene-cover"
src={coverImagePreview}
alt="Scene cover"
/>
<Form.Group className="test" controlId="cover">
<Form.Control
type="file"
onChange={onCoverImageChange}
accept=".jpg,.jpeg,.png"
/>
</Form.Group>
</div>
</Collapse>
<Form.Group className="test" controlId="cover">
<Form.Label>Cover Image</Form.Label>
<img
className="scene-cover"
src={coverImagePreview}
alt="Scene cover"
/>
<ImageInput isEditing onImageChange={onCoverImageChange} />
</Form.Group>
</div>
</div>
<Button className="edit-button" variant="primary" onClick={onSave}>
Save
</Button>
<Button
className="edit-button"
variant="danger"
onClick={() => setIsDeleteAlertOpen(true)}
>
Delete
</Button>
<div className="col edit-buttons">
<Button className="edit-button" variant="primary" onClick={onSave}>
Save
</Button>
<Button
className="edit-button"
variant="danger"
onClick={() => setIsDeleteAlertOpen(true)}
>
Delete
</Button>
</div>
{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 {
Badge,
Button,
Card,
Collapse,
Form as BootstrapForm
} from "react-bootstrap";
import { Field, FieldProps, Form, Formik } from "formik";
import * as GQL from "src/core/generated-graphql";
import { StashService } from "src/core/StashService";
import { TextUtils } from "src/utils";
import { useToast } from "src/hooks";
import {
DurationInput,
TagSelect,
MarkerTitleSuggest
} from "src/components/Shared";
import { 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 {
scene: GQL.SceneDataFragment;
onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;
}
interface IFormFields {
title: string;
seconds: string;
primaryTagId: string;
tagIds: string[];
}
export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
props: ISceneMarkersPanelProps
) => {
const Toast = useToast();
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
const [
editingMarker,
setEditingMarker
] = useState<GQL.SceneMarkerDataFragment | null>(null);
] = useState<GQL.SceneMarkerDataFragment>();
const [sceneMarkerCreate] = StashService.useSceneMarkerCreate();
const [sceneMarkerUpdate] = StashService.useSceneMarkerUpdate();
const [sceneMarkerDestroy] = StashService.useSceneMarkerDestroy();
const jwplayer = SceneHelpers.getPlayer();
const jwplayer = JWUtils.getPlayer();
function onOpenEditor(marker?: GQL.SceneMarkerDataFragment) {
setIsEditorOpen(true);
setEditingMarker(marker ?? null);
setEditingMarker(marker ?? undefined);
}
function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
props.onClickMarker(marker);
}
function renderTags() {
function renderMarkers(primaryTag: GQL.SceneMarkerTag) {
const markers = primaryTag.scene_markers.map(marker => {
const markerTags = marker.tags.map(tag => (
<Badge key={tag.id} variant="secondary" className="tag-item">
{tag.name}
</Badge>
));
const closeEditor = () => {
setEditingMarker(undefined);
setIsEditorOpen(false);
};
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);
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: []
};
}
if(isEditorOpen)
return (
<Collapse in={isEditorOpen}>
<div className="">
<Formik
initialValues={initialValues}
onSubmit={onSubmit}
render={renderFormFields}
/>
</div>
</Collapse>
<SceneMarkerForm
sceneID={props.scene.id}
editingMarker={editingMarker}
playerPosition={jwplayer.getPlayer?.().playerPosition}
onClose={closeEditor}
/>
);
}
function render() {
const newMarkerForm = (
<div style={{ margin: "5px" }}>
<Button onClick={() => onOpenEditor()}>Create</Button>
{renderForm()}
</div>
);
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 (
<>
{newMarkerForm}
<div style={containerStyle}>{renderTags()}</div>
return (
<>
<Button onClick={() => onOpenEditor()}>Create Marker</Button>
<PrimaryTags
sceneMarkers={props.scene.scene_markers ?? []}
onClickMarker={onClickMarker}
onEdit={onOpenEditor}
/>
<div className="row">
<WallPanel
sceneMarkers={props.scene.scene_markers}
clickHandler={marker => {
@ -314,9 +64,7 @@ export const SceneMarkersPanel: React.FC<ISceneMarkersPanelProps> = (
onClickMarker(marker as any);
}}
/>
</>
);
}
return render();
</div>
</>
);
};

View file

@ -9,13 +9,12 @@ import {
Dropdown,
DropdownButton,
Form,
Table,
Spinner
Table
} from "react-bootstrap";
import _ from "lodash";
import { StashService } from "src/core/StashService";
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 { useToast } from "src/hooks";
import { Pagination } from "../list/Pagination";
@ -1065,7 +1064,7 @@ export const SceneFilenameParser: React.FC = () => {
<h4>Scene Filename Parser</h4>
<ParserInput input={parserInput} onFind={input => onFindClicked(input)} />
{isLoading ? <Spinner animation="border" variant="light" /> : undefined}
{isLoading && <LoadingIndicator />}
{renderTable()}
</Card>
);

View file

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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
import React, { useEffect, useState } from "react";
import { Button, ButtonGroup, Form, Spinner } from "react-bootstrap";
import { Button, Form } from "react-bootstrap";
import _ from "lodash";
import { StashService } from "src/core/StashService";
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";
interface IListOperationProps {
@ -245,7 +245,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
const itemIDs = items.map(i => i.id);
switch (type) {
case "performers":
setPerformerIds(itemIDS);
setPerformerIds(itemIDs);
break;
case "tags":
setTagIds(itemIDs);
@ -258,7 +258,7 @@ export const SceneSelectedOptions: React.FC<IListOperationProps> = (
}
if(isLoading)
return <Spinner animation="border" variant="light" />;
return <LoadingIndicator />;
function render() {
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;
button {
padding-top: 3px;
padding-bottom: 3px;
padding-top: 3px;
}
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-item {
min-width: 200px;
min-width: 240px;
}
.rating-operation {
@ -26,3 +46,62 @@
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 queryString from "query-string";
import React, { useState } from "react";
import { Spinner } from "react-bootstrap";
import { ApolloError } from "apollo-client";
import { useHistory } from "react-router-dom";
import {
@ -16,12 +15,13 @@ import {
FindStudiosQueryResult,
FindPerformersQueryResult
} from "src/core/generated-graphql";
import { ListFilter } from "../components/list/ListFilter";
import { Pagination } from "../components/list/Pagination";
import { StashService } from "../core/StashService";
import { Criterion } from "../models/list-filter/criteria/criterion";
import { ListFilterModel } from "../models/list-filter/filter";
import { DisplayMode, FilterMode } from "../models/list-filter/types";
import { LoadingIndicator } from 'src/components/Shared';
import { ListFilter } from "src/components/list/ListFilter";
import { Pagination } from "src/components/list/Pagination";
import { StashService } from "src/core/StashService";
import { Criterion } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode, FilterMode } from "src/models/list-filter/types";
interface IListHookData {
filter: ListFilterModel;
@ -294,12 +294,8 @@ const useList = <QueryResult extends IQueryResult, QueryData extends IDataItem>(
{options.renderSelectedOptions && selectedIds.size > 0
? options.renderSelectedOptions(result, selectedIds)
: undefined}
{result.loading ? (
<Spinner animation="border" variant="light" />
) : (
undefined
)}
{result.error ? <h1>{result.error.message}</h1> : undefined}
{result.loading && <LoadingIndicator />}
{result.error && <h1>{result.error.message}</h1>}
{options.renderContent(result, filter, selectedIds, zoomIndex)}
<Pagination
itemsPerPage={filter.itemsPerPage}

View file

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

View file

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

View file

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

View file

@ -19,15 +19,13 @@ $link-hover-color: #48aff0;
$text-color: #f5f8fa;
$pre-color: $text-color;
$navbar-dark-color: rgb(245, 248, 250);
$input-bg: $secondary;
$input-color: #f5f8fa;
$popover-bg: $secondary;
@import "node_modules/bootstrap/scss/bootstrap";
.btn.active:not(.disabled),
.btn.active.minimal:not(.disabled) {
background-color: rgba(138,155,168,.3);
background-color: rgba(138, 155, 168, .3);
color: #f5f8fa;
}
@ -39,23 +37,17 @@ button.minimal {
transition: none;
&:hover {
background: rgba(138,155,168,.15);
background: rgba(138, 155, 168, .15);
color: $text-color;
}
&:active {
background: rgba(138,155,168,.3);
background: rgba(138, 155, 168, .3);
color: $text-color;
}
}
input.form-control {
background-color: rgba(16, 22, 26, 0.3);
}
.form-control {
border-color: rgba(16,22,26,.4);
}
.dropdown-toggle:after {
.dropdown-toggle::after {
content: none;
}
@ -63,18 +55,40 @@ nav .svg-inline--fa {
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 {
margin: 5px 0;
}
.table {
th {
border-top: none;
}
border: none;
thead {
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 TextUtils } from "./text";
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 { PerformersCriterion } from "../models/list-filter/criteria/performers";
import { StudiosCriterion } from "../models/list-filter/criteria/studios";
import { TagsCriterion } from "../models/list-filter/criteria/tags";
import { ListFilterModel } from "../models/list-filter/filter";
import { FilterMode } from "../models/list-filter/types";
import * as GQL from "src/core/generated-graphql";
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
import { TagsCriterion } from "src/models/list-filter/criteria/tags";
import { ListFilterModel } from "src/models/list-filter/filter";
import { FilterMode } from "src/models/list-filter/types";
const makePerformerScenesUrl = (
performer: Partial<GQL.PerformerDataFragment>
@ -54,11 +54,10 @@ const makeSceneMarkerUrl = (
return `/scenes/${sceneMarker.scene.id}?t=${sceneMarker.seconds}`;
};
const Nav = {
export default {
makePerformerScenesUrl,
makeStudioScenesUrl,
makeTagSceneMarkersUrl,
makeTagScenesUrl,
makeSceneMarkerUrl
};
export default Nav;

View file

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

View file

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