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:
CJ 2023-07-31 01:10:42 -05:00 committed by GitHub
parent a665a56ef0
commit b8e2f2a0fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 2023 additions and 1022 deletions

View file

@ -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}

View file

@ -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;
}
}

View file

@ -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>

View file

@ -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()}

View file

@ -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>
);
};

View file

@ -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}

View file

@ -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 {

View file

@ -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>

View file

@ -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 (

View file

@ -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>
);
};

View file

@ -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;
}

View file

@ -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) {

View file

@ -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">

View file

@ -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}
/>
</>
);
};

View 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>
);
};

View file

@ -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>) {

View file

@ -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 {

View file

@ -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>

View file

@ -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()
)}

View file

@ -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>
);
};

View file

@ -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}

View file

@ -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>
);
};

View file

@ -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()
)}

View file

@ -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>
);
};

View file

@ -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}

View file

@ -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

View file

@ -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 |

View file

@ -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;

View file

@ -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)"

View file

@ -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;