mirror of
https://github.com/stashapp/stash.git
synced 2025-12-15 21:03:22 +01:00
Details page redesign (#3946)
* mobile improvements to performer page * updated remaining details pages * fixes tag page on mobile * implemented show hide for performer details * fixes card width cutoff on mobile(not related to redesign) * added background image option plus more improvements * add tooltip for age field * translate encoding message string
This commit is contained in:
parent
a665a56ef0
commit
b8e2f2a0fa
30 changed files with 2023 additions and 1022 deletions
|
|
@ -466,7 +466,7 @@ export function makeItemList<T extends QueryResult, E extends IDataItem>({
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="item-list-container">
|
||||
<ButtonToolbar className="justify-content-center">
|
||||
<ListFilter
|
||||
onFilterUpdate={updateFilter}
|
||||
|
|
|
|||
|
|
@ -359,3 +359,11 @@ input[type="range"].zoom-slider {
|
|||
.tilted {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.item-list-container {
|
||||
padding-top: 15px;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,24 @@ import { useLightbox } from "src/hooks/Lightbox/hooks";
|
|||
import { ModalComponent } from "src/components/Shared/Modal";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { MovieScenesPanel } from "./MovieScenesPanel";
|
||||
import { MovieDetailsPanel } from "./MovieDetailsPanel";
|
||||
import {
|
||||
CompressedMovieDetailsPanel,
|
||||
MovieDetailsPanel,
|
||||
} from "./MovieDetailsPanel";
|
||||
import { MovieEditPanel } from "./MovieEditPanel";
|
||||
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faChevronDown,
|
||||
faChevronUp,
|
||||
faLink,
|
||||
faTrashAlt,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { IUIConfig } from "src/core/config";
|
||||
import ImageUtils from "src/utils/image";
|
||||
import { useRatingKeybinds } from "src/hooks/keybinds";
|
||||
|
||||
interface IProps {
|
||||
movie: GQL.MovieDataFragment;
|
||||
|
|
@ -30,6 +45,16 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||
const history = useHistory();
|
||||
const Toast = useToast();
|
||||
|
||||
// Configuration settings
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
const uiConfig = configuration?.ui as IUIConfig | undefined;
|
||||
const enableBackgroundImage = uiConfig?.enableMovieBackgroundImage ?? false;
|
||||
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
|
||||
const showAllDetails = uiConfig?.showAllDetails ?? true;
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
||||
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false);
|
||||
|
||||
// Editing state
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
|
||||
|
|
@ -87,6 +112,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||
Mousetrap.bind("d d", () => {
|
||||
onDelete();
|
||||
});
|
||||
Mousetrap.bind(",", () => setCollapsed(!collapsed));
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("e");
|
||||
|
|
@ -94,6 +120,27 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||
};
|
||||
});
|
||||
|
||||
useRatingKeybinds(
|
||||
true,
|
||||
configuration?.ui?.ratingSystemOptions?.type,
|
||||
setRating
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const f = () => {
|
||||
if (document.documentElement.scrollTop <= 50) {
|
||||
setLoadStickyHeader(false);
|
||||
} else {
|
||||
setLoadStickyHeader(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", f);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", f);
|
||||
};
|
||||
});
|
||||
|
||||
async function onSave(input: GQL.MovieCreateInput) {
|
||||
await updateMovie({
|
||||
variables: {
|
||||
|
|
@ -159,6 +206,25 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||
);
|
||||
}
|
||||
|
||||
function getCollapseButtonIcon() {
|
||||
return collapsed ? faChevronDown : faChevronUp;
|
||||
}
|
||||
|
||||
function maybeRenderShowCollapseButton() {
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<span className="detail-expand-collapse">
|
||||
<Button
|
||||
className="minimal expand-collapse"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<Icon className="fa-fw" icon={getCollapseButtonIcon()} />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderFrontImage() {
|
||||
let image = movie.front_image_path;
|
||||
if (isEditing) {
|
||||
|
|
@ -174,7 +240,11 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||
if (image && defaultImage) {
|
||||
return (
|
||||
<div className="movie-image-container">
|
||||
<img alt="Front Cover" src={image} />
|
||||
<img
|
||||
alt="Front Cover"
|
||||
src={image}
|
||||
onLoad={ImageUtils.verifyImageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (image) {
|
||||
|
|
@ -184,7 +254,11 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||
variant="link"
|
||||
onClick={() => showLightbox()}
|
||||
>
|
||||
<img alt="Front Cover" src={image} />
|
||||
<img
|
||||
alt="Front Cover"
|
||||
src={image}
|
||||
onLoad={ImageUtils.verifyImageSize}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -207,62 +281,180 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||
variant="link"
|
||||
onClick={() => showLightbox(index - 1)}
|
||||
>
|
||||
<img alt="Back Cover" src={image} />
|
||||
<img
|
||||
alt="Back Cover"
|
||||
src={image}
|
||||
onLoad={ImageUtils.verifyImageSize}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const renderClickableIcons = () => (
|
||||
<span className="name-icons">
|
||||
{movie.url && (
|
||||
<Button className="minimal icon-link" title={movie.url}>
|
||||
<a
|
||||
href={TextUtils.sanitiseURL(movie.url)}
|
||||
className="link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon={faLink} />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
function maybeRenderAliases() {
|
||||
if (movie?.aliases) {
|
||||
return (
|
||||
<div>
|
||||
<span className="alias-head">{movie?.aliases}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setRating(v: number | null) {
|
||||
if (movie.id) {
|
||||
updateMovie({
|
||||
variables: {
|
||||
input: {
|
||||
id: movie.id,
|
||||
rating100: v,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const renderTabs = () => <MovieScenesPanel active={true} movie={movie} />;
|
||||
|
||||
function maybeRenderDetails() {
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<MovieDetailsPanel
|
||||
movie={movie}
|
||||
fullWidth={!collapsed && !compactExpandedDetails}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderEditPanel() {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<MovieEditPanel
|
||||
movie={movie}
|
||||
onSubmit={onSave}
|
||||
onCancel={() => toggleEditing()}
|
||||
onDelete={onDelete}
|
||||
setFrontImage={setFrontImage}
|
||||
setBackImage={setBackImage}
|
||||
setEncodingImage={setEncodingImage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
{
|
||||
return (
|
||||
<DetailsEditNavbar
|
||||
objectName={movie.name}
|
||||
isNew={false}
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={() => toggleEditing()}
|
||||
onSave={() => {}}
|
||||
onImageChange={() => {}}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderCompressedDetails() {
|
||||
if (!isEditing && loadStickyHeader) {
|
||||
return <CompressedMovieDetailsPanel movie={movie} />;
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderHeaderBackgroundImage() {
|
||||
let image = movie.front_image_path;
|
||||
if (enableBackgroundImage && !isEditing && image) {
|
||||
return (
|
||||
<div className="background-image-container">
|
||||
<picture>
|
||||
<source src={image} />
|
||||
<img
|
||||
className="background-image"
|
||||
src={image}
|
||||
alt={`${movie.name} background`}
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderTab() {
|
||||
if (!isEditing) {
|
||||
return renderTabs();
|
||||
}
|
||||
}
|
||||
|
||||
if (updating || deleting) return <LoadingIndicator />;
|
||||
|
||||
// TODO: CSS class
|
||||
return (
|
||||
<div className="row">
|
||||
<div id="movie-page" className="row">
|
||||
<Helmet>
|
||||
<title>{movie?.name}</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="movie-details mb-3 col col-xl-4 col-lg-6">
|
||||
<div className="logo w-100">
|
||||
{encodingImage ? (
|
||||
<LoadingIndicator message="Encoding image..." />
|
||||
) : (
|
||||
<div className="movie-images">
|
||||
{renderFrontImage()}
|
||||
{renderBackImage()}
|
||||
<div
|
||||
className={`detail-header ${isEditing ? "edit" : ""} ${
|
||||
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
|
||||
}`}
|
||||
>
|
||||
{maybeRenderHeaderBackgroundImage()}
|
||||
<div className="detail-container">
|
||||
<div className="detail-header-image">
|
||||
<div className="logo w-100">
|
||||
{encodingImage ? (
|
||||
<LoadingIndicator
|
||||
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
|
||||
/>
|
||||
) : (
|
||||
<div className="movie-images">
|
||||
{renderFrontImage()}
|
||||
{renderBackImage()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="movie-head col">
|
||||
<h2>
|
||||
<span className="movie-name">{movie.name}</span>
|
||||
{maybeRenderShowCollapseButton()}
|
||||
{renderClickableIcons()}
|
||||
</h2>
|
||||
{maybeRenderAliases()}
|
||||
<RatingSystem
|
||||
value={movie.rating100 ?? undefined}
|
||||
onSetRating={(value) => setRating(value ?? null)}
|
||||
/>
|
||||
{maybeRenderDetails()}
|
||||
{maybeRenderEditPanel()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<MovieDetailsPanel movie={movie} />
|
||||
{/* HACK - this is also rendered in the MovieEditPanel */}
|
||||
<DetailsEditNavbar
|
||||
objectName={movie.name}
|
||||
isNew={false}
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={() => toggleEditing()}
|
||||
onSave={() => {}}
|
||||
onImageChange={() => {}}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<MovieEditPanel
|
||||
movie={movie}
|
||||
onSubmit={onSave}
|
||||
onCancel={() => toggleEditing()}
|
||||
onDelete={onDelete}
|
||||
setFrontImage={setFrontImage}
|
||||
setBackImage={setBackImage}
|
||||
setEncodingImage={setEncodingImage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-xl-8 col-lg-6">
|
||||
<MovieScenesPanel active={true} movie={movie} />
|
||||
{maybeRenderCompressedDetails()}
|
||||
<div className="detail-body">
|
||||
<div className="movie-body">
|
||||
<div className="movie-tabs">{maybeRenderTab()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderDeleteAlert()}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ const MovieCreate: React.FC = () => {
|
|||
<div className="movie-details mb-3 col">
|
||||
<div className="logo w-100">
|
||||
{encodingImage ? (
|
||||
<LoadingIndicator message="Encoding image..." />
|
||||
<LoadingIndicator
|
||||
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
|
||||
/>
|
||||
) : (
|
||||
<div className="movie-images">
|
||||
{renderFrontImage()}
|
||||
|
|
|
|||
|
|
@ -3,82 +3,73 @@ import { useIntl } from "react-intl";
|
|||
import * as GQL from "src/core/generated-graphql";
|
||||
import DurationUtils from "src/utils/duration";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||
import { TextField, URLField } from "src/utils/field";
|
||||
import { DetailItem } from "src/components/Shared/DetailItem";
|
||||
|
||||
interface IMovieDetailsPanel {
|
||||
movie: GQL.MovieDataFragment;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
|
||||
export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({
|
||||
movie,
|
||||
fullWidth,
|
||||
}) => {
|
||||
// Network state
|
||||
const intl = useIntl();
|
||||
|
||||
function maybeRenderAliases() {
|
||||
if (movie.aliases) {
|
||||
return (
|
||||
<div>
|
||||
<span className="alias-head">
|
||||
{intl.formatMessage({ id: "also_known_as" })}{" "}
|
||||
</span>
|
||||
<span className="alias">{movie.aliases}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRatingField() {
|
||||
if (!movie.rating100) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<dt>{intl.formatMessage({ id: "rating" })}</dt>
|
||||
<dd>
|
||||
<RatingSystem value={movie.rating100} disabled />
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: CSS class
|
||||
return (
|
||||
<div className="movie-details">
|
||||
<div>
|
||||
<h2>{movie.name}</h2>
|
||||
</div>
|
||||
<div className="detail-group">
|
||||
<DetailItem
|
||||
id="duration"
|
||||
value={
|
||||
movie.duration ? DurationUtils.secondsToString(movie.duration) : ""
|
||||
}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="date"
|
||||
value={movie.date ? TextUtils.formatDate(intl, movie.date) : ""}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="studio"
|
||||
value={
|
||||
movie.studio?.id ? (
|
||||
<a href={`/studios/${movie.studio?.id}`} target="_self">
|
||||
{movie.studio?.name}
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
|
||||
{maybeRenderAliases()}
|
||||
|
||||
<dl className="details-list">
|
||||
<TextField
|
||||
id="duration"
|
||||
value={
|
||||
movie.duration ? DurationUtils.secondsToString(movie.duration) : ""
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
id="date"
|
||||
value={movie.date ? TextUtils.formatDate(intl, movie.date) : ""}
|
||||
/>
|
||||
<URLField
|
||||
id="studio"
|
||||
value={movie.studio?.name}
|
||||
url={`/studios/${movie.studio?.id}`}
|
||||
/>
|
||||
<TextField id="director" value={movie.director} />
|
||||
|
||||
{renderRatingField()}
|
||||
|
||||
<URLField
|
||||
id="url"
|
||||
value={movie.url}
|
||||
url={TextUtils.sanitiseURL(movie.url ?? "")}
|
||||
/>
|
||||
|
||||
<TextField id="synopsis" value={movie.synopsis} />
|
||||
</dl>
|
||||
<DetailItem id="director" value={movie.director} fullWidth={fullWidth} />
|
||||
<DetailItem id="synopsis" value={movie.synopsis} fullWidth={fullWidth} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CompressedMovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({
|
||||
movie,
|
||||
}) => {
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticky detail-header">
|
||||
<div className="sticky detail-header-group">
|
||||
<a className="movie-name" onClick={() => scrollToTop()}>
|
||||
{movie.name}
|
||||
</a>
|
||||
{movie?.studio?.name ? (
|
||||
<span className="movie-studio">{movie?.studio?.name}</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,14 +15,10 @@ import { URLField } from "src/components/Shared/URLField";
|
|||
import { useToast } from "src/hooks/Toast";
|
||||
import { Modal as BSModal, Form, Button, Col, Row } from "react-bootstrap";
|
||||
import DurationUtils from "src/utils/duration";
|
||||
import FormUtils from "src/utils/form";
|
||||
import ImageUtils from "src/utils/image";
|
||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||
import { useFormik } from "formik";
|
||||
import { Prompt } from "react-router-dom";
|
||||
import { MovieScrapeDialog } from "./MovieScrapeDialog";
|
||||
import { useRatingKeybinds } from "src/hooks/keybinds";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import isEqual from "lodash-es/isEqual";
|
||||
import { DateInput } from "src/components/Shared/DateInput";
|
||||
import { handleUnsavedChanges } from "src/utils/navigation";
|
||||
|
|
@ -48,7 +44,6 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
}) => {
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
const { configuration: stashConfig } = React.useContext(ConfigurationContext);
|
||||
|
||||
const isNew = movie.id === undefined;
|
||||
|
||||
|
|
@ -60,6 +55,11 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
const Scrapers = useListMovieScrapers();
|
||||
const [scrapedMovie, setScrapedMovie] = useState<GQL.ScrapedMovie>();
|
||||
|
||||
const labelXS = 3;
|
||||
const labelXL = 2;
|
||||
const fieldXS = 9;
|
||||
const fieldXL = 7;
|
||||
|
||||
const schema = yup.object({
|
||||
name: yup.string().required(),
|
||||
aliases: yup.string().ensure(),
|
||||
|
|
@ -79,7 +79,6 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
}),
|
||||
studio_id: yup.string().required().nullable(),
|
||||
director: yup.string().ensure(),
|
||||
rating100: yup.number().nullable().defined(),
|
||||
url: yup.string().ensure(),
|
||||
synopsis: yup.string().ensure(),
|
||||
front_image: yup.string().nullable().optional(),
|
||||
|
|
@ -93,7 +92,6 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
date: movie?.date ?? "",
|
||||
studio_id: movie?.studio?.id ?? null,
|
||||
director: movie?.director ?? "",
|
||||
rating100: movie?.rating100 ?? null,
|
||||
url: movie?.url ?? "",
|
||||
synopsis: movie?.synopsis ?? "",
|
||||
};
|
||||
|
|
@ -107,16 +105,6 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
onSubmit: (values) => onSave(values),
|
||||
});
|
||||
|
||||
function setRating(v: number) {
|
||||
formik.setFieldValue("rating100", v);
|
||||
}
|
||||
|
||||
useRatingKeybinds(
|
||||
true,
|
||||
stashConfig?.ui?.ratingSystemOptions?.type,
|
||||
setRating
|
||||
);
|
||||
|
||||
// set up hotkeys
|
||||
useEffect(() => {
|
||||
// Mousetrap.bind("u", (e) => {
|
||||
|
|
@ -347,10 +335,10 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
function renderTextField(field: string, title: string, placeholder?: string) {
|
||||
return (
|
||||
<Form.Group controlId={field} as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title,
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id={title} />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
placeholder={placeholder ?? title}
|
||||
|
|
@ -390,10 +378,10 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
|
||||
<Form noValidate onSubmit={formik.handleSubmit} id="movie-edit">
|
||||
<Form.Group controlId="name" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "name" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="name" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
placeholder={intl.formatMessage({ id: "name" })}
|
||||
|
|
@ -409,10 +397,10 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
{renderTextField("aliases", intl.formatMessage({ id: "aliases" }))}
|
||||
|
||||
<Form.Group controlId="duration" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "duration" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="duration" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<DurationInput
|
||||
numericValue={formik.values.duration ?? undefined}
|
||||
onValueChange={(valueAsNumber) => {
|
||||
|
|
@ -423,10 +411,10 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="date" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "date" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="date" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<DateInput
|
||||
value={formik.values.date}
|
||||
onValueChange={(value) => formik.setFieldValue("date", value)}
|
||||
|
|
@ -436,10 +424,10 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="studio" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "studio" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="studio" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<StudioSelect
|
||||
onSelect={(items) =>
|
||||
formik.setFieldValue(
|
||||
|
|
@ -454,24 +442,11 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
|
||||
{renderTextField("director", intl.formatMessage({ id: "director" }))}
|
||||
|
||||
<Form.Group controlId="rating" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "rating" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<RatingSystem
|
||||
value={formik.values.rating100 ?? undefined}
|
||||
onSetRating={(value) =>
|
||||
formik.setFieldValue("rating100", value ?? null)
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="url" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "url" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="url" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<URLField
|
||||
{...formik.getFieldProps("url")}
|
||||
onScrapeClick={onScrapeMovieURL}
|
||||
|
|
@ -481,10 +456,10 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="synopsis" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "synopsis" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="synopsis" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
className="text-input"
|
||||
|
|
@ -498,6 +473,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
<DetailsEditNavbar
|
||||
objectName={movie?.name ?? intl.formatMessage({ id: "movie" })}
|
||||
isNew={isNew}
|
||||
classNames="col-xl-9 mt-3"
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={onCancel}
|
||||
onSave={formik.handleSubmit}
|
||||
|
|
|
|||
|
|
@ -28,12 +28,10 @@
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-evenly;
|
||||
margin: 1rem;
|
||||
max-width: 100%;
|
||||
|
||||
.movie-image-container {
|
||||
box-shadow: none;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
img {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Tabs, Tab, Col, Row } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useParams, useHistory } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
import cx from "classnames";
|
||||
|
|
@ -13,7 +13,6 @@ import {
|
|||
mutateMetadataAutoTag,
|
||||
} from "src/core/StashService";
|
||||
import { Counter } from "src/components/Shared/Counter";
|
||||
import { CountryFlag } from "src/components/Shared/CountryFlag";
|
||||
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
|
||||
import { ErrorMessage } from "src/components/Shared/ErrorMessage";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
|
|
@ -23,7 +22,10 @@ import { useToast } from "src/hooks/Toast";
|
|||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||
import { PerformerDetailsPanel } from "./PerformerDetailsPanel";
|
||||
import {
|
||||
CompressedPerformerDetailsPanel,
|
||||
PerformerDetailsPanel,
|
||||
} from "./PerformerDetailsPanel";
|
||||
import { PerformerScenesPanel } from "./PerformerScenesPanel";
|
||||
import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel";
|
||||
import { PerformerMoviesPanel } from "./PerformerMoviesPanel";
|
||||
|
|
@ -31,16 +33,16 @@ import { PerformerImagesPanel } from "./PerformerImagesPanel";
|
|||
import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel";
|
||||
import { PerformerEditPanel } from "./PerformerEditPanel";
|
||||
import { PerformerSubmitButton } from "./PerformerSubmitButton";
|
||||
import GenderIcon from "../GenderIcon";
|
||||
import {
|
||||
faChevronDown,
|
||||
faChevronUp,
|
||||
faHeart,
|
||||
faLink,
|
||||
faChevronRight,
|
||||
faChevronLeft,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons";
|
||||
import { IUIConfig } from "src/core/config";
|
||||
import { useRatingKeybinds } from "src/hooks/keybinds";
|
||||
import ImageUtils from "src/utils/image";
|
||||
|
||||
interface IProps {
|
||||
performer: GQL.PerformerDataFragment;
|
||||
|
|
@ -55,16 +57,20 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||
const intl = useIntl();
|
||||
const { tab = "details" } = useParams<IPerformerParams>();
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// Configuration settings
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
const abbreviateCounter =
|
||||
(configuration?.ui as IUIConfig)?.abbreviateCounters ?? false;
|
||||
const uiConfig = configuration?.ui as IUIConfig | undefined;
|
||||
const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;
|
||||
const enableBackgroundImage =
|
||||
uiConfig?.enablePerformerBackgroundImage ?? false;
|
||||
const showAllDetails = uiConfig?.showAllDetails ?? false;
|
||||
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [image, setImage] = useState<string | null>();
|
||||
const [encodingImage, setEncodingImage] = useState<boolean>(false);
|
||||
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false);
|
||||
|
||||
const activeImage = useMemo(() => {
|
||||
const performerImage = performer.image_path;
|
||||
|
|
@ -99,10 +105,10 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||
tab === "movies" ||
|
||||
tab == "appearswith"
|
||||
? tab
|
||||
: "details";
|
||||
: "scenes";
|
||||
const setActiveTabKey = (newTab: string | null) => {
|
||||
if (tab !== newTab) {
|
||||
const tabParam = newTab === "details" ? "" : `/${newTab}`;
|
||||
const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
|
||||
history.replace(`/performers/${performer.id}${tabParam}`);
|
||||
}
|
||||
};
|
||||
|
|
@ -126,7 +132,6 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||
|
||||
// set up hotkeys
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("a", () => setActiveTabKey("details"));
|
||||
Mousetrap.bind("e", () => toggleEditing());
|
||||
Mousetrap.bind("c", () => setActiveTabKey("scenes"));
|
||||
Mousetrap.bind("g", () => setActiveTabKey("galleries"));
|
||||
|
|
@ -186,44 +191,24 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||
if (activeImage) {
|
||||
return (
|
||||
<Button variant="link" onClick={() => showLightbox()}>
|
||||
<img className="performer" src={activeImage} alt={performer.name} />
|
||||
<img
|
||||
className="performer"
|
||||
src={activeImage}
|
||||
alt={performer.name}
|
||||
onLoad={ImageUtils.verifyImageSize}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
const renderTabs = () => (
|
||||
<React.Fragment>
|
||||
<Col>
|
||||
<Row xs={8}>
|
||||
<DetailsEditNavbar
|
||||
objectName={
|
||||
performer?.name ?? intl.formatMessage({ id: "performer" })
|
||||
}
|
||||
onToggleEdit={() => toggleEditing()}
|
||||
onDelete={onDelete}
|
||||
onAutoTag={onAutoTag}
|
||||
isNew={false}
|
||||
isEditing={false}
|
||||
onSave={() => {}}
|
||||
onImageChange={() => {}}
|
||||
classNames="mb-2"
|
||||
customButtons={
|
||||
<div>
|
||||
<PerformerSubmitButton performer={performer} />
|
||||
</div>
|
||||
}
|
||||
></DetailsEditNavbar>
|
||||
</Row>
|
||||
</Col>
|
||||
<Tabs
|
||||
activeKey={activeTabKey}
|
||||
onSelect={setActiveTabKey}
|
||||
id="performer-details"
|
||||
unmountOnExit
|
||||
>
|
||||
<Tab eventKey="details" title={intl.formatMessage({ id: "details" })}>
|
||||
<PerformerDetailsPanel performer={performer} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="scenes"
|
||||
title={
|
||||
|
|
@ -318,7 +303,24 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||
</React.Fragment>
|
||||
);
|
||||
|
||||
function renderTabsOrEditPanel() {
|
||||
function maybeRenderHeaderBackgroundImage() {
|
||||
if (enableBackgroundImage && !isEditing && activeImage) {
|
||||
return (
|
||||
<div className="background-image-container">
|
||||
<picture>
|
||||
<source src={activeImage} />
|
||||
<img
|
||||
className="background-image"
|
||||
src={activeImage}
|
||||
alt={`${performer.name} background`}
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderEditPanel() {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<PerformerEditPanel
|
||||
|
|
@ -330,37 +332,83 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||
setEncodingImage={setEncodingImage}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return renderTabs();
|
||||
}
|
||||
{
|
||||
return (
|
||||
<Col>
|
||||
<Row xs={8}>
|
||||
<DetailsEditNavbar
|
||||
objectName={
|
||||
performer?.name ?? intl.formatMessage({ id: "performer" })
|
||||
}
|
||||
onToggleEdit={() => toggleEditing()}
|
||||
onDelete={onDelete}
|
||||
onAutoTag={onAutoTag}
|
||||
isNew={false}
|
||||
isEditing={false}
|
||||
onSave={() => {}}
|
||||
onImageChange={() => {}}
|
||||
classNames="mb-2"
|
||||
customButtons={
|
||||
<div>
|
||||
<PerformerSubmitButton performer={performer} />
|
||||
</div>
|
||||
}
|
||||
></DetailsEditNavbar>
|
||||
</Row>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderAge() {
|
||||
if (performer?.birthdate) {
|
||||
// calculate the age from birthdate. In future, this should probably be
|
||||
// provided by the server
|
||||
function getCollapseButtonIcon() {
|
||||
return collapsed ? faChevronDown : faChevronUp;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const f = () => {
|
||||
if (document.documentElement.scrollTop <= 50) {
|
||||
setLoadStickyHeader(false);
|
||||
} else {
|
||||
setLoadStickyHeader(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", f);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", f);
|
||||
};
|
||||
});
|
||||
|
||||
function maybeRenderDetails() {
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<div>
|
||||
<span className="age">
|
||||
{TextUtils.age(performer.birthdate, performer.death_date)}
|
||||
</span>
|
||||
<span className="age-tail">
|
||||
{" "}
|
||||
<FormattedMessage id="years_old" />
|
||||
</span>
|
||||
</div>
|
||||
<PerformerDetailsPanel
|
||||
performer={performer}
|
||||
collapsed={collapsed}
|
||||
fullWidth={!collapsed && !compactExpandedDetails}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderCompressedDetails() {
|
||||
if (!isEditing && loadStickyHeader) {
|
||||
return <CompressedPerformerDetailsPanel performer={performer} />;
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderTab() {
|
||||
if (!isEditing) {
|
||||
return renderTabs();
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderAliases() {
|
||||
if (performer?.alias_list?.length) {
|
||||
return (
|
||||
<div>
|
||||
<span className="alias-head">
|
||||
<FormattedMessage id="also_known_as" />{" "}
|
||||
</span>
|
||||
<span className="alias">{performer.alias_list?.join(", ")}</span>
|
||||
<span className="alias-head">{performer.alias_list?.join(", ")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -392,61 +440,95 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||
}
|
||||
}
|
||||
|
||||
const renderClickableIcons = () => (
|
||||
<span className="name-icons">
|
||||
<Button
|
||||
className={cx(
|
||||
"minimal",
|
||||
performer.favorite ? "favorite" : "not-favorite"
|
||||
function maybeRenderShowCollapseButton() {
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<span className="detail-expand-collapse">
|
||||
<Button
|
||||
className="minimal expand-collapse"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<Icon className="fa-fw" icon={getCollapseButtonIcon()} />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderClickableIcons() {
|
||||
/* Collect urls adding into details */
|
||||
/* This code can be removed once multple urls are supported for performers */
|
||||
const detailURLsRegex = /\[((?:http|www\.)[^\n\]]+)\]/gm;
|
||||
let urls = performer?.details?.match(detailURLsRegex);
|
||||
|
||||
return (
|
||||
<span className="name-icons">
|
||||
<Button
|
||||
className={cx(
|
||||
"minimal",
|
||||
performer.favorite ? "favorite" : "not-favorite"
|
||||
)}
|
||||
onClick={() => setFavorite(!performer.favorite)}
|
||||
>
|
||||
<Icon icon={faHeart} />
|
||||
</Button>
|
||||
{performer.url && (
|
||||
<Button className="minimal icon-link" title={performer.url}>
|
||||
<a
|
||||
href={TextUtils.sanitiseURL(performer.url)}
|
||||
className="link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon={faLink} />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
onClick={() => setFavorite(!performer.favorite)}
|
||||
>
|
||||
<Icon icon={faHeart} />
|
||||
</Button>
|
||||
{performer.url && (
|
||||
<Button className="minimal icon-link">
|
||||
<a
|
||||
href={TextUtils.sanitiseURL(performer.url)}
|
||||
className="link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon={faLink} />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{performer.twitter && (
|
||||
<Button className="minimal icon-link">
|
||||
<a
|
||||
href={TextUtils.sanitiseURL(
|
||||
performer.twitter,
|
||||
TextUtils.twitterURL
|
||||
)}
|
||||
className="twitter"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon={faTwitter} />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{performer.instagram && (
|
||||
<Button className="minimal icon-link">
|
||||
<a
|
||||
href={TextUtils.sanitiseURL(
|
||||
performer.instagram,
|
||||
TextUtils.instagramURL
|
||||
)}
|
||||
className="instagram"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon={faInstagram} />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
{(urls ?? []).map((url, index) => (
|
||||
<Button key={index} className="minimal icon-link" title={url}>
|
||||
<a
|
||||
href={TextUtils.sanitiseURL(url)}
|
||||
className={`detail-link ${index}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon={faLink} />
|
||||
</a>
|
||||
</Button>
|
||||
))}
|
||||
{performer.twitter && (
|
||||
<Button className="minimal icon-link" title={performer.twitter}>
|
||||
<a
|
||||
href={TextUtils.sanitiseURL(
|
||||
performer.twitter,
|
||||
TextUtils.twitterURL
|
||||
)}
|
||||
className="twitter"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon={faTwitter} />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{performer.instagram && (
|
||||
<Button className="minimal icon-link" title={performer.instagram}>
|
||||
<a
|
||||
href={TextUtils.sanitiseURL(
|
||||
performer.instagram,
|
||||
TextUtils.instagramURL
|
||||
)}
|
||||
className="instagram"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon={faInstagram} />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDestroying)
|
||||
return (
|
||||
|
|
@ -455,10 +537,6 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||
/>
|
||||
);
|
||||
|
||||
function getCollapseButtonIcon() {
|
||||
return collapsed ? faChevronRight : faChevronLeft;
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="performer-page" className="row">
|
||||
<Helmet>
|
||||
|
|
@ -466,48 +544,48 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
|||
</Helmet>
|
||||
|
||||
<div
|
||||
className={`performer-image-container details-tab text-center text-center ${
|
||||
collapsed ? "collapsed" : ""
|
||||
className={`detail-header ${isEditing ? "edit" : ""} ${
|
||||
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
|
||||
}`}
|
||||
>
|
||||
{encodingImage ? (
|
||||
<LoadingIndicator message="Encoding image..." />
|
||||
) : (
|
||||
renderImage()
|
||||
)}
|
||||
</div>
|
||||
<div className="details-divider d-none d-xl-block">
|
||||
<Button onClick={() => setCollapsed(!collapsed)}>
|
||||
<Icon className="fa-fw" icon={getCollapseButtonIcon()} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`content-container ${collapsed ? "expanded" : ""}`}>
|
||||
<div className="row">
|
||||
<div className="performer-head col">
|
||||
<h2>
|
||||
<GenderIcon
|
||||
gender={performer.gender}
|
||||
className="gender-icon mr-2 fi"
|
||||
{maybeRenderHeaderBackgroundImage()}
|
||||
<div className="detail-container">
|
||||
<div className="detail-header-image">
|
||||
{encodingImage ? (
|
||||
<LoadingIndicator
|
||||
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
|
||||
/>
|
||||
<CountryFlag country={performer.country} className="mr-2" />
|
||||
<span className="performer-name">{performer.name}</span>
|
||||
{performer.disambiguation && (
|
||||
<span className="performer-disambiguation">
|
||||
{` (${performer.disambiguation})`}
|
||||
</span>
|
||||
)}
|
||||
{renderClickableIcons()}
|
||||
</h2>
|
||||
<RatingSystem
|
||||
value={performer.rating100 ?? undefined}
|
||||
onSetRating={(value) => setRating(value ?? null)}
|
||||
/>
|
||||
{maybeRenderAliases()}
|
||||
{maybeRenderAge()}
|
||||
) : (
|
||||
renderImage()
|
||||
)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="performer-head col">
|
||||
<h2>
|
||||
<span className="performer-name">{performer.name}</span>
|
||||
{performer.disambiguation && (
|
||||
<span className="performer-disambiguation">
|
||||
{` (${performer.disambiguation})`}
|
||||
</span>
|
||||
)}
|
||||
{maybeRenderShowCollapseButton()}
|
||||
{renderClickableIcons()}
|
||||
</h2>
|
||||
{maybeRenderAliases()}
|
||||
<RatingSystem
|
||||
value={performer.rating100 ?? undefined}
|
||||
onSetRating={(value) => setRating(value ?? null)}
|
||||
/>
|
||||
{maybeRenderDetails()}
|
||||
{maybeRenderEditPanel()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{maybeRenderCompressedDetails()}
|
||||
<div className="detail-body">
|
||||
<div className="performer-body">
|
||||
<div className="performer-tabs">{renderTabsOrEditPanel()}</div>
|
||||
<div className="performer-tabs">{maybeRenderTab()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,11 @@ const PerformerCreate: React.FC = () => {
|
|||
|
||||
function renderPerformerImage() {
|
||||
if (encodingImage) {
|
||||
return <LoadingIndicator message="Encoding image..." />;
|
||||
return (
|
||||
<LoadingIndicator
|
||||
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (image) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
import React from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { useIntl } from "react-intl";
|
||||
import { TagLink } from "src/components/Shared/TagLink";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { getStashboxBase } from "src/utils/stashbox";
|
||||
import { getCountryByISO } from "src/utils/country";
|
||||
import { TextField, URLField } from "src/utils/field";
|
||||
import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units";
|
||||
import { DetailItem } from "src/components/Shared/DetailItem";
|
||||
import { CountryFlag } from "src/components/Shared/CountryFlag";
|
||||
|
||||
interface IPerformerDetails {
|
||||
performer: GQL.PerformerDataFragment;
|
||||
collapsed?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
||||
performer,
|
||||
collapsed,
|
||||
fullWidth,
|
||||
}) => {
|
||||
// Network state
|
||||
const intl = useIntl();
|
||||
|
|
@ -22,20 +26,12 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||
if (!performer.tags.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<dt>
|
||||
<FormattedMessage id="tags" />
|
||||
</dt>
|
||||
<dd>
|
||||
<ul className="pl-0">
|
||||
{(performer.tags ?? []).map((tag) => (
|
||||
<TagLink key={tag.id} tagType="performer" tag={tag} />
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</>
|
||||
<ul className="pl-0">
|
||||
{(performer.tags ?? []).map((tag) => (
|
||||
<TagLink key={tag.id} tagType="performer" tag={tag} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -45,32 +41,27 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<dt>StashIDs</dt>
|
||||
<dd>
|
||||
<ul className="pl-0">
|
||||
{performer.stash_ids.map((stashID) => {
|
||||
const base = getStashboxBase(stashID.endpoint);
|
||||
const link = base ? (
|
||||
<a
|
||||
href={`${base}performers/${stashID.stash_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{stashID.stash_id}
|
||||
</a>
|
||||
) : (
|
||||
stashID.stash_id
|
||||
);
|
||||
return (
|
||||
<li key={stashID.stash_id} className="row no-gutters">
|
||||
{link}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</dd>
|
||||
</>
|
||||
<ul className="pl-0">
|
||||
{performer.stash_ids.map((stashID) => {
|
||||
const base = getStashboxBase(stashID.endpoint);
|
||||
const link = base ? (
|
||||
<a
|
||||
href={`${base}performers/${stashID.stash_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{stashID.stash_id}
|
||||
</a>
|
||||
) : (
|
||||
stashID.stash_id
|
||||
);
|
||||
return (
|
||||
<li key={stashID.stash_id} className="row no-gutters">
|
||||
{link}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -176,92 +167,169 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||
);
|
||||
};
|
||||
|
||||
function maybeRenderExtraDetails() {
|
||||
if (!collapsed) {
|
||||
/* Remove extra urls provided in details since they will be present by perfomr name */
|
||||
/* This code can be removed once multple urls are supported for performers */
|
||||
let details = performer?.details
|
||||
?.replace(/\[((?:http|www\.)[^\n\]]+)\]/gm, "")
|
||||
.trim();
|
||||
return (
|
||||
<>
|
||||
<DetailItem
|
||||
id="tattoos"
|
||||
value={performer?.tattoos}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="piercings"
|
||||
value={performer?.piercings}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem id="details" value={details} fullWidth={fullWidth} />
|
||||
<DetailItem
|
||||
id="tags"
|
||||
value={renderTagsField()}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="StashIDs"
|
||||
value={renderStashIDs()}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className="details-list">
|
||||
<TextField
|
||||
id="gender"
|
||||
value={
|
||||
performer.gender
|
||||
? intl.formatMessage({ id: "gender_types." + performer.gender })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
id="birthdate"
|
||||
value={TextUtils.formatDate(intl, performer.birthdate ?? undefined)}
|
||||
/>
|
||||
<TextField
|
||||
id="death_date"
|
||||
value={TextUtils.formatDate(intl, performer.death_date ?? undefined)}
|
||||
/>
|
||||
<TextField id="ethnicity" value={performer.ethnicity} />
|
||||
<TextField id="hair_color" value={performer.hair_color} />
|
||||
<TextField id="eye_color" value={performer.eye_color} />
|
||||
<TextField
|
||||
id="country"
|
||||
value={
|
||||
getCountryByISO(performer.country, intl.locale) ?? performer.country
|
||||
}
|
||||
/>
|
||||
|
||||
{!!performer.height_cm && (
|
||||
<>
|
||||
<dt>
|
||||
<FormattedMessage id="height" />
|
||||
</dt>
|
||||
<dd>{formatHeight(performer.height_cm)}</dd>
|
||||
</>
|
||||
<div className="detail-group">
|
||||
{performer.gender ? (
|
||||
<DetailItem
|
||||
id="gender"
|
||||
value={intl.formatMessage({ id: "gender_types." + performer.gender })}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
{!!performer.weight && (
|
||||
<>
|
||||
<dt>
|
||||
<FormattedMessage id="weight" />
|
||||
</dt>
|
||||
<dd>{formatWeight(performer.weight)}</dd>
|
||||
</>
|
||||
<DetailItem
|
||||
id="age"
|
||||
value={TextUtils.age(performer.birthdate, performer.death_date)}
|
||||
title={TextUtils.formatDate(intl, performer.birthdate ?? undefined)}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem id="death_date" value={performer.death_date} />
|
||||
{performer.country ? (
|
||||
<DetailItem
|
||||
id="country"
|
||||
value={
|
||||
<CountryFlag
|
||||
country={performer.country}
|
||||
className="mr-2"
|
||||
includeName={true}
|
||||
/>
|
||||
}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
{(performer.penis_length || performer.circumcised) && (
|
||||
<>
|
||||
<dt>
|
||||
<FormattedMessage id="penis" />:
|
||||
</dt>
|
||||
<dd>
|
||||
{formatPenisLength(performer.penis_length)}
|
||||
{formatCircumcised(performer.circumcised)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<TextField id="measurements" value={performer.measurements} />
|
||||
<TextField id="fake_tits" value={performer.fake_tits} />
|
||||
<TextField id="career_length" value={performer.career_length} />
|
||||
<TextField id="tattoos" value={performer.tattoos} />
|
||||
<TextField id="piercings" value={performer.piercings} />
|
||||
<TextField id="details" value={performer.details} />
|
||||
<URLField
|
||||
id="url"
|
||||
value={performer.url}
|
||||
url={TextUtils.sanitiseURL(performer.url ?? "")}
|
||||
<DetailItem
|
||||
id="ethnicity"
|
||||
value={performer?.ethnicity}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<URLField
|
||||
id="twitter"
|
||||
value={performer.twitter}
|
||||
url={TextUtils.sanitiseURL(
|
||||
performer.twitter ?? "",
|
||||
TextUtils.twitterURL
|
||||
)}
|
||||
<DetailItem
|
||||
id="hair_color"
|
||||
value={performer?.hair_color}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<URLField
|
||||
id="instagram"
|
||||
value={performer.instagram}
|
||||
url={TextUtils.sanitiseURL(
|
||||
performer.instagram ?? "",
|
||||
TextUtils.instagramURL
|
||||
)}
|
||||
<DetailItem
|
||||
id="eye_color"
|
||||
value={performer?.eye_color}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
{renderTagsField()}
|
||||
{renderStashIDs()}
|
||||
</dl>
|
||||
<DetailItem
|
||||
id="height"
|
||||
value={formatHeight(performer.height_cm)}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="weight"
|
||||
value={formatWeight(performer.weight)}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="penis_length"
|
||||
value={formatPenisLength(performer.penis_length)}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="circumcised"
|
||||
value={formatCircumcised(performer.circumcised)}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="measurements"
|
||||
value={performer?.measurements}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="fake_tits"
|
||||
value={performer?.fake_tits}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
{maybeRenderExtraDetails()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CompressedPerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
||||
performer,
|
||||
}) => {
|
||||
// Network state
|
||||
const intl = useIntl();
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticky detail-header">
|
||||
<div className="sticky detail-header-group">
|
||||
<a className="performer-name" onClick={() => scrollToTop()}>
|
||||
{performer.name}
|
||||
</a>
|
||||
{performer.gender ? (
|
||||
<span className="performer-gender">
|
||||
{intl.formatMessage({ id: "gender_types." + performer.gender })}
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{performer.birthdate ? (
|
||||
<span
|
||||
className="performer-age"
|
||||
title={TextUtils.formatDate(intl, performer.birthdate ?? undefined)}
|
||||
>
|
||||
{TextUtils.age(performer.birthdate, performer.death_date)}
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{performer.country ? (
|
||||
<span className="performer-country">
|
||||
<CountryFlag
|
||||
country={performer.country}
|
||||
className="mr-2"
|
||||
includeName={true}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,12 +16,9 @@
|
|||
|
||||
.performer-head {
|
||||
display: inline-block;
|
||||
margin-bottom: 2rem;
|
||||
vertical-align: top;
|
||||
|
||||
.name-icons {
|
||||
margin-left: 10px;
|
||||
|
||||
.not-favorite {
|
||||
color: rgba(191, 204, 214, 0.5);
|
||||
}
|
||||
|
|
@ -213,4 +210,5 @@
|
|||
/* stylelint-disable */
|
||||
font-size: 0.875em;
|
||||
/* stylelint-enable */
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -683,7 +683,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
|
||||
const image = useMemo(() => {
|
||||
if (encodingImage) {
|
||||
return <LoadingIndicator message="Encoding image..." />;
|
||||
return (
|
||||
<LoadingIndicator
|
||||
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (coverImagePreview) {
|
||||
|
|
|
|||
|
|
@ -493,6 +493,64 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||
/>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection headingID="config.ui.detail.heading">
|
||||
<div className="setting-group">
|
||||
<div className="setting">
|
||||
<div>
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
id: "config.ui.detail.enable_background_image.heading",
|
||||
})}
|
||||
</h3>
|
||||
<div className="sub-heading">
|
||||
{intl.formatMessage({
|
||||
id: "config.ui.detail.enable_background_image.description",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<BooleanSetting
|
||||
id="enableMovieBackgroundImage"
|
||||
headingID="movie"
|
||||
checked={ui.enableMovieBackgroundImage ?? undefined}
|
||||
onChange={(v) => saveUI({ enableMovieBackgroundImage: v })}
|
||||
/>
|
||||
<BooleanSetting
|
||||
id="enablePerformerBackgroundImage"
|
||||
headingID="performer"
|
||||
checked={ui.enablePerformerBackgroundImage ?? undefined}
|
||||
onChange={(v) => saveUI({ enablePerformerBackgroundImage: v })}
|
||||
/>
|
||||
<BooleanSetting
|
||||
id="enableStudioBackgroundImage"
|
||||
headingID="studio"
|
||||
checked={ui.enableStudioBackgroundImage ?? undefined}
|
||||
onChange={(v) => saveUI({ enableStudioBackgroundImage: v })}
|
||||
/>
|
||||
<BooleanSetting
|
||||
id="enableTagBackgroundImage"
|
||||
headingID="tag"
|
||||
checked={ui.enableTagBackgroundImage ?? undefined}
|
||||
onChange={(v) => saveUI({ enableTagBackgroundImage: v })}
|
||||
/>
|
||||
</div>
|
||||
<BooleanSetting
|
||||
id="show_all_details"
|
||||
headingID="config.ui.detail.show_all_details.heading"
|
||||
subHeadingID="config.ui.detail.show_all_details.description"
|
||||
checked={ui.showAllDetails ?? true}
|
||||
onChange={(v) => saveUI({ showAllDetails: v })}
|
||||
/>
|
||||
<BooleanSetting
|
||||
id="compact_expanded_details"
|
||||
headingID="config.ui.detail.compact_expanded_details.heading"
|
||||
subHeadingID="config.ui.detail.compact_expanded_details.description"
|
||||
checked={ui.compactExpandedDetails ?? undefined}
|
||||
onChange={(v) => saveUI({ compactExpandedDetails: v })}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection headingID="config.ui.editing.heading">
|
||||
<div className="setting-group">
|
||||
<div className="setting">
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import { getCountryByISO } from "src/utils/country";
|
|||
interface ICountryFlag {
|
||||
country?: string | null;
|
||||
className?: string;
|
||||
includeName?: boolean;
|
||||
}
|
||||
|
||||
export const CountryFlag: React.FC<ICountryFlag> = ({
|
||||
className,
|
||||
country: isoCountry,
|
||||
includeName,
|
||||
}) => {
|
||||
const { locale } = useIntl();
|
||||
|
||||
|
|
@ -18,9 +20,12 @@ export const CountryFlag: React.FC<ICountryFlag> = ({
|
|||
if (!isoCountry || !country) return <></>;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`${className ?? ""} fi fi-${isoCountry.toLowerCase()}`}
|
||||
title={country}
|
||||
/>
|
||||
<>
|
||||
{includeName ? country : ""}
|
||||
<span
|
||||
className={`${className ?? ""} fi fi-${isoCountry.toLowerCase()}`}
|
||||
title={country}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
39
ui/v2.5/src/components/Shared/DetailItem.tsx
Normal file
39
ui/v2.5/src/components/Shared/DetailItem.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import React from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
interface IDetailItem {
|
||||
id?: string | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value?: any;
|
||||
title?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const DetailItem: React.FC<IDetailItem> = ({
|
||||
id,
|
||||
value,
|
||||
title,
|
||||
fullWidth,
|
||||
}) => {
|
||||
if (!id || !value || value === "Na") {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const message = <FormattedMessage id={id} />;
|
||||
|
||||
return (
|
||||
// according to linter rule CSS classes shouldn't use underscores
|
||||
<div className={`detail-item ${id}`}>
|
||||
<span className={`detail-item-title ${id.replace("_", "-")}`}>
|
||||
{message}
|
||||
{fullWidth ? ":" : ""}
|
||||
</span>
|
||||
<span
|
||||
className={`detail-item-value ${id.replace("_", "-")}`}
|
||||
title={title}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -35,6 +35,7 @@ export const GridCard: React.FC<ICardProps> = (props: ICardProps) => {
|
|||
props.onSelectedChanged(!props.selected, shiftKey);
|
||||
event.preventDefault();
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function handleDrag(event: React.DragEvent<HTMLElement>) {
|
||||
|
|
|
|||
|
|
@ -46,11 +46,11 @@
|
|||
margin-right: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
div:nth-last-child(2) {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
}
|
||||
.detail-header.edit .details-edit div:nth-last-child(2) {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.select-suggest {
|
||||
|
|
|
|||
|
|
@ -26,14 +26,22 @@ import { StudioImagesPanel } from "./StudioImagesPanel";
|
|||
import { StudioChildrenPanel } from "./StudioChildrenPanel";
|
||||
import { StudioPerformersPanel } from "./StudioPerformersPanel";
|
||||
import { StudioEditPanel } from "./StudioEditPanel";
|
||||
import { StudioDetailsPanel } from "./StudioDetailsPanel";
|
||||
import {
|
||||
CompressedStudioDetailsPanel,
|
||||
StudioDetailsPanel,
|
||||
} from "./StudioDetailsPanel";
|
||||
import { StudioMoviesPanel } from "./StudioMoviesPanel";
|
||||
import {
|
||||
faTrashAlt,
|
||||
faChevronRight,
|
||||
faChevronLeft,
|
||||
faLink,
|
||||
faChevronDown,
|
||||
faChevronUp,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { IUIConfig } from "src/core/config";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||
import ImageUtils from "src/utils/image";
|
||||
import { useRatingKeybinds } from "src/hooks/keybinds";
|
||||
|
||||
interface IProps {
|
||||
studio: GQL.StudioDataFragment;
|
||||
|
|
@ -49,12 +57,16 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
|||
const intl = useIntl();
|
||||
const { tab = "details" } = useParams<IStudioParams>();
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// Configuration settings
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
const abbreviateCounter =
|
||||
(configuration?.ui as IUIConfig)?.abbreviateCounters ?? false;
|
||||
const uiConfig = configuration?.ui as IUIConfig | undefined;
|
||||
const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;
|
||||
const enableBackgroundImage = uiConfig?.enableStudioBackgroundImage ?? false;
|
||||
const showAllDetails = uiConfig?.showAllDetails ?? false;
|
||||
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
||||
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false);
|
||||
|
||||
// Editing state
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
|
|
@ -95,6 +107,27 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
|||
};
|
||||
});
|
||||
|
||||
useRatingKeybinds(
|
||||
true,
|
||||
configuration?.ui?.ratingSystemOptions?.type,
|
||||
setRating
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const f = () => {
|
||||
if (document.documentElement.scrollTop <= 50) {
|
||||
setLoadStickyHeader(false);
|
||||
} else {
|
||||
setLoadStickyHeader(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", f);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", f);
|
||||
};
|
||||
});
|
||||
|
||||
async function onSave(input: GQL.StudioCreateInput) {
|
||||
await updateStudio({
|
||||
variables: {
|
||||
|
|
@ -162,6 +195,20 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
|||
);
|
||||
}
|
||||
|
||||
function maybeRenderAliases() {
|
||||
if (studio?.aliases?.length) {
|
||||
return (
|
||||
<div>
|
||||
<span className="alias-head">{studio?.aliases?.join(", ")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getCollapseButtonIcon() {
|
||||
return collapsed ? faChevronDown : faChevronUp;
|
||||
}
|
||||
|
||||
function toggleEditing(value?: boolean) {
|
||||
if (value !== undefined) {
|
||||
setIsEditing(value);
|
||||
|
|
@ -184,7 +231,14 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
|||
}
|
||||
|
||||
if (studioImage) {
|
||||
return <img className="logo" alt={studio.name} src={studioImage} />;
|
||||
return (
|
||||
<img
|
||||
className="logo"
|
||||
alt={studio.name}
|
||||
src={studioImage}
|
||||
onLoad={ImageUtils.verifyImageSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -203,175 +257,289 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
|
|||
}
|
||||
};
|
||||
|
||||
function getCollapseButtonIcon() {
|
||||
return collapsed ? faChevronRight : faChevronLeft;
|
||||
const renderClickableIcons = () => (
|
||||
<span className="name-icons">
|
||||
{studio.url && (
|
||||
<Button className="minimal icon-link" title={studio.url}>
|
||||
<a
|
||||
href={TextUtils.sanitiseURL(studio.url)}
|
||||
className="link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon={faLink} />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
function setRating(v: number | null) {
|
||||
if (studio.id) {
|
||||
updateStudio({
|
||||
variables: {
|
||||
input: {
|
||||
id: studio.id,
|
||||
rating100: v,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderDetails() {
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<StudioDetailsPanel
|
||||
studio={studio}
|
||||
collapsed={collapsed}
|
||||
fullWidth={!collapsed && !compactExpandedDetails}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderShowCollapseButton() {
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<span className="detail-expand-collapse">
|
||||
<Button
|
||||
className="minimal expand-collapse"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<Icon className="fa-fw" icon={getCollapseButtonIcon()} />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderCompressedDetails() {
|
||||
if (!isEditing && loadStickyHeader) {
|
||||
return <CompressedStudioDetailsPanel studio={studio} />;
|
||||
}
|
||||
}
|
||||
|
||||
const renderTabs = () => (
|
||||
<React.Fragment>
|
||||
<Tabs
|
||||
id="studio-tabs"
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
activeKey={activeTabKey}
|
||||
onSelect={setActiveTabKey}
|
||||
>
|
||||
<Tab
|
||||
eventKey="scenes"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "scenes" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={sceneCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioScenesPanel
|
||||
active={activeTabKey == "scenes"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="galleries"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "galleries" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={galleryCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioGalleriesPanel
|
||||
active={activeTabKey == "galleries"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="images"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "images" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={imageCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioImagesPanel
|
||||
active={activeTabKey == "images"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="performers"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "performers" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performerCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioPerformersPanel
|
||||
active={activeTabKey == "performers"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="movies"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "movies" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={movieCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioMoviesPanel
|
||||
active={activeTabKey == "movies"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="childstudios"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "subsidiary_studios" })}
|
||||
<Counter
|
||||
abbreviateCounter={false}
|
||||
count={studio.child_studios.length}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioChildrenPanel
|
||||
active={activeTabKey == "childstudios"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
function maybeRenderHeaderBackgroundImage() {
|
||||
let studioImage = studio.image_path;
|
||||
if (enableBackgroundImage && !isEditing && studioImage) {
|
||||
return (
|
||||
<div className="background-image-container">
|
||||
<picture>
|
||||
<source src={studioImage} />
|
||||
<img
|
||||
className="background-image"
|
||||
src={studioImage}
|
||||
alt={`${studio.name} background`}
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderTab() {
|
||||
if (!isEditing) {
|
||||
return renderTabs();
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderEditPanel() {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<StudioEditPanel
|
||||
studio={studio}
|
||||
onSubmit={onSave}
|
||||
onCancel={() => toggleEditing()}
|
||||
onDelete={onDelete}
|
||||
setImage={setImage}
|
||||
setEncodingImage={setEncodingImage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
{
|
||||
return (
|
||||
<DetailsEditNavbar
|
||||
objectName={studio.name ?? intl.formatMessage({ id: "studio" })}
|
||||
isNew={false}
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={() => toggleEditing()}
|
||||
onSave={() => {}}
|
||||
onImageChange={() => {}}
|
||||
onClearImage={() => {}}
|
||||
onAutoTag={onAutoTag}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div id="studio-page" className="row">
|
||||
<Helmet>
|
||||
<title>{studio.name ?? intl.formatMessage({ id: "studio" })}</title>
|
||||
</Helmet>
|
||||
|
||||
<div
|
||||
className={`studio-details details-tab ${collapsed ? "collapsed" : ""}`}
|
||||
className={`detail-header ${isEditing ? "edit" : ""} ${
|
||||
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="text-center">
|
||||
{encodingImage ? (
|
||||
<LoadingIndicator message="Encoding image..." />
|
||||
) : (
|
||||
renderImage()
|
||||
)}
|
||||
{maybeRenderHeaderBackgroundImage()}
|
||||
<div className="detail-container">
|
||||
<div className="detail-header-image">
|
||||
{encodingImage ? (
|
||||
<LoadingIndicator
|
||||
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
|
||||
/>
|
||||
) : (
|
||||
renderImage()
|
||||
)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="studio-head col">
|
||||
<h2>
|
||||
<span className="studio-name">{studio.name}</span>
|
||||
{maybeRenderShowCollapseButton()}
|
||||
{renderClickableIcons()}
|
||||
</h2>
|
||||
{maybeRenderAliases()}
|
||||
<RatingSystem
|
||||
value={studio.rating100 ?? undefined}
|
||||
onSetRating={(value) => setRating(value ?? null)}
|
||||
/>
|
||||
{maybeRenderDetails()}
|
||||
{maybeRenderEditPanel()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{studio.name ?? intl.formatMessage({ id: "studio" })}
|
||||
</title>
|
||||
</Helmet>
|
||||
<StudioDetailsPanel studio={studio} />
|
||||
<DetailsEditNavbar
|
||||
objectName={studio.name ?? intl.formatMessage({ id: "studio" })}
|
||||
isNew={false}
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={() => toggleEditing()}
|
||||
onSave={() => {}}
|
||||
onImageChange={() => {}}
|
||||
onClearImage={() => {}}
|
||||
onAutoTag={onAutoTag}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<StudioEditPanel
|
||||
studio={studio}
|
||||
onSubmit={onSave}
|
||||
onCancel={() => toggleEditing()}
|
||||
onDelete={onDelete}
|
||||
setImage={setImage}
|
||||
setEncodingImage={setEncodingImage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="details-divider d-none d-xl-block">
|
||||
<Button onClick={() => setCollapsed(!collapsed)}>
|
||||
<Icon className="fa-fw" icon={getCollapseButtonIcon()} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`col content-container ${collapsed ? "expanded" : ""}`}>
|
||||
<Tabs
|
||||
id="studio-tabs"
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
activeKey={activeTabKey}
|
||||
onSelect={setActiveTabKey}
|
||||
>
|
||||
<Tab
|
||||
eventKey="scenes"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "scenes" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={sceneCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioScenesPanel
|
||||
active={activeTabKey == "scenes"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="galleries"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "galleries" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={galleryCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioGalleriesPanel
|
||||
active={activeTabKey == "galleries"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="images"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "images" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={imageCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioImagesPanel
|
||||
active={activeTabKey == "images"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="performers"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "performers" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performerCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioPerformersPanel
|
||||
active={activeTabKey == "performers"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="movies"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "movies" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={movieCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioMoviesPanel
|
||||
active={activeTabKey == "movies"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="childstudios"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "subsidiary_studios" })}
|
||||
<Counter
|
||||
abbreviateCounter={false}
|
||||
count={studio.child_studios.length}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StudioChildrenPanel
|
||||
active={activeTabKey == "childstudios"}
|
||||
studio={studio}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
{maybeRenderCompressedDetails()}
|
||||
<div className="detail-body">
|
||||
<div className="studio-body">
|
||||
<div className="studio-tabs">{maybeRenderTab()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderDeleteAlert()}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ const StudioCreate: React.FC = () => {
|
|||
</h2>
|
||||
<div className="text-center">
|
||||
{encodingImage ? (
|
||||
<LoadingIndicator message="Encoding image..." />
|
||||
<LoadingIndicator
|
||||
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
|
||||
/>
|
||||
) : (
|
||||
renderImage()
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,119 +1,100 @@
|
|||
import React from "react";
|
||||
import { Badge } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||
import { TextField, URLField } from "src/utils/field";
|
||||
import { DetailItem } from "src/components/Shared/DetailItem";
|
||||
|
||||
interface IStudioDetailsPanel {
|
||||
studio: GQL.StudioDataFragment;
|
||||
collapsed?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
|
||||
studio,
|
||||
collapsed,
|
||||
fullWidth,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
function renderRatingField() {
|
||||
if (!studio.rating100) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<dt>{intl.formatMessage({ id: "rating" })}</dt>
|
||||
<dd>
|
||||
<RatingSystem value={studio.rating100} disabled />
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTagsList() {
|
||||
if (!studio.aliases?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<dt>
|
||||
<FormattedMessage id="aliases" />
|
||||
</dt>
|
||||
<dd>
|
||||
{studio.aliases.map((a) => (
|
||||
<Badge className="tag-item" variant="secondary" key={a}>
|
||||
{a}
|
||||
</Badge>
|
||||
))}
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStashIDs() {
|
||||
if (!studio.stash_ids?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<dt>
|
||||
<FormattedMessage id="stash_ids" />
|
||||
</dt>
|
||||
<dd>
|
||||
<ul className="pl-0">
|
||||
{studio.stash_ids.map((stashID) => {
|
||||
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||
const link = base ? (
|
||||
<a
|
||||
href={`${base}studios/${stashID.stash_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{stashID.stash_id}
|
||||
</a>
|
||||
) : (
|
||||
stashID.stash_id
|
||||
);
|
||||
return (
|
||||
<li key={stashID.stash_id} className="row no-gutters">
|
||||
{link}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</dd>
|
||||
</>
|
||||
<ul className="pl-0">
|
||||
{studio.stash_ids.map((stashID) => {
|
||||
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||
const link = base ? (
|
||||
<a
|
||||
href={`${base}studios/${stashID.stash_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{stashID.stash_id}
|
||||
</a>
|
||||
) : (
|
||||
stashID.stash_id
|
||||
);
|
||||
return (
|
||||
<li key={stashID.stash_id} className="row no-gutters">
|
||||
{link}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderExtraDetails() {
|
||||
if (!collapsed) {
|
||||
return (
|
||||
<DetailItem
|
||||
id="StashIDs"
|
||||
value={renderStashIDs()}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="studio-details">
|
||||
<div>
|
||||
<h2>{studio.name}</h2>
|
||||
</div>
|
||||
|
||||
<dl className="details-list">
|
||||
<URLField
|
||||
id="url"
|
||||
value={studio.url}
|
||||
url={TextUtils.sanitiseURL(studio.url ?? "")}
|
||||
/>
|
||||
|
||||
<TextField id="details" value={studio.details} />
|
||||
|
||||
<URLField
|
||||
id="parent_studios"
|
||||
value={studio.parent_studio?.name}
|
||||
url={`/studios/${studio.parent_studio?.id}`}
|
||||
trusted
|
||||
target="_self"
|
||||
/>
|
||||
|
||||
{renderRatingField()}
|
||||
{renderTagsList()}
|
||||
{renderStashIDs()}
|
||||
</dl>
|
||||
<div className="detail-group">
|
||||
<DetailItem id="details" value={studio.details} fullWidth={fullWidth} />
|
||||
<DetailItem
|
||||
id="parent_studios"
|
||||
value={
|
||||
studio.parent_studio?.name ? (
|
||||
<a href={`/studios/${studio.parent_studio?.id}`} target="_self">
|
||||
{studio.parent_studio.name}
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
{maybeRenderExtraDetails()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CompressedStudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
|
||||
studio,
|
||||
}) => {
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticky detail-header">
|
||||
<div className="sticky detail-header-group">
|
||||
<a className="studio-name" onClick={() => scrollToTop()}>
|
||||
{studio.name}
|
||||
</a>
|
||||
{studio?.parent_studio?.name ? (
|
||||
<span className="studio-parent">{studio?.parent_studio?.name}</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,16 +8,12 @@ import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
|||
import { StudioSelect } from "src/components/Shared/Select";
|
||||
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
|
||||
import { Button, Form, Col, Row } from "react-bootstrap";
|
||||
import FormUtils from "src/utils/form";
|
||||
import ImageUtils from "src/utils/image";
|
||||
import { getStashIDs } from "src/utils/stashIds";
|
||||
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
|
||||
import { useFormik } from "formik";
|
||||
import { Prompt } from "react-router-dom";
|
||||
import { StringListInput } from "../../Shared/StringListInput";
|
||||
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useRatingKeybinds } from "src/hooks/keybinds";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import isEqual from "lodash-es/isEqual";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { handleUnsavedChanges } from "src/utils/navigation";
|
||||
|
|
@ -43,7 +39,11 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
const Toast = useToast();
|
||||
|
||||
const isNew = studio.id === undefined;
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
|
||||
const labelXS = 3;
|
||||
const labelXL = 2;
|
||||
const fieldXS = 9;
|
||||
const fieldXL = 7;
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -53,7 +53,6 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
url: yup.string().ensure(),
|
||||
details: yup.string().ensure(),
|
||||
parent_id: yup.string().required().nullable(),
|
||||
rating100: yup.number().nullable().defined(),
|
||||
aliases: yup
|
||||
.array(yup.string().required())
|
||||
.defined()
|
||||
|
|
@ -85,7 +84,6 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
url: studio.url ?? "",
|
||||
details: studio.details ?? "",
|
||||
parent_id: studio.parent_studio?.id ?? null,
|
||||
rating100: studio.rating100 ?? null,
|
||||
aliases: studio.aliases ?? [],
|
||||
ignore_auto_tag: studio.ignore_auto_tag ?? false,
|
||||
stash_ids: getStashIDs(studio.stash_ids),
|
||||
|
|
@ -112,16 +110,6 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
setEncodingImage(encodingImage);
|
||||
}, [setEncodingImage, encodingImage]);
|
||||
|
||||
function setRating(v: number) {
|
||||
formik.setFieldValue("rating100", v);
|
||||
}
|
||||
|
||||
useRatingKeybinds(
|
||||
true,
|
||||
configuration?.ui?.ratingSystemOptions?.type,
|
||||
setRating
|
||||
);
|
||||
|
||||
// set up hotkeys
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("s s", () => {
|
||||
|
|
@ -171,8 +159,10 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
|
||||
return (
|
||||
<Row>
|
||||
<Form.Label column>StashIDs</Form.Label>
|
||||
<Col xs={9}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
StashIDs
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<ul className="pl-0">
|
||||
{formik.values.stash_ids.map((stashID) => {
|
||||
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||
|
|
@ -235,10 +225,10 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
|
||||
<Form noValidate onSubmit={formik.handleSubmit} id="studio-edit">
|
||||
<Form.Group controlId="name" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "name" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="name" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
{...formik.getFieldProps("name")}
|
||||
|
|
@ -250,11 +240,25 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="aliases" as={Row}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="aliases" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<StringListInput
|
||||
value={formik.values.aliases ?? []}
|
||||
setValue={(value) => formik.setFieldValue("aliases", value)}
|
||||
errors={aliasErrorMsg}
|
||||
errorIdx={aliasErrorIdx}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="url" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "url" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="url" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
{...formik.getFieldProps("url")}
|
||||
|
|
@ -267,10 +271,10 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="details" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "details" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="details" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
className="text-input"
|
||||
|
|
@ -284,10 +288,10 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="parent_studio" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "parent_studios" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="parent_studios" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<StudioSelect
|
||||
onSelect={(items) =>
|
||||
formik.setFieldValue(
|
||||
|
|
@ -301,44 +305,16 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="rating" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "rating" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<RatingSystem
|
||||
value={formik.values.rating100 ?? undefined}
|
||||
onSetRating={(value) =>
|
||||
formik.setFieldValue("rating100", value ?? null)
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
{renderStashIDs()}
|
||||
|
||||
<Form.Group controlId="aliases" as={Row}>
|
||||
<Form.Label column xs={3}>
|
||||
<FormattedMessage id="aliases" />
|
||||
</Form.Label>
|
||||
<Col xs={9}>
|
||||
<StringListInput
|
||||
value={formik.values.aliases ?? []}
|
||||
setValue={(value) => formik.setFieldValue("aliases", value)}
|
||||
errors={aliasErrorMsg}
|
||||
errorIdx={aliasErrorIdx}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
|
||||
<hr />
|
||||
|
||||
<Form.Group controlId="ignore-auto-tag" as={Row}>
|
||||
<Form.Label column xs={3}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="ignore_auto_tag" />
|
||||
</Form.Label>
|
||||
<Col xs={9}>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<Form.Check
|
||||
{...formik.getFieldProps({
|
||||
name: "ignore_auto_tag",
|
||||
|
|
@ -350,6 +326,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||
|
||||
<DetailsEditNavbar
|
||||
objectName={studio?.name ?? intl.formatMessage({ id: "studio" })}
|
||||
classNames="col-xl-9 mt-3"
|
||||
isNew={isNew}
|
||||
isEditing
|
||||
onToggleEdit={onCancel}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Tabs, Tab, Dropdown } from "react-bootstrap";
|
||||
import { Tabs, Tab, Dropdown, Button } from "react-bootstrap";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useHistory } from "react-router-dom";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
|
@ -26,17 +26,18 @@ import { TagMarkersPanel } from "./TagMarkersPanel";
|
|||
import { TagImagesPanel } from "./TagImagesPanel";
|
||||
import { TagPerformersPanel } from "./TagPerformersPanel";
|
||||
import { TagGalleriesPanel } from "./TagGalleriesPanel";
|
||||
import { TagDetailsPanel } from "./TagDetailsPanel";
|
||||
import { CompressedTagDetailsPanel, TagDetailsPanel } from "./TagDetailsPanel";
|
||||
import { TagEditPanel } from "./TagEditPanel";
|
||||
import { TagMergeModal } from "./TagMergeDialog";
|
||||
import {
|
||||
faChevronDown,
|
||||
faChevronUp,
|
||||
faSignInAlt,
|
||||
faSignOutAlt,
|
||||
faTrashAlt,
|
||||
faChevronRight,
|
||||
faChevronLeft,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { IUIConfig } from "src/core/config";
|
||||
import ImageUtils from "src/utils/image";
|
||||
|
||||
interface IProps {
|
||||
tag: GQL.TagDataFragment;
|
||||
|
|
@ -51,12 +52,16 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
|||
const Toast = useToast();
|
||||
const intl = useIntl();
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// Configuration settings
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
const abbreviateCounter =
|
||||
(configuration?.ui as IUIConfig)?.abbreviateCounters ?? false;
|
||||
const uiConfig = configuration?.ui as IUIConfig | undefined;
|
||||
const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;
|
||||
const enableBackgroundImage = uiConfig?.enableTagBackgroundImage ?? false;
|
||||
const showAllDetails = uiConfig?.showAllDetails ?? false;
|
||||
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
|
||||
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false);
|
||||
|
||||
const { tab = "scenes" } = useParams<ITabParams>();
|
||||
|
||||
|
|
@ -117,6 +122,21 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
|||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const f = () => {
|
||||
if (document.documentElement.scrollTop <= 50) {
|
||||
setLoadStickyHeader(false);
|
||||
} else {
|
||||
setLoadStickyHeader(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", f);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", f);
|
||||
};
|
||||
});
|
||||
|
||||
async function onSave(input: GQL.TagCreateInput) {
|
||||
const oldRelations = {
|
||||
parents: tag.parents ?? [],
|
||||
|
|
@ -203,6 +223,35 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
|||
);
|
||||
}
|
||||
|
||||
function getCollapseButtonIcon() {
|
||||
return collapsed ? faChevronDown : faChevronUp;
|
||||
}
|
||||
|
||||
function maybeRenderShowCollapseButton() {
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<span className="detail-expand-collapse">
|
||||
<Button
|
||||
className="minimal expand-collapse"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<Icon className="fa-fw" icon={getCollapseButtonIcon()} />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderAliases() {
|
||||
if (tag?.aliases?.length) {
|
||||
return (
|
||||
<div>
|
||||
<span className="alias-head">{tag?.aliases?.join(", ")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEditing(value?: boolean) {
|
||||
if (value !== undefined) {
|
||||
setIsEditing(value);
|
||||
|
|
@ -225,7 +274,14 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
|||
}
|
||||
|
||||
if (tagImage) {
|
||||
return <img className="logo" alt={tag.name} src={tagImage} />;
|
||||
return (
|
||||
<img
|
||||
className="logo"
|
||||
alt={tag.name}
|
||||
src={tagImage}
|
||||
onLoad={ImageUtils.verifyImageSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,157 +326,211 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
|
|||
);
|
||||
}
|
||||
|
||||
function getCollapseButtonIcon() {
|
||||
return collapsed ? faChevronRight : faChevronLeft;
|
||||
function maybeRenderDetails() {
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<TagDetailsPanel
|
||||
tag={tag}
|
||||
fullWidth={!collapsed && !compactExpandedDetails}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderEditPanel() {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<TagEditPanel
|
||||
tag={tag}
|
||||
onSubmit={onSave}
|
||||
onCancel={() => toggleEditing()}
|
||||
onDelete={onDelete}
|
||||
setImage={setImage}
|
||||
setEncodingImage={setEncodingImage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
{
|
||||
return (
|
||||
<DetailsEditNavbar
|
||||
objectName={tag.name}
|
||||
isNew={false}
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={() => toggleEditing()}
|
||||
onSave={() => {}}
|
||||
onImageChange={() => {}}
|
||||
onClearImage={() => {}}
|
||||
onAutoTag={onAutoTag}
|
||||
onDelete={onDelete}
|
||||
classNames="mb-2"
|
||||
customButtons={renderMergeButton()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const renderTabs = () => (
|
||||
<React.Fragment>
|
||||
<Tabs
|
||||
id="tag-tabs"
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
activeKey={activeTabKey}
|
||||
onSelect={setActiveTabKey}
|
||||
>
|
||||
<Tab
|
||||
eventKey="scenes"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "scenes" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={sceneCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagScenesPanel active={activeTabKey == "scenes"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="images"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "images" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={imageCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagImagesPanel active={activeTabKey == "images"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="galleries"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "galleries" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={galleryCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagGalleriesPanel active={activeTabKey == "galleries"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="markers"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "markers" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={sceneMarkerCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagMarkersPanel active={activeTabKey == "markers"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="performers"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "performers" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performerCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagPerformersPanel active={activeTabKey == "performers"} tag={tag} />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
function maybeRenderHeaderBackgroundImage() {
|
||||
let tagImage = tag.image_path;
|
||||
if (enableBackgroundImage && !isEditing && tagImage) {
|
||||
return (
|
||||
<div className="background-image-container">
|
||||
<picture>
|
||||
<source src={tagImage} />
|
||||
<img
|
||||
className="background-image"
|
||||
src={tagImage}
|
||||
alt={`${tag.name} background`}
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderTab() {
|
||||
if (!isEditing) {
|
||||
return renderTabs();
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderCompressedDetails() {
|
||||
if (!isEditing && loadStickyHeader) {
|
||||
return <CompressedTagDetailsPanel tag={tag} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="tag-page" className="row">
|
||||
<Helmet>
|
||||
<title>{tag.name}</title>
|
||||
</Helmet>
|
||||
<div className="row">
|
||||
<div
|
||||
className={`tag-details details-tab ${collapsed ? "collapsed" : ""}`}
|
||||
>
|
||||
<div className="text-center logo-container">
|
||||
|
||||
<div
|
||||
className={`detail-header ${isEditing ? "edit" : ""} ${
|
||||
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
|
||||
}`}
|
||||
>
|
||||
{maybeRenderHeaderBackgroundImage()}
|
||||
<div className="detail-container">
|
||||
<div className="detail-header-image">
|
||||
{encodingImage ? (
|
||||
<LoadingIndicator message="Encoding image..." />
|
||||
<LoadingIndicator
|
||||
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
|
||||
/>
|
||||
) : (
|
||||
renderImage()
|
||||
)}
|
||||
<h2>{tag.name}</h2>
|
||||
<p>{tag.description}</p>
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<TagDetailsPanel tag={tag} />
|
||||
{/* HACK - this is also rendered in the TagEditPanel */}
|
||||
<DetailsEditNavbar
|
||||
objectName={tag.name}
|
||||
isNew={false}
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={() => toggleEditing()}
|
||||
onSave={() => {}}
|
||||
onImageChange={() => {}}
|
||||
onClearImage={() => {}}
|
||||
onAutoTag={onAutoTag}
|
||||
onDelete={onDelete}
|
||||
classNames="mb-2"
|
||||
customButtons={renderMergeButton()}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TagEditPanel
|
||||
tag={tag}
|
||||
onSubmit={onSave}
|
||||
onCancel={() => toggleEditing()}
|
||||
onDelete={onDelete}
|
||||
setImage={setImage}
|
||||
setEncodingImage={setEncodingImage}
|
||||
/>
|
||||
)}
|
||||
<div className="row">
|
||||
<div className="studio-head col">
|
||||
<h2>
|
||||
<span className="tag-name">{tag.name}</span>
|
||||
{maybeRenderShowCollapseButton()}
|
||||
</h2>
|
||||
{maybeRenderAliases()}
|
||||
{maybeRenderDetails()}
|
||||
{maybeRenderEditPanel()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="details-divider d-none d-xl-block">
|
||||
<Button onClick={() => setCollapsed(!collapsed)}>
|
||||
<Icon className="fa-fw" icon={getCollapseButtonIcon()} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`col content-container ${collapsed ? "expanded" : ""}`}>
|
||||
<Tabs
|
||||
id="tag-tabs"
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
activeKey={activeTabKey}
|
||||
onSelect={setActiveTabKey}
|
||||
>
|
||||
<Tab
|
||||
eventKey="scenes"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "scenes" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={sceneCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagScenesPanel active={activeTabKey == "scenes"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="images"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "images" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={imageCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagImagesPanel active={activeTabKey == "images"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="galleries"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "galleries" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={galleryCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagGalleriesPanel
|
||||
active={activeTabKey == "galleries"}
|
||||
tag={tag}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="markers"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "markers" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={sceneMarkerCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagMarkersPanel active={activeTabKey == "markers"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="performers"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "performers" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={performerCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagPerformersPanel
|
||||
active={activeTabKey == "performers"}
|
||||
tag={tag}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
{renderDeleteAlert()}
|
||||
{renderMergeDialog()}
|
||||
</div>
|
||||
</>
|
||||
{maybeRenderCompressedDetails()}
|
||||
<div className="detail-body">
|
||||
<div className="tag-body">
|
||||
<div className="tag-tabs">{maybeRenderTab()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderDeleteAlert()}
|
||||
{renderMergeDialog()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@ const TagCreate: React.FC = () => {
|
|||
<div className="tag-details col-md-8">
|
||||
<div className="text-center logo-container">
|
||||
{encodingImage ? (
|
||||
<LoadingIndicator message="Encoding image..." />
|
||||
<LoadingIndicator
|
||||
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
|
||||
/>
|
||||
) : (
|
||||
renderImage()
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,53 +1,28 @@
|
|||
import React from "react";
|
||||
import { Badge } from "react-bootstrap";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DetailItem } from "src/components/Shared/DetailItem";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
|
||||
interface ITagDetails {
|
||||
tag: GQL.TagDataFragment;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag }) => {
|
||||
function renderAliasesField() {
|
||||
if (!tag.aliases.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className="row">
|
||||
<dt className="col-3 col-xl-2">
|
||||
<FormattedMessage id="aliases" />
|
||||
</dt>
|
||||
<dd className="col-9 col-xl-10">
|
||||
{tag.aliases.map((a) => (
|
||||
<Badge className="tag-item" variant="secondary" key={a}>
|
||||
{a}
|
||||
</Badge>
|
||||
))}
|
||||
</dd>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag, fullWidth }) => {
|
||||
function renderParentsField() {
|
||||
if (!tag.parents?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className="row">
|
||||
<dt className="col-3 col-xl-2">
|
||||
<FormattedMessage id="parent_tags" />
|
||||
</dt>
|
||||
<dd className="col-9 col-xl-10">
|
||||
{tag.parents.map((p) => (
|
||||
<Badge key={p.id} className="tag-item" variant="secondary">
|
||||
<Link to={`/tags/${p.id}`}>{p.name}</Link>
|
||||
</Badge>
|
||||
))}
|
||||
</dd>
|
||||
</dl>
|
||||
<>
|
||||
{tag.parents.map((p) => (
|
||||
<Badge key={p.id} className="tag-item" variant="secondary">
|
||||
<Link to={`/tags/${p.id}`}>{p.name}</Link>
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -57,26 +32,54 @@ export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<dl className="row">
|
||||
<dt className="col-3 col-xl-2">
|
||||
<FormattedMessage id="sub_tags" />
|
||||
</dt>
|
||||
<dd className="col-9 col-xl-10">
|
||||
{tag.children.map((c) => (
|
||||
<Badge key={c.id} className="tag-item" variant="secondary">
|
||||
<Link to={`/tags/${c.id}`}>{c.name}</Link>
|
||||
</Badge>
|
||||
))}
|
||||
</dd>
|
||||
</dl>
|
||||
<>
|
||||
{tag.children.map((c) => (
|
||||
<Badge key={c.id} className="tag-item" variant="secondary">
|
||||
<Link to={`/tags/${c.id}`}>{c.name}</Link>
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderAliasesField()}
|
||||
{renderParentsField()}
|
||||
{renderChildrenField()}
|
||||
</>
|
||||
<div className="detail-group">
|
||||
<DetailItem
|
||||
id="description"
|
||||
value={tag.description}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="parent_tags"
|
||||
value={renderParentsField()}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="sub_tags"
|
||||
value={renderChildrenField()}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CompressedTagDetailsPanel: React.FC<ITagDetails> = ({ tag }) => {
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticky detail-header">
|
||||
<div className="sticky detail-header-group">
|
||||
<a className="tag-name" onClick={() => scrollToTop()}>
|
||||
{tag.name}
|
||||
</a>
|
||||
{tag.description ? (
|
||||
<span className="tag-desc">{tag.description}</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import * as yup from "yup";
|
|||
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
|
||||
import { TagSelect } from "src/components/Shared/Select";
|
||||
import { Form, Col, Row } from "react-bootstrap";
|
||||
import FormUtils from "src/utils/form";
|
||||
import ImageUtils from "src/utils/image";
|
||||
import { useFormik } from "formik";
|
||||
import { Prompt } from "react-router-dom";
|
||||
|
|
@ -42,9 +41,9 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const labelXS = 3;
|
||||
const labelXL = 3;
|
||||
const labelXL = 2;
|
||||
const fieldXS = 9;
|
||||
const fieldXL = 9;
|
||||
const fieldXL = 7;
|
||||
|
||||
const schema = yup.object({
|
||||
name: yup.string().required(),
|
||||
|
|
@ -204,10 +203,10 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="description" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "description" }),
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="description" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
className="text-input"
|
||||
|
|
@ -218,15 +217,10 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="parent_tags" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "parent_tags" }),
|
||||
labelProps: {
|
||||
column: true,
|
||||
sm: 3,
|
||||
xl: 12,
|
||||
},
|
||||
})}
|
||||
<Col sm={9} xl={12}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="parent_tags" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<TagSelect
|
||||
isMulti
|
||||
onSelect={(items) =>
|
||||
|
|
@ -247,15 +241,10 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="sub_tags" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: intl.formatMessage({ id: "sub_tags" }),
|
||||
labelProps: {
|
||||
column: true,
|
||||
sm: 3,
|
||||
xl: 12,
|
||||
},
|
||||
})}
|
||||
<Col sm={9} xl={12}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="sub_tags" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<TagSelect
|
||||
isMulti
|
||||
onSelect={(items) =>
|
||||
|
|
@ -294,6 +283,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
|
|||
|
||||
<DetailsEditNavbar
|
||||
objectName={tag?.name ?? intl.formatMessage({ id: "tag" })}
|
||||
classNames="col-xl-9 mt-3"
|
||||
isNew={isNew}
|
||||
isEditing={isEditing}
|
||||
onToggleEdit={onCancel}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,18 @@ export interface IUIConfig {
|
|||
|
||||
ratingSystemOptions?: RatingSystemOptions;
|
||||
|
||||
// if true a background image will be display on header
|
||||
enableMovieBackgroundImage?: boolean;
|
||||
// if true a background image will be display on header
|
||||
enablePerformerBackgroundImage?: boolean;
|
||||
// if true a background image will be display on header
|
||||
enableStudioBackgroundImage?: boolean;
|
||||
// if true a background image will be display on header
|
||||
enableTagBackgroundImage?: boolean;
|
||||
// if true view expanded details compact
|
||||
compactExpandedDetails?: boolean;
|
||||
// if true show all content details by default
|
||||
showAllDetails?: boolean;
|
||||
// if true the chromecast option will enabled
|
||||
enableChromecast?: boolean;
|
||||
// if true continue scene will always play from the beginning
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
| `r 0` | [Edit mode] Unset rating (stars) |
|
||||
| `r {0-9} {0-9}` | [Edit mode] Set rating (decimal - `r 0 0` for `10.0`) |
|
||||
| ``r ` `` | [Edit mode] Unset rating (decimal) |
|
||||
| `,` | Expand/Collapse Details |
|
||||
| `Ctrl + v` | Paste Movie image |
|
||||
|
||||
[//]: # "Commented until implementation is dealt with"
|
||||
|
|
@ -144,11 +145,11 @@
|
|||
|
||||
| Keyboard sequence | Action |
|
||||
|-------------------|--------|
|
||||
| `a` | Details tab |
|
||||
| `c` | Scenes tab |
|
||||
| `e` | Edit tab |
|
||||
| `o` | Operations tab |
|
||||
| `f` | Toggle favourite |
|
||||
| `,` | Expand/Collapse Details |
|
||||
|
||||
### Edit Performer tab shortcuts
|
||||
|
||||
|
|
@ -171,6 +172,7 @@
|
|||
| `e` | Edit Studio |
|
||||
| `s s` | Save Studio |
|
||||
| `d d` | Delete Studio |
|
||||
| `,` | Expand/Collapse Details |
|
||||
| `Ctrl + v` | Paste Studio image |
|
||||
|
||||
## Tags Page shortcuts
|
||||
|
|
@ -186,4 +188,5 @@
|
|||
| `e` | Edit Tag |
|
||||
| `s s` | Save Tag |
|
||||
| `d d` | Delete Tag |
|
||||
| `,` | Expand/Collapse Details |
|
||||
| `Ctrl + v` | Paste Tag image |
|
||||
|
|
@ -39,10 +39,11 @@ html {
|
|||
|
||||
body {
|
||||
color: $text-color;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
margin: 0;
|
||||
padding: 4rem 0 0 0;
|
||||
overflow-x: hidden;
|
||||
padding: 3.5rem 0 0 0;
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
@media (orientation: portrait) {
|
||||
|
|
@ -61,6 +62,303 @@ dd {
|
|||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.sticky.detail-header-group {
|
||||
padding: 1rem 2.5rem;
|
||||
|
||||
a.movie-name,
|
||||
a.performer-name,
|
||||
a.studio-name,
|
||||
a.tag-name {
|
||||
color: #f5f8fa;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
a,
|
||||
span {
|
||||
color: #d7d9db;
|
||||
font-weight: 600;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sticky.detail-header {
|
||||
display: block;
|
||||
min-height: 50px;
|
||||
padding: unset;
|
||||
position: fixed;
|
||||
top: 3.3rem;
|
||||
z-index: 10;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.movie-name,
|
||||
.performer-name,
|
||||
.studio-name,
|
||||
.tag-name {
|
||||
font-weight: 800;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-expand-collapse {
|
||||
.btn-primary:focus,
|
||||
.btn-primary.focus,
|
||||
.btn-primary:not(:disabled):not(.disabled):active,
|
||||
.btn-primary:not(:disabled):not(.disabled).active,
|
||||
.show > .btn-primary.dropdown-toggle,
|
||||
.btn-primary:hover {
|
||||
background: rgba(138, 155, 168, 0.15);
|
||||
background-color: rgba(138, 155, 168, 0.15);
|
||||
border-color: rgba(138, 155, 168, 0.15);
|
||||
box-shadow: unset;
|
||||
color: #f5f8fa;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
background-color: #192127;
|
||||
min-height: 15rem;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
transition: 0.3s;
|
||||
width: 100%;
|
||||
z-index: 11;
|
||||
|
||||
.detail-group,
|
||||
.col {
|
||||
transition: 0.2s;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.background-image-container {
|
||||
bottom: -0.2rem;
|
||||
left: 0;
|
||||
opacity: 0.2;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -0.2rem;
|
||||
z-index: auto;
|
||||
|
||||
.background-image {
|
||||
filter: blur(16px);
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 50% 30%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
|
||||
.detail-item-value.age {
|
||||
border-bottom: 1px dotted #f5f8fa;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.country,
|
||||
.performer-country {
|
||||
.mr-2.fi {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.alias-head {
|
||||
color: #868791;
|
||||
}
|
||||
|
||||
.detail-expand-collapse,
|
||||
.name-icons {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-header.edit {
|
||||
background-color: unset;
|
||||
|
||||
form {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.details-edit {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.detail-header-image {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-header.collapsed {
|
||||
.detail-header-image img {
|
||||
max-width: 11rem;
|
||||
transition: 0.5s;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
margin-left: 15px;
|
||||
margin-right: 15px;
|
||||
width: 100%;
|
||||
|
||||
nav {
|
||||
align-content: center;
|
||||
border-bottom: solid 2px #192127;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsed .detail-item-value {
|
||||
-webkit-box-orient: vertical;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
.detail-header-image {
|
||||
height: auto;
|
||||
|
||||
img {
|
||||
max-width: 22rem;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
flex-direction: unset;
|
||||
padding-right: 0;
|
||||
width: 100%;
|
||||
|
||||
.detail-item-title {
|
||||
display: table-cell;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.detail-item-value {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-item-title.tags,
|
||||
.detail-item-title.parent-tags,
|
||||
.detail-item-title.sub-tags {
|
||||
padding-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-header-image {
|
||||
display: flex;
|
||||
float: left;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
padding: 0 1rem;
|
||||
|
||||
.movie-images {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
float: unset;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
|
||||
.movie-images {
|
||||
.img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
margin: auto;
|
||||
max-width: 14rem;
|
||||
transition: 0.5s;
|
||||
}
|
||||
|
||||
.movie-images img {
|
||||
@media (max-width: 576px) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#movie-page .detail-header-image .movie-images img {
|
||||
max-width: 13rem;
|
||||
}
|
||||
|
||||
#movie-page .detail-header-image img,
|
||||
#performer-page .detail-header-image img,
|
||||
#tag-page .detail-header-image img {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
#tag-page .full-width .detail-header-image img {
|
||||
max-width: 22rem;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
#tag-page .detail-header-image img {
|
||||
max-width: 18rem;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-item.tags .pl-0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
align-items: left;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-right: 4rem;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-item-title {
|
||||
color: #868791;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-item-value {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.input-control,
|
||||
.text-input {
|
||||
border: 0;
|
||||
|
|
@ -130,6 +428,12 @@ textarea.text-input {
|
|||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.row.justify-content-center {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
@media (min-width: 576px) {
|
||||
.zoom-0 {
|
||||
width: 240px;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
"download_backup": "Download Backup",
|
||||
"edit": "Edit",
|
||||
"edit_entity": "Edit {entityType}",
|
||||
"encoding_image": "Encoding image",
|
||||
"export": "Export",
|
||||
"export_all": "Export all…",
|
||||
"find": "Find",
|
||||
|
|
@ -580,6 +581,21 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"enable_background_image": {
|
||||
"description": "Display background image on detail page.",
|
||||
"heading": "Enable background image"
|
||||
},
|
||||
"heading": "Detail Page",
|
||||
"compact_expanded_details": {
|
||||
"description": "When enabled, this option will present expanded details while maintaining a compact presentation",
|
||||
"heading": "Compact expanded details"
|
||||
},
|
||||
"show_all_details": {
|
||||
"description": "When enabled, all content details will be shown by default and each detail item will fit under a single column",
|
||||
"heading": "Show all details"
|
||||
}
|
||||
},
|
||||
"funscript_offset": {
|
||||
"description": "Time offset in milliseconds for interactive scripts playback.",
|
||||
"heading": "Funscript Offset (ms)"
|
||||
|
|
|
|||
|
|
@ -70,6 +70,17 @@ const ImageUtils = {
|
|||
onImageChange,
|
||||
usePasteImage,
|
||||
imageToDataURL,
|
||||
verifyImageSize,
|
||||
};
|
||||
|
||||
function verifyImageSize(e: React.UIEvent<HTMLImageElement>) {
|
||||
const img = e.target as HTMLImageElement;
|
||||
// set width = 200px if zero-sized image (SVG w/o intrinsic size)
|
||||
if (img.width === 0 && img.height === 0) {
|
||||
img.setAttribute("width", "200");
|
||||
} else {
|
||||
img.removeAttribute("width");
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageUtils;
|
||||
|
|
|
|||
Loading…
Reference in a new issue