mirror of
https://github.com/stashapp/stash.git
synced 2025-12-10 02:15:30 +01:00
Styling
This commit is contained in:
parent
3fa3f61d93
commit
ac3d03715f
58 changed files with 1533 additions and 1483 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()}</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
|
|
|||
23
ui/v2.5/src/components/Shared/ImageInput.tsx
Normal file
23
ui/v2.5/src/components/Shared/ImageInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
8
ui/v2.5/src/components/Studios/styles.scss
Normal file
8
ui/v2.5/src/components/Studios/styles.scss
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.studio-details {
|
||||
padding-left: 4rem;
|
||||
|
||||
.logo {
|
||||
margin: 4rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
28
ui/v2.5/src/components/Tags/styles.scss
Normal file
28
ui/v2.5/src/components/Tags/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
89
ui/v2.5/src/components/performers/styles.scss
Normal file
89
ui/v2.5/src/components/performers/styles.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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() })}
|
||||
|
|
|
|||
70
ui/v2.5/src/components/scenes/SceneDetails/PrimaryTags.tsx
Normal file
70
ui/v2.5/src/components/scenes/SceneDetails/PrimaryTags.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
177
ui/v2.5/src/components/scenes/SceneDetails/SceneMarkerForm.tsx
Normal file
177
ui/v2.5/src/components/scenes/SceneDetails/SceneMarkerForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}`}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
9
ui/v2.5/src/utils/jwplayer.ts
Normal file
9
ui/v2.5/src/utils/jwplayer.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
const playerID = "main-jwplayer";
|
||||
const getPlayer = () => (
|
||||
(window as any).jwplayer(playerID)
|
||||
)
|
||||
|
||||
export default {
|
||||
playerID,
|
||||
getPlayer
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue