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
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 ( return (
<div> <div className="item-list-container">
<ButtonToolbar className="justify-content-center"> <ButtonToolbar className="justify-content-center">
<ListFilter <ListFilter
onFilterUpdate={updateFilter} onFilterUpdate={updateFilter}

View File

@@ -359,3 +359,11 @@ input[type="range"].zoom-slider {
.tilted { .tilted {
transform: rotate(45deg); 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 { ModalComponent } from "src/components/Shared/Modal";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { MovieScenesPanel } from "./MovieScenesPanel"; import { MovieScenesPanel } from "./MovieScenesPanel";
import { MovieDetailsPanel } from "./MovieDetailsPanel"; import {
CompressedMovieDetailsPanel,
MovieDetailsPanel,
} from "./MovieDetailsPanel";
import { MovieEditPanel } from "./MovieEditPanel"; 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 { interface IProps {
movie: GQL.MovieDataFragment; movie: GQL.MovieDataFragment;
@@ -30,6 +45,16 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
const history = useHistory(); const history = useHistory();
const Toast = useToast(); 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 // Editing state
const [isEditing, setIsEditing] = useState<boolean>(false); const [isEditing, setIsEditing] = useState<boolean>(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
@@ -87,6 +112,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
Mousetrap.bind("d d", () => { Mousetrap.bind("d d", () => {
onDelete(); onDelete();
}); });
Mousetrap.bind(",", () => setCollapsed(!collapsed));
return () => { return () => {
Mousetrap.unbind("e"); 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) { async function onSave(input: GQL.MovieCreateInput) {
await updateMovie({ await updateMovie({
variables: { 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() { function renderFrontImage() {
let image = movie.front_image_path; let image = movie.front_image_path;
if (isEditing) { if (isEditing) {
@@ -174,7 +240,11 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
if (image && defaultImage) { if (image && defaultImage) {
return ( return (
<div className="movie-image-container"> <div className="movie-image-container">
<img alt="Front Cover" src={image} /> <img
alt="Front Cover"
src={image}
onLoad={ImageUtils.verifyImageSize}
/>
</div> </div>
); );
} else if (image) { } else if (image) {
@@ -184,7 +254,11 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
variant="link" variant="link"
onClick={() => showLightbox()} onClick={() => showLightbox()}
> >
<img alt="Front Cover" src={image} /> <img
alt="Front Cover"
src={image}
onLoad={ImageUtils.verifyImageSize}
/>
</Button> </Button>
); );
} }
@@ -207,48 +281,72 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
variant="link" variant="link"
onClick={() => showLightbox(index - 1)} onClick={() => showLightbox(index - 1)}
> >
<img alt="Back Cover" src={image} /> <img
alt="Back Cover"
src={image}
onLoad={ImageUtils.verifyImageSize}
/>
</Button> </Button>
); );
} }
} }
if (updating || deleting) return <LoadingIndicator />; const renderClickableIcons = () => (
<span className="name-icons">
// TODO: CSS class {movie.url && (
return ( <Button className="minimal icon-link" title={movie.url}>
<div className="row"> <a
<Helmet> href={TextUtils.sanitiseURL(movie.url)}
<title>{movie?.name}</title> className="link"
</Helmet> target="_blank"
rel="noopener noreferrer"
<div className="movie-details mb-3 col col-xl-4 col-lg-6"> >
<div className="logo w-100"> <Icon icon={faLink} />
{encodingImage ? ( </a>
<LoadingIndicator message="Encoding image..." /> </Button>
) : (
<div className="movie-images">
{renderFrontImage()}
{renderBackImage()}
</div>
)} )}
</div> </span>
);
{!isEditing ? ( function maybeRenderAliases() {
<> if (movie?.aliases) {
<MovieDetailsPanel movie={movie} /> return (
{/* HACK - this is also rendered in the MovieEditPanel */} <div>
<DetailsEditNavbar <span className="alias-head">{movie?.aliases}</span>
objectName={movie.name} </div>
isNew={false} );
isEditing={isEditing} }
onToggleEdit={() => toggleEditing()} }
onSave={() => {}}
onImageChange={() => {}} function setRating(v: number | null) {
onDelete={onDelete} 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 <MovieEditPanel
movie={movie} movie={movie}
onSubmit={onSave} onSubmit={onSave}
@@ -258,11 +356,105 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
setBackImage={setBackImage} setBackImage={setBackImage}
setEncodingImage={setEncodingImage} 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 />;
return (
<div id="movie-page" className="row">
<Helmet>
<title>{movie?.name}</title>
</Helmet>
<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>
<div className="col-xl-8 col-lg-6"> <div className="row">
<MovieScenesPanel active={true} movie={movie} /> <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>
</div>
{maybeRenderCompressedDetails()}
<div className="detail-body">
<div className="movie-body">
<div className="movie-tabs">{maybeRenderTab()}</div>
</div>
</div> </div>
{renderDeleteAlert()} {renderDeleteAlert()}
</div> </div>

View File

@@ -66,7 +66,9 @@ const MovieCreate: React.FC = () => {
<div className="movie-details mb-3 col"> <div className="movie-details mb-3 col">
<div className="logo w-100"> <div className="logo w-100">
{encodingImage ? ( {encodingImage ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
/>
) : ( ) : (
<div className="movie-images"> <div className="movie-images">
{renderFrontImage()} {renderFrontImage()}

View File

@@ -3,82 +3,73 @@ import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import DurationUtils from "src/utils/duration"; import DurationUtils from "src/utils/duration";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { DetailItem } from "src/components/Shared/DetailItem";
import { TextField, URLField } from "src/utils/field";
interface IMovieDetailsPanel { interface IMovieDetailsPanel {
movie: GQL.MovieDataFragment; movie: GQL.MovieDataFragment;
fullWidth?: boolean;
} }
export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => { export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({
movie,
fullWidth,
}) => {
// Network state // Network state
const intl = useIntl(); const intl = useIntl();
function maybeRenderAliases() {
if (movie.aliases) {
return ( return (
<div> <div className="detail-group">
<span className="alias-head"> <DetailItem
{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>
{maybeRenderAliases()}
<dl className="details-list">
<TextField
id="duration" id="duration"
value={ value={
movie.duration ? DurationUtils.secondsToString(movie.duration) : "" movie.duration ? DurationUtils.secondsToString(movie.duration) : ""
} }
fullWidth={fullWidth}
/> />
<TextField <DetailItem
id="date" id="date"
value={movie.date ? TextUtils.formatDate(intl, movie.date) : ""} value={movie.date ? TextUtils.formatDate(intl, movie.date) : ""}
fullWidth={fullWidth}
/> />
<URLField <DetailItem
id="studio" id="studio"
value={movie.studio?.name} value={
url={`/studios/${movie.studio?.id}`} movie.studio?.id ? (
/> <a href={`/studios/${movie.studio?.id}`} target="_self">
<TextField id="director" value={movie.director} /> {movie.studio?.name}
</a>
{renderRatingField()} ) : (
""
<URLField )
id="url" }
value={movie.url} fullWidth={fullWidth}
url={TextUtils.sanitiseURL(movie.url ?? "")}
/> />
<TextField id="synopsis" value={movie.synopsis} /> <DetailItem id="director" value={movie.director} fullWidth={fullWidth} />
</dl> <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> </div>
); );
}; };

View File

@@ -15,14 +15,10 @@ import { URLField } from "src/components/Shared/URLField";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { Modal as BSModal, Form, Button, Col, Row } from "react-bootstrap"; import { Modal as BSModal, Form, Button, Col, Row } from "react-bootstrap";
import DurationUtils from "src/utils/duration"; import DurationUtils from "src/utils/duration";
import FormUtils from "src/utils/form";
import ImageUtils from "src/utils/image"; import ImageUtils from "src/utils/image";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { Prompt } from "react-router-dom"; import { Prompt } from "react-router-dom";
import { MovieScrapeDialog } from "./MovieScrapeDialog"; import { MovieScrapeDialog } from "./MovieScrapeDialog";
import { useRatingKeybinds } from "src/hooks/keybinds";
import { ConfigurationContext } from "src/hooks/Config";
import isEqual from "lodash-es/isEqual"; import isEqual from "lodash-es/isEqual";
import { DateInput } from "src/components/Shared/DateInput"; import { DateInput } from "src/components/Shared/DateInput";
import { handleUnsavedChanges } from "src/utils/navigation"; import { handleUnsavedChanges } from "src/utils/navigation";
@@ -48,7 +44,6 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const { configuration: stashConfig } = React.useContext(ConfigurationContext);
const isNew = movie.id === undefined; const isNew = movie.id === undefined;
@@ -60,6 +55,11 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
const Scrapers = useListMovieScrapers(); const Scrapers = useListMovieScrapers();
const [scrapedMovie, setScrapedMovie] = useState<GQL.ScrapedMovie>(); const [scrapedMovie, setScrapedMovie] = useState<GQL.ScrapedMovie>();
const labelXS = 3;
const labelXL = 2;
const fieldXS = 9;
const fieldXL = 7;
const schema = yup.object({ const schema = yup.object({
name: yup.string().required(), name: yup.string().required(),
aliases: yup.string().ensure(), aliases: yup.string().ensure(),
@@ -79,7 +79,6 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
}), }),
studio_id: yup.string().required().nullable(), studio_id: yup.string().required().nullable(),
director: yup.string().ensure(), director: yup.string().ensure(),
rating100: yup.number().nullable().defined(),
url: yup.string().ensure(), url: yup.string().ensure(),
synopsis: yup.string().ensure(), synopsis: yup.string().ensure(),
front_image: yup.string().nullable().optional(), front_image: yup.string().nullable().optional(),
@@ -93,7 +92,6 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
date: movie?.date ?? "", date: movie?.date ?? "",
studio_id: movie?.studio?.id ?? null, studio_id: movie?.studio?.id ?? null,
director: movie?.director ?? "", director: movie?.director ?? "",
rating100: movie?.rating100 ?? null,
url: movie?.url ?? "", url: movie?.url ?? "",
synopsis: movie?.synopsis ?? "", synopsis: movie?.synopsis ?? "",
}; };
@@ -107,16 +105,6 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
onSubmit: (values) => onSave(values), onSubmit: (values) => onSave(values),
}); });
function setRating(v: number) {
formik.setFieldValue("rating100", v);
}
useRatingKeybinds(
true,
stashConfig?.ui?.ratingSystemOptions?.type,
setRating
);
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
// Mousetrap.bind("u", (e) => { // Mousetrap.bind("u", (e) => {
@@ -347,10 +335,10 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
function renderTextField(field: string, title: string, placeholder?: string) { function renderTextField(field: string, title: string, placeholder?: string) {
return ( return (
<Form.Group controlId={field} as={Row}> <Form.Group controlId={field} as={Row}>
{FormUtils.renderLabel({ <Form.Label column xs={labelXS} xl={labelXL}>
title, <FormattedMessage id={title} />
})} </Form.Label>
<Col xs={9}> <Col xs={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
className="text-input" className="text-input"
placeholder={placeholder ?? title} placeholder={placeholder ?? title}
@@ -390,10 +378,10 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
<Form noValidate onSubmit={formik.handleSubmit} id="movie-edit"> <Form noValidate onSubmit={formik.handleSubmit} id="movie-edit">
<Form.Group controlId="name" as={Row}> <Form.Group controlId="name" as={Row}>
{FormUtils.renderLabel({ <Form.Label column xs={labelXS} xl={labelXL}>
title: intl.formatMessage({ id: "name" }), <FormattedMessage id="name" />
})} </Form.Label>
<Col xs={9}> <Col xs={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
className="text-input" className="text-input"
placeholder={intl.formatMessage({ id: "name" })} placeholder={intl.formatMessage({ id: "name" })}
@@ -409,10 +397,10 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
{renderTextField("aliases", intl.formatMessage({ id: "aliases" }))} {renderTextField("aliases", intl.formatMessage({ id: "aliases" }))}
<Form.Group controlId="duration" as={Row}> <Form.Group controlId="duration" as={Row}>
{FormUtils.renderLabel({ <Form.Label column xs={labelXS} xl={labelXL}>
title: intl.formatMessage({ id: "duration" }), <FormattedMessage id="duration" />
})} </Form.Label>
<Col xs={9}> <Col xs={fieldXS} xl={fieldXL}>
<DurationInput <DurationInput
numericValue={formik.values.duration ?? undefined} numericValue={formik.values.duration ?? undefined}
onValueChange={(valueAsNumber) => { onValueChange={(valueAsNumber) => {
@@ -423,10 +411,10 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
</Form.Group> </Form.Group>
<Form.Group controlId="date" as={Row}> <Form.Group controlId="date" as={Row}>
{FormUtils.renderLabel({ <Form.Label column xs={labelXS} xl={labelXL}>
title: intl.formatMessage({ id: "date" }), <FormattedMessage id="date" />
})} </Form.Label>
<Col xs={9}> <Col xs={fieldXS} xl={fieldXL}>
<DateInput <DateInput
value={formik.values.date} value={formik.values.date}
onValueChange={(value) => formik.setFieldValue("date", value)} onValueChange={(value) => formik.setFieldValue("date", value)}
@@ -436,10 +424,10 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
</Form.Group> </Form.Group>
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({ <Form.Label column xs={labelXS} xl={labelXL}>
title: intl.formatMessage({ id: "studio" }), <FormattedMessage id="studio" />
})} </Form.Label>
<Col xs={9}> <Col xs={fieldXS} xl={fieldXL}>
<StudioSelect <StudioSelect
onSelect={(items) => onSelect={(items) =>
formik.setFieldValue( formik.setFieldValue(
@@ -454,24 +442,11 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
{renderTextField("director", intl.formatMessage({ id: "director" }))} {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}> <Form.Group controlId="url" as={Row}>
{FormUtils.renderLabel({ <Form.Label column xs={labelXS} xl={labelXL}>
title: intl.formatMessage({ id: "url" }), <FormattedMessage id="url" />
})} </Form.Label>
<Col xs={9}> <Col xs={fieldXS} xl={fieldXL}>
<URLField <URLField
{...formik.getFieldProps("url")} {...formik.getFieldProps("url")}
onScrapeClick={onScrapeMovieURL} onScrapeClick={onScrapeMovieURL}
@@ -481,10 +456,10 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
</Form.Group> </Form.Group>
<Form.Group controlId="synopsis" as={Row}> <Form.Group controlId="synopsis" as={Row}>
{FormUtils.renderLabel({ <Form.Label column xs={labelXS} xl={labelXL}>
title: intl.formatMessage({ id: "synopsis" }), <FormattedMessage id="synopsis" />
})} </Form.Label>
<Col xs={9}> <Col xs={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
as="textarea" as="textarea"
className="text-input" className="text-input"
@@ -498,6 +473,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
<DetailsEditNavbar <DetailsEditNavbar
objectName={movie?.name ?? intl.formatMessage({ id: "movie" })} objectName={movie?.name ?? intl.formatMessage({ id: "movie" })}
isNew={isNew} isNew={isNew}
classNames="col-xl-9 mt-3"
isEditing={isEditing} isEditing={isEditing}
onToggleEdit={onCancel} onToggleEdit={onCancel}
onSave={formik.handleSubmit} onSave={formik.handleSubmit}

View File

@@ -28,12 +28,10 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-evenly; justify-content: space-evenly;
margin: 1rem;
max-width: 100%; max-width: 100%;
.movie-image-container { .movie-image-container {
box-shadow: none; box-shadow: none;
margin: 1rem;
} }
img { img {

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Button, Tabs, Tab, Col, Row } from "react-bootstrap"; 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 { useParams, useHistory } from "react-router-dom";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import cx from "classnames"; import cx from "classnames";
@@ -13,7 +13,6 @@ import {
mutateMetadataAutoTag, mutateMetadataAutoTag,
} from "src/core/StashService"; } from "src/core/StashService";
import { Counter } from "src/components/Shared/Counter"; import { Counter } from "src/components/Shared/Counter";
import { CountryFlag } from "src/components/Shared/CountryFlag";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { ErrorMessage } from "src/components/Shared/ErrorMessage";
import { Icon } from "src/components/Shared/Icon"; import { Icon } from "src/components/Shared/Icon";
@@ -23,7 +22,10 @@ import { useToast } from "src/hooks/Toast";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { PerformerDetailsPanel } from "./PerformerDetailsPanel"; import {
CompressedPerformerDetailsPanel,
PerformerDetailsPanel,
} from "./PerformerDetailsPanel";
import { PerformerScenesPanel } from "./PerformerScenesPanel"; import { PerformerScenesPanel } from "./PerformerScenesPanel";
import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel"; import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel";
import { PerformerMoviesPanel } from "./PerformerMoviesPanel"; import { PerformerMoviesPanel } from "./PerformerMoviesPanel";
@@ -31,16 +33,16 @@ import { PerformerImagesPanel } from "./PerformerImagesPanel";
import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel"; import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel";
import { PerformerEditPanel } from "./PerformerEditPanel"; import { PerformerEditPanel } from "./PerformerEditPanel";
import { PerformerSubmitButton } from "./PerformerSubmitButton"; import { PerformerSubmitButton } from "./PerformerSubmitButton";
import GenderIcon from "../GenderIcon";
import { import {
faChevronDown,
faChevronUp,
faHeart, faHeart,
faLink, faLink,
faChevronRight,
faChevronLeft,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons"; import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons";
import { IUIConfig } from "src/core/config"; import { IUIConfig } from "src/core/config";
import { useRatingKeybinds } from "src/hooks/keybinds"; import { useRatingKeybinds } from "src/hooks/keybinds";
import ImageUtils from "src/utils/image";
interface IProps { interface IProps {
performer: GQL.PerformerDataFragment; performer: GQL.PerformerDataFragment;
@@ -55,16 +57,20 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
const intl = useIntl(); const intl = useIntl();
const { tab = "details" } = useParams<IPerformerParams>(); const { tab = "details" } = useParams<IPerformerParams>();
const [collapsed, setCollapsed] = useState(false);
// Configuration settings // Configuration settings
const { configuration } = React.useContext(ConfigurationContext); const { configuration } = React.useContext(ConfigurationContext);
const abbreviateCounter = const uiConfig = configuration?.ui as IUIConfig | undefined;
(configuration?.ui as IUIConfig)?.abbreviateCounters ?? false; 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 [isEditing, setIsEditing] = useState<boolean>(false);
const [image, setImage] = useState<string | null>(); const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false); const [encodingImage, setEncodingImage] = useState<boolean>(false);
const [loadStickyHeader, setLoadStickyHeader] = useState<boolean>(false);
const activeImage = useMemo(() => { const activeImage = useMemo(() => {
const performerImage = performer.image_path; const performerImage = performer.image_path;
@@ -99,10 +105,10 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
tab === "movies" || tab === "movies" ||
tab == "appearswith" tab == "appearswith"
? tab ? tab
: "details"; : "scenes";
const setActiveTabKey = (newTab: string | null) => { const setActiveTabKey = (newTab: string | null) => {
if (tab !== newTab) { if (tab !== newTab) {
const tabParam = newTab === "details" ? "" : `/${newTab}`; const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
history.replace(`/performers/${performer.id}${tabParam}`); history.replace(`/performers/${performer.id}${tabParam}`);
} }
}; };
@@ -126,7 +132,6 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
Mousetrap.bind("a", () => setActiveTabKey("details"));
Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("e", () => toggleEditing());
Mousetrap.bind("c", () => setActiveTabKey("scenes")); Mousetrap.bind("c", () => setActiveTabKey("scenes"));
Mousetrap.bind("g", () => setActiveTabKey("galleries")); Mousetrap.bind("g", () => setActiveTabKey("galleries"));
@@ -186,44 +191,24 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
if (activeImage) { if (activeImage) {
return ( return (
<Button variant="link" onClick={() => showLightbox()}> <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> </Button>
); );
} }
} }
const renderTabs = () => ( const renderTabs = () => (
<React.Fragment> <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 <Tabs
activeKey={activeTabKey} activeKey={activeTabKey}
onSelect={setActiveTabKey} onSelect={setActiveTabKey}
id="performer-details" id="performer-details"
unmountOnExit unmountOnExit
> >
<Tab eventKey="details" title={intl.formatMessage({ id: "details" })}>
<PerformerDetailsPanel performer={performer} />
</Tab>
<Tab <Tab
eventKey="scenes" eventKey="scenes"
title={ title={
@@ -318,7 +303,24 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
</React.Fragment> </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) { if (isEditing) {
return ( return (
<PerformerEditPanel <PerformerEditPanel
@@ -330,37 +332,83 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
setEncodingImage={setEncodingImage} 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() { function getCollapseButtonIcon() {
if (performer?.birthdate) { return collapsed ? faChevronDown : faChevronUp;
// calculate the age from birthdate. In future, this should probably be }
// provided by the server
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 ( return (
<div> <PerformerDetailsPanel
<span className="age"> performer={performer}
{TextUtils.age(performer.birthdate, performer.death_date)} collapsed={collapsed}
</span> fullWidth={!collapsed && !compactExpandedDetails}
<span className="age-tail"> />
{" "}
<FormattedMessage id="years_old" />
</span>
</div>
); );
} }
} }
function maybeRenderCompressedDetails() {
if (!isEditing && loadStickyHeader) {
return <CompressedPerformerDetailsPanel performer={performer} />;
}
}
function maybeRenderTab() {
if (!isEditing) {
return renderTabs();
}
}
function maybeRenderAliases() { function maybeRenderAliases() {
if (performer?.alias_list?.length) { if (performer?.alias_list?.length) {
return ( return (
<div> <div>
<span className="alias-head"> <span className="alias-head">{performer.alias_list?.join(", ")}</span>
<FormattedMessage id="also_known_as" />{" "}
</span>
<span className="alias">{performer.alias_list?.join(", ")}</span>
</div> </div>
); );
} }
@@ -392,7 +440,28 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
} }
} }
const renderClickableIcons = () => ( 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"> <span className="name-icons">
<Button <Button
className={cx( className={cx(
@@ -404,7 +473,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
<Icon icon={faHeart} /> <Icon icon={faHeart} />
</Button> </Button>
{performer.url && ( {performer.url && (
<Button className="minimal icon-link"> <Button className="minimal icon-link" title={performer.url}>
<a <a
href={TextUtils.sanitiseURL(performer.url)} href={TextUtils.sanitiseURL(performer.url)}
className="link" className="link"
@@ -415,8 +484,20 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
</a> </a>
</Button> </Button>
)} )}
{(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 && ( {performer.twitter && (
<Button className="minimal icon-link"> <Button className="minimal icon-link" title={performer.twitter}>
<a <a
href={TextUtils.sanitiseURL( href={TextUtils.sanitiseURL(
performer.twitter, performer.twitter,
@@ -431,7 +512,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
</Button> </Button>
)} )}
{performer.instagram && ( {performer.instagram && (
<Button className="minimal icon-link"> <Button className="minimal icon-link" title={performer.instagram}>
<a <a
href={TextUtils.sanitiseURL( href={TextUtils.sanitiseURL(
performer.instagram, performer.instagram,
@@ -447,6 +528,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
)} )}
</span> </span>
); );
}
if (isDestroying) if (isDestroying)
return ( return (
@@ -455,10 +537,6 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
/> />
); );
function getCollapseButtonIcon() {
return collapsed ? faChevronRight : faChevronLeft;
}
return ( return (
<div id="performer-page" className="row"> <div id="performer-page" className="row">
<Helmet> <Helmet>
@@ -466,48 +544,48 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
</Helmet> </Helmet>
<div <div
className={`performer-image-container details-tab text-center text-center ${ className={`detail-header ${isEditing ? "edit" : ""} ${
collapsed ? "collapsed" : "" collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
}`} }`}
> >
{maybeRenderHeaderBackgroundImage()}
<div className="detail-container">
<div className="detail-header-image">
{encodingImage ? ( {encodingImage ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
/>
) : ( ) : (
renderImage() renderImage()
)} )}
</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={`content-container ${collapsed ? "expanded" : ""}`}>
<div className="row"> <div className="row">
<div className="performer-head col"> <div className="performer-head col">
<h2> <h2>
<GenderIcon
gender={performer.gender}
className="gender-icon mr-2 fi"
/>
<CountryFlag country={performer.country} className="mr-2" />
<span className="performer-name">{performer.name}</span> <span className="performer-name">{performer.name}</span>
{performer.disambiguation && ( {performer.disambiguation && (
<span className="performer-disambiguation"> <span className="performer-disambiguation">
{` (${performer.disambiguation})`} {` (${performer.disambiguation})`}
</span> </span>
)} )}
{maybeRenderShowCollapseButton()}
{renderClickableIcons()} {renderClickableIcons()}
</h2> </h2>
{maybeRenderAliases()}
<RatingSystem <RatingSystem
value={performer.rating100 ?? undefined} value={performer.rating100 ?? undefined}
onSetRating={(value) => setRating(value ?? null)} onSetRating={(value) => setRating(value ?? null)}
/> />
{maybeRenderAliases()} {maybeRenderDetails()}
{maybeRenderAge()} {maybeRenderEditPanel()}
</div> </div>
</div> </div>
</div>
</div>
{maybeRenderCompressedDetails()}
<div className="detail-body">
<div className="performer-body"> <div className="performer-body">
<div className="performer-tabs">{renderTabsOrEditPanel()}</div> <div className="performer-tabs">{maybeRenderTab()}</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -42,7 +42,11 @@ const PerformerCreate: React.FC = () => {
function renderPerformerImage() { function renderPerformerImage() {
if (encodingImage) { if (encodingImage) {
return <LoadingIndicator message="Encoding image..." />; return (
<LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
/>
);
} }
if (image) { if (image) {
return ( return (

View File

@@ -1,19 +1,23 @@
import React from "react"; import React from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { TagLink } from "src/components/Shared/TagLink"; import { TagLink } from "src/components/Shared/TagLink";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { getStashboxBase } from "src/utils/stashbox"; 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 { cmToImperial, cmToInches, kgToLbs } from "src/utils/units";
import { DetailItem } from "src/components/Shared/DetailItem";
import { CountryFlag } from "src/components/Shared/CountryFlag";
interface IPerformerDetails { interface IPerformerDetails {
performer: GQL.PerformerDataFragment; performer: GQL.PerformerDataFragment;
collapsed?: boolean;
fullWidth?: boolean;
} }
export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
performer, performer,
collapsed,
fullWidth,
}) => { }) => {
// Network state // Network state
const intl = useIntl(); const intl = useIntl();
@@ -22,20 +26,12 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
if (!performer.tags.length) { if (!performer.tags.length) {
return; return;
} }
return ( return (
<>
<dt>
<FormattedMessage id="tags" />
</dt>
<dd>
<ul className="pl-0"> <ul className="pl-0">
{(performer.tags ?? []).map((tag) => ( {(performer.tags ?? []).map((tag) => (
<TagLink key={tag.id} tagType="performer" tag={tag} /> <TagLink key={tag.id} tagType="performer" tag={tag} />
))} ))}
</ul> </ul>
</dd>
</>
); );
} }
@@ -45,9 +41,6 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
} }
return ( return (
<>
<dt>StashIDs</dt>
<dd>
<ul className="pl-0"> <ul className="pl-0">
{performer.stash_ids.map((stashID) => { {performer.stash_ids.map((stashID) => {
const base = getStashboxBase(stashID.endpoint); const base = getStashboxBase(stashID.endpoint);
@@ -69,8 +62,6 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
); );
})} })}
</ul> </ul>
</dd>
</>
); );
} }
@@ -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 ( return (
<dl className="details-list"> <>
<TextField <DetailItem
id="gender" id="tattoos"
value={ value={performer?.tattoos}
performer.gender fullWidth={fullWidth}
? intl.formatMessage({ id: "gender_types." + performer.gender }) />
: undefined <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 (
<div className="detail-group">
{performer.gender ? (
<DetailItem
id="gender"
value={intl.formatMessage({ id: "gender_types." + performer.gender })}
fullWidth={fullWidth}
/> />
<TextField ) : (
id="birthdate" ""
value={TextUtils.formatDate(intl, performer.birthdate ?? undefined)} )}
<DetailItem
id="age"
value={TextUtils.age(performer.birthdate, performer.death_date)}
title={TextUtils.formatDate(intl, performer.birthdate ?? undefined)}
fullWidth={fullWidth}
/> />
<TextField <DetailItem id="death_date" value={performer.death_date} />
id="death_date" {performer.country ? (
value={TextUtils.formatDate(intl, performer.death_date ?? undefined)} <DetailItem
/>
<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" id="country"
value={ value={
getCountryByISO(performer.country, intl.locale) ?? performer.country <CountryFlag
country={performer.country}
className="mr-2"
includeName={true}
/>
} }
fullWidth={fullWidth}
/> />
) : (
{!!performer.height_cm && ( ""
<>
<dt>
<FormattedMessage id="height" />
</dt>
<dd>{formatHeight(performer.height_cm)}</dd>
</>
)} )}
<DetailItem
{!!performer.weight && ( id="ethnicity"
<> value={performer?.ethnicity}
<dt> fullWidth={fullWidth}
<FormattedMessage id="weight" />
</dt>
<dd>{formatWeight(performer.weight)}</dd>
</>
)}
{(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 ?? "")}
/> />
<URLField <DetailItem
id="twitter" id="hair_color"
value={performer.twitter} value={performer?.hair_color}
url={TextUtils.sanitiseURL( fullWidth={fullWidth}
performer.twitter ?? "",
TextUtils.twitterURL
)}
/> />
<URLField <DetailItem
id="instagram" id="eye_color"
value={performer.instagram} value={performer?.eye_color}
url={TextUtils.sanitiseURL( fullWidth={fullWidth}
performer.instagram ?? "",
TextUtils.instagramURL
)}
/> />
{renderTagsField()} <DetailItem
{renderStashIDs()} id="height"
</dl> 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 { .performer-head {
display: inline-block; display: inline-block;
margin-bottom: 2rem;
vertical-align: top; vertical-align: top;
.name-icons { .name-icons {
margin-left: 10px;
.not-favorite { .not-favorite {
color: rgba(191, 204, 214, 0.5); color: rgba(191, 204, 214, 0.5);
} }
@@ -213,4 +210,5 @@
/* stylelint-disable */ /* stylelint-disable */
font-size: 0.875em; font-size: 0.875em;
/* stylelint-enable */ /* stylelint-enable */
padding-right: 0.5rem;
} }

View File

@@ -683,7 +683,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
const image = useMemo(() => { const image = useMemo(() => {
if (encodingImage) { if (encodingImage) {
return <LoadingIndicator message="Encoding image..." />; return (
<LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
/>
);
} }
if (coverImagePreview) { if (coverImagePreview) {

View File

@@ -493,6 +493,64 @@ export const SettingsInterfacePanel: React.FC = () => {
/> />
</SettingSection> </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"> <SettingSection headingID="config.ui.editing.heading">
<div className="setting-group"> <div className="setting-group">
<div className="setting"> <div className="setting">

View File

@@ -5,11 +5,13 @@ import { getCountryByISO } from "src/utils/country";
interface ICountryFlag { interface ICountryFlag {
country?: string | null; country?: string | null;
className?: string; className?: string;
includeName?: boolean;
} }
export const CountryFlag: React.FC<ICountryFlag> = ({ export const CountryFlag: React.FC<ICountryFlag> = ({
className, className,
country: isoCountry, country: isoCountry,
includeName,
}) => { }) => {
const { locale } = useIntl(); const { locale } = useIntl();
@@ -18,9 +20,12 @@ export const CountryFlag: React.FC<ICountryFlag> = ({
if (!isoCountry || !country) return <></>; if (!isoCountry || !country) return <></>;
return ( return (
<>
{includeName ? country : ""}
<span <span
className={`${className ?? ""} fi fi-${isoCountry.toLowerCase()}`} className={`${className ?? ""} fi fi-${isoCountry.toLowerCase()}`}
title={country} 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); props.onSelectedChanged(!props.selected, shiftKey);
event.preventDefault(); event.preventDefault();
} }
window.scrollTo(0, 0);
} }
function handleDrag(event: React.DragEvent<HTMLElement>) { function handleDrag(event: React.DragEvent<HTMLElement>) {

View File

@@ -46,12 +46,12 @@
margin-right: 0.5rem; margin-right: 0.5rem;
white-space: nowrap; white-space: nowrap;
} }
}
div:nth-last-child(2) { .detail-header.edit .details-edit div:nth-last-child(2) {
flex: 1; flex: 1;
max-width: 100%; max-width: 100%;
} }
}
.select-suggest { .select-suggest {
&:hover { &:hover {

View File

@@ -26,14 +26,22 @@ import { StudioImagesPanel } from "./StudioImagesPanel";
import { StudioChildrenPanel } from "./StudioChildrenPanel"; import { StudioChildrenPanel } from "./StudioChildrenPanel";
import { StudioPerformersPanel } from "./StudioPerformersPanel"; import { StudioPerformersPanel } from "./StudioPerformersPanel";
import { StudioEditPanel } from "./StudioEditPanel"; import { StudioEditPanel } from "./StudioEditPanel";
import { StudioDetailsPanel } from "./StudioDetailsPanel"; import {
CompressedStudioDetailsPanel,
StudioDetailsPanel,
} from "./StudioDetailsPanel";
import { StudioMoviesPanel } from "./StudioMoviesPanel"; import { StudioMoviesPanel } from "./StudioMoviesPanel";
import { import {
faTrashAlt, faTrashAlt,
faChevronRight, faLink,
faChevronLeft, faChevronDown,
faChevronUp,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { IUIConfig } from "src/core/config"; 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 { interface IProps {
studio: GQL.StudioDataFragment; studio: GQL.StudioDataFragment;
@@ -49,12 +57,16 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
const intl = useIntl(); const intl = useIntl();
const { tab = "details" } = useParams<IStudioParams>(); const { tab = "details" } = useParams<IStudioParams>();
const [collapsed, setCollapsed] = useState(false);
// Configuration settings // Configuration settings
const { configuration } = React.useContext(ConfigurationContext); const { configuration } = React.useContext(ConfigurationContext);
const abbreviateCounter = const uiConfig = configuration?.ui as IUIConfig | undefined;
(configuration?.ui as IUIConfig)?.abbreviateCounters ?? false; 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 // Editing state
const [isEditing, setIsEditing] = useState<boolean>(false); 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) { async function onSave(input: GQL.StudioCreateInput) {
await updateStudio({ await updateStudio({
variables: { 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) { function toggleEditing(value?: boolean) {
if (value !== undefined) { if (value !== undefined) {
setIsEditing(value); setIsEditing(value);
@@ -184,7 +231,14 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
} }
if (studioImage) { if (studioImage) {
return <img className="logo" alt={studio.name} src={studioImage} />; return (
<img
className="logo"
alt={studio.name}
src={studioImage}
onLoad={ImageUtils.verifyImageSize}
/>
);
} }
} }
@@ -203,59 +257,71 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
} }
}; };
function getCollapseButtonIcon() { const renderClickableIcons = () => (
return collapsed ? faChevronRight : faChevronLeft; <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 ( return (
<div className="row"> <StudioDetailsPanel
<div
className={`studio-details details-tab ${collapsed ? "collapsed" : ""}`}
>
<div className="text-center">
{encodingImage ? (
<LoadingIndicator message="Encoding image..." />
) : (
renderImage()
)}
</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} studio={studio}
onSubmit={onSave} collapsed={collapsed}
onCancel={() => toggleEditing()} fullWidth={!collapsed && !compactExpandedDetails}
onDelete={onDelete}
setImage={setImage}
setEncodingImage={setEncodingImage}
/> />
)} );
</div> }
<div className="details-divider d-none d-xl-block"> }
<Button onClick={() => setCollapsed(!collapsed)}>
function maybeRenderShowCollapseButton() {
if (!isEditing) {
return (
<span className="detail-expand-collapse">
<Button
className="minimal expand-collapse"
onClick={() => setCollapsed(!collapsed)}
>
<Icon className="fa-fw" icon={getCollapseButtonIcon()} /> <Icon className="fa-fw" icon={getCollapseButtonIcon()} />
</Button> </Button>
</div> </span>
<div className={`col content-container ${collapsed ? "expanded" : ""}`}> );
}
}
function maybeRenderCompressedDetails() {
if (!isEditing && loadStickyHeader) {
return <CompressedStudioDetailsPanel studio={studio} />;
}
}
const renderTabs = () => (
<React.Fragment>
<Tabs <Tabs
id="studio-tabs" id="studio-tabs"
mountOnEnter mountOnEnter
@@ -372,6 +438,108 @@ const StudioPage: React.FC<IProps> = ({ studio }) => {
/> />
</Tab> </Tab>
</Tabs> </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 id="studio-page" className="row">
<Helmet>
<title>{studio.name ?? intl.formatMessage({ id: "studio" })}</title>
</Helmet>
<div
className={`detail-header ${isEditing ? "edit" : ""} ${
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
}`}
>
{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>
</div>
{maybeRenderCompressedDetails()}
<div className="detail-body">
<div className="studio-body">
<div className="studio-tabs">{maybeRenderTab()}</div>
</div>
</div> </div>
{renderDeleteAlert()} {renderDeleteAlert()}
</div> </div>

View File

@@ -58,7 +58,9 @@ const StudioCreate: React.FC = () => {
</h2> </h2>
<div className="text-center"> <div className="text-center">
{encodingImage ? ( {encodingImage ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
/>
) : ( ) : (
renderImage() renderImage()
)} )}

View File

@@ -1,66 +1,24 @@
import React from "react"; import React from "react";
import { Badge } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import TextUtils from "src/utils/text"; import { DetailItem } from "src/components/Shared/DetailItem";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { TextField, URLField } from "src/utils/field";
interface IStudioDetailsPanel { interface IStudioDetailsPanel {
studio: GQL.StudioDataFragment; studio: GQL.StudioDataFragment;
collapsed?: boolean;
fullWidth?: boolean;
} }
export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
studio, 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() { function renderStashIDs() {
if (!studio.stash_ids?.length) { if (!studio.stash_ids?.length) {
return; return;
} }
return ( return (
<>
<dt>
<FormattedMessage id="stash_ids" />
</dt>
<dd>
<ul className="pl-0"> <ul className="pl-0">
{studio.stash_ids.map((stashID) => { {studio.stash_ids.map((stashID) => {
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
@@ -82,38 +40,61 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
); );
})} })}
</ul> </ul>
</dd>
</>
); );
} }
function maybeRenderExtraDetails() {
if (!collapsed) {
return ( return (
<div className="studio-details"> <DetailItem
<div> id="StashIDs"
<h2>{studio.name}</h2> value={renderStashIDs()}
</div> fullWidth={fullWidth}
<dl className="details-list">
<URLField
id="url"
value={studio.url}
url={TextUtils.sanitiseURL(studio.url ?? "")}
/> />
);
}
}
<TextField id="details" value={studio.details} /> return (
<div className="detail-group">
<URLField <DetailItem id="details" value={studio.details} fullWidth={fullWidth} />
<DetailItem
id="parent_studios" id="parent_studios"
value={studio.parent_studio?.name} value={
url={`/studios/${studio.parent_studio?.id}`} studio.parent_studio?.name ? (
trusted <a href={`/studios/${studio.parent_studio?.id}`} target="_self">
target="_self" {studio.parent_studio.name}
</a>
) : (
""
)
}
fullWidth={fullWidth}
/> />
{maybeRenderExtraDetails()}
{renderRatingField()} </div>
{renderTagsList()} );
{renderStashIDs()} };
</dl>
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> </div>
); );
}; };

View File

@@ -8,16 +8,12 @@ import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { StudioSelect } from "src/components/Shared/Select"; import { StudioSelect } from "src/components/Shared/Select";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { Button, Form, Col, Row } from "react-bootstrap"; import { Button, Form, Col, Row } from "react-bootstrap";
import FormUtils from "src/utils/form";
import ImageUtils from "src/utils/image"; import ImageUtils from "src/utils/image";
import { getStashIDs } from "src/utils/stashIds"; import { getStashIDs } from "src/utils/stashIds";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { Prompt } from "react-router-dom"; import { Prompt } from "react-router-dom";
import { StringListInput } from "../../Shared/StringListInput"; import { StringListInput } from "../../Shared/StringListInput";
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; 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 isEqual from "lodash-es/isEqual";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { handleUnsavedChanges } from "src/utils/navigation"; import { handleUnsavedChanges } from "src/utils/navigation";
@@ -43,7 +39,11 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
const Toast = useToast(); const Toast = useToast();
const isNew = studio.id === undefined; const isNew = studio.id === undefined;
const { configuration } = React.useContext(ConfigurationContext);
const labelXS = 3;
const labelXL = 2;
const fieldXS = 9;
const fieldXL = 7;
// Network state // Network state
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -53,7 +53,6 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
url: yup.string().ensure(), url: yup.string().ensure(),
details: yup.string().ensure(), details: yup.string().ensure(),
parent_id: yup.string().required().nullable(), parent_id: yup.string().required().nullable(),
rating100: yup.number().nullable().defined(),
aliases: yup aliases: yup
.array(yup.string().required()) .array(yup.string().required())
.defined() .defined()
@@ -85,7 +84,6 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
url: studio.url ?? "", url: studio.url ?? "",
details: studio.details ?? "", details: studio.details ?? "",
parent_id: studio.parent_studio?.id ?? null, parent_id: studio.parent_studio?.id ?? null,
rating100: studio.rating100 ?? null,
aliases: studio.aliases ?? [], aliases: studio.aliases ?? [],
ignore_auto_tag: studio.ignore_auto_tag ?? false, ignore_auto_tag: studio.ignore_auto_tag ?? false,
stash_ids: getStashIDs(studio.stash_ids), stash_ids: getStashIDs(studio.stash_ids),
@@ -112,16 +110,6 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
setEncodingImage(encodingImage); setEncodingImage(encodingImage);
}, [setEncodingImage, encodingImage]); }, [setEncodingImage, encodingImage]);
function setRating(v: number) {
formik.setFieldValue("rating100", v);
}
useRatingKeybinds(
true,
configuration?.ui?.ratingSystemOptions?.type,
setRating
);
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
Mousetrap.bind("s s", () => { Mousetrap.bind("s s", () => {
@@ -171,8 +159,10 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
return ( return (
<Row> <Row>
<Form.Label column>StashIDs</Form.Label> <Form.Label column xs={labelXS} xl={labelXL}>
<Col xs={9}> StashIDs
</Form.Label>
<Col xs={fieldXS} xl={fieldXL}>
<ul className="pl-0"> <ul className="pl-0">
{formik.values.stash_ids.map((stashID) => { {formik.values.stash_ids.map((stashID) => {
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; 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 noValidate onSubmit={formik.handleSubmit} id="studio-edit">
<Form.Group controlId="name" as={Row}> <Form.Group controlId="name" as={Row}>
{FormUtils.renderLabel({ <Form.Label column xs={labelXS} xl={labelXL}>
title: intl.formatMessage({ id: "name" }), <FormattedMessage id="name" />
})} </Form.Label>
<Col xs={9}> <Col xs={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
className="text-input" className="text-input"
{...formik.getFieldProps("name")} {...formik.getFieldProps("name")}
@@ -250,11 +240,25 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
</Col> </Col>
</Form.Group> </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}> <Form.Group controlId="url" as={Row}>
{FormUtils.renderLabel({ <Form.Label column xs={labelXS} xl={labelXL}>
title: intl.formatMessage({ id: "url" }), <FormattedMessage id="url" />
})} </Form.Label>
<Col xs={9}> <Col xs={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
className="text-input" className="text-input"
{...formik.getFieldProps("url")} {...formik.getFieldProps("url")}
@@ -267,10 +271,10 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
</Form.Group> </Form.Group>
<Form.Group controlId="details" as={Row}> <Form.Group controlId="details" as={Row}>
{FormUtils.renderLabel({ <Form.Label column xs={labelXS} xl={labelXL}>
title: intl.formatMessage({ id: "details" }), <FormattedMessage id="details" />
})} </Form.Label>
<Col xs={9}> <Col xs={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
as="textarea" as="textarea"
className="text-input" className="text-input"
@@ -284,10 +288,10 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
</Form.Group> </Form.Group>
<Form.Group controlId="parent_studio" as={Row}> <Form.Group controlId="parent_studio" as={Row}>
{FormUtils.renderLabel({ <Form.Label column xs={labelXS} xl={labelXL}>
title: intl.formatMessage({ id: "parent_studios" }), <FormattedMessage id="parent_studios" />
})} </Form.Label>
<Col xs={9}> <Col xs={fieldXS} xl={fieldXL}>
<StudioSelect <StudioSelect
onSelect={(items) => onSelect={(items) =>
formik.setFieldValue( formik.setFieldValue(
@@ -301,44 +305,16 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
</Col> </Col>
</Form.Group> </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()} {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> </Form>
<hr /> <hr />
<Form.Group controlId="ignore-auto-tag" as={Row}> <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" /> <FormattedMessage id="ignore_auto_tag" />
</Form.Label> </Form.Label>
<Col xs={9}> <Col xs={fieldXS} xl={fieldXL}>
<Form.Check <Form.Check
{...formik.getFieldProps({ {...formik.getFieldProps({
name: "ignore_auto_tag", name: "ignore_auto_tag",
@@ -350,6 +326,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
<DetailsEditNavbar <DetailsEditNavbar
objectName={studio?.name ?? intl.formatMessage({ id: "studio" })} objectName={studio?.name ?? intl.formatMessage({ id: "studio" })}
classNames="col-xl-9 mt-3"
isNew={isNew} isNew={isNew}
isEditing isEditing
onToggleEdit={onCancel} 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 React, { useEffect, useState } from "react";
import { useParams, useHistory } from "react-router-dom"; import { useParams, useHistory } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
@@ -26,17 +26,18 @@ import { TagMarkersPanel } from "./TagMarkersPanel";
import { TagImagesPanel } from "./TagImagesPanel"; import { TagImagesPanel } from "./TagImagesPanel";
import { TagPerformersPanel } from "./TagPerformersPanel"; import { TagPerformersPanel } from "./TagPerformersPanel";
import { TagGalleriesPanel } from "./TagGalleriesPanel"; import { TagGalleriesPanel } from "./TagGalleriesPanel";
import { TagDetailsPanel } from "./TagDetailsPanel"; import { CompressedTagDetailsPanel, TagDetailsPanel } from "./TagDetailsPanel";
import { TagEditPanel } from "./TagEditPanel"; import { TagEditPanel } from "./TagEditPanel";
import { TagMergeModal } from "./TagMergeDialog"; import { TagMergeModal } from "./TagMergeDialog";
import { import {
faChevronDown,
faChevronUp,
faSignInAlt, faSignInAlt,
faSignOutAlt, faSignOutAlt,
faTrashAlt, faTrashAlt,
faChevronRight,
faChevronLeft,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { IUIConfig } from "src/core/config"; import { IUIConfig } from "src/core/config";
import ImageUtils from "src/utils/image";
interface IProps { interface IProps {
tag: GQL.TagDataFragment; tag: GQL.TagDataFragment;
@@ -51,12 +52,16 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
const [collapsed, setCollapsed] = useState(false);
// Configuration settings // Configuration settings
const { configuration } = React.useContext(ConfigurationContext); const { configuration } = React.useContext(ConfigurationContext);
const abbreviateCounter = const uiConfig = configuration?.ui as IUIConfig | undefined;
(configuration?.ui as IUIConfig)?.abbreviateCounters ?? false; 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>(); 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) { async function onSave(input: GQL.TagCreateInput) {
const oldRelations = { const oldRelations = {
parents: tag.parents ?? [], 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) { function toggleEditing(value?: boolean) {
if (value !== undefined) { if (value !== undefined) {
setIsEditing(value); setIsEditing(value);
@@ -225,7 +274,14 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
} }
if (tagImage) { if (tagImage) {
return <img className="logo" alt={tag.name} src={tagImage} />; return (
<img
className="logo"
alt={tag.name}
src={tagImage}
onLoad={ImageUtils.verifyImageSize}
/>
);
} }
} }
@@ -270,32 +326,32 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
); );
} }
function getCollapseButtonIcon() { function maybeRenderDetails() {
return collapsed ? faChevronRight : faChevronLeft; 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 ( return (
<>
<Helmet>
<title>{tag.name}</title>
</Helmet>
<div className="row">
<div
className={`tag-details details-tab ${collapsed ? "collapsed" : ""}`}
>
<div className="text-center logo-container">
{encodingImage ? (
<LoadingIndicator message="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 <DetailsEditNavbar
objectName={tag.name} objectName={tag.name}
isNew={false} isNew={false}
@@ -309,24 +365,12 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
classNames="mb-2" classNames="mb-2"
customButtons={renderMergeButton()} customButtons={renderMergeButton()}
/> />
</> );
) : ( }
<TagEditPanel }
tag={tag}
onSubmit={onSave} const renderTabs = () => (
onCancel={() => toggleEditing()} <React.Fragment>
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 <Tabs
id="tag-tabs" id="tag-tabs"
mountOnEnter mountOnEnter
@@ -377,10 +421,7 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
</> </>
} }
> >
<TagGalleriesPanel <TagGalleriesPanel active={activeTabKey == "galleries"} tag={tag} />
active={activeTabKey == "galleries"}
tag={tag}
/>
</Tab> </Tab>
<Tab <Tab
eventKey="markers" eventKey="markers"
@@ -410,17 +451,86 @@ const TagPage: React.FC<IProps> = ({ tag }) => {
</> </>
} }
> >
<TagPerformersPanel <TagPerformersPanel active={activeTabKey == "performers"} tag={tag} />
active={activeTabKey == "performers"}
tag={tag}
/>
</Tab> </Tab>
</Tabs> </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={`detail-header ${isEditing ? "edit" : ""} ${
collapsed ? "collapsed" : !compactExpandedDetails ? "full-width" : ""
}`}
>
{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="tag-name">{tag.name}</span>
{maybeRenderShowCollapseButton()}
</h2>
{maybeRenderAliases()}
{maybeRenderDetails()}
{maybeRenderEditPanel()}
</div>
</div>
</div>
</div>
{maybeRenderCompressedDetails()}
<div className="detail-body">
<div className="tag-body">
<div className="tag-tabs">{maybeRenderTab()}</div>
</div>
</div> </div>
{renderDeleteAlert()} {renderDeleteAlert()}
{renderMergeDialog()} {renderMergeDialog()}
</div> </div>
</>
); );
}; };

View File

@@ -60,7 +60,9 @@ const TagCreate: React.FC = () => {
<div className="tag-details col-md-8"> <div className="tag-details col-md-8">
<div className="text-center logo-container"> <div className="text-center logo-container">
{encodingImage ? ( {encodingImage ? (
<LoadingIndicator message="Encoding image..." /> <LoadingIndicator
message={`${intl.formatMessage({ id: "encoding_image" })}...`}
/>
) : ( ) : (
renderImage() renderImage()
)} )}

View File

@@ -1,53 +1,28 @@
import React from "react"; import React from "react";
import { Badge } from "react-bootstrap"; import { Badge } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { DetailItem } from "src/components/Shared/DetailItem";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
interface ITagDetails { interface ITagDetails {
tag: GQL.TagDataFragment; tag: GQL.TagDataFragment;
fullWidth?: boolean;
} }
export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag }) => { export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag, fullWidth }) => {
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>
);
}
function renderParentsField() { function renderParentsField() {
if (!tag.parents?.length) { if (!tag.parents?.length) {
return; return;
} }
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) => ( {tag.parents.map((p) => (
<Badge key={p.id} className="tag-item" variant="secondary"> <Badge key={p.id} className="tag-item" variant="secondary">
<Link to={`/tags/${p.id}`}>{p.name}</Link> <Link to={`/tags/${p.id}`}>{p.name}</Link>
</Badge> </Badge>
))} ))}
</dd> </>
</dl>
); );
} }
@@ -57,26 +32,54 @@ export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag }) => {
} }
return ( 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) => ( {tag.children.map((c) => (
<Badge key={c.id} className="tag-item" variant="secondary"> <Badge key={c.id} className="tag-item" variant="secondary">
<Link to={`/tags/${c.id}`}>{c.name}</Link> <Link to={`/tags/${c.id}`}>{c.name}</Link>
</Badge> </Badge>
))} ))}
</dd> </>
</dl>
); );
} }
return ( return (
<> <div className="detail-group">
{renderAliasesField()} <DetailItem
{renderParentsField()} id="description"
{renderChildrenField()} 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 { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { TagSelect } from "src/components/Shared/Select"; import { TagSelect } from "src/components/Shared/Select";
import { Form, Col, Row } from "react-bootstrap"; import { Form, Col, Row } from "react-bootstrap";
import FormUtils from "src/utils/form";
import ImageUtils from "src/utils/image"; import ImageUtils from "src/utils/image";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { Prompt } from "react-router-dom"; import { Prompt } from "react-router-dom";
@@ -42,9 +41,9 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const labelXS = 3; const labelXS = 3;
const labelXL = 3; const labelXL = 2;
const fieldXS = 9; const fieldXS = 9;
const fieldXL = 9; const fieldXL = 7;
const schema = yup.object({ const schema = yup.object({
name: yup.string().required(), name: yup.string().required(),
@@ -204,10 +203,10 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
</Form.Group> </Form.Group>
<Form.Group controlId="description" as={Row}> <Form.Group controlId="description" as={Row}>
{FormUtils.renderLabel({ <Form.Label column xs={labelXS} xl={labelXL}>
title: intl.formatMessage({ id: "description" }), <FormattedMessage id="description" />
})} </Form.Label>
<Col xs={9}> <Col xs={fieldXS} xl={fieldXL}>
<Form.Control <Form.Control
as="textarea" as="textarea"
className="text-input" className="text-input"
@@ -218,15 +217,10 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
</Form.Group> </Form.Group>
<Form.Group controlId="parent_tags" as={Row}> <Form.Group controlId="parent_tags" as={Row}>
{FormUtils.renderLabel({ <Form.Label column xs={labelXS} xl={labelXL}>
title: intl.formatMessage({ id: "parent_tags" }), <FormattedMessage id="parent_tags" />
labelProps: { </Form.Label>
column: true, <Col xs={fieldXS} xl={fieldXL}>
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<TagSelect <TagSelect
isMulti isMulti
onSelect={(items) => onSelect={(items) =>
@@ -247,15 +241,10 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
</Form.Group> </Form.Group>
<Form.Group controlId="sub_tags" as={Row}> <Form.Group controlId="sub_tags" as={Row}>
{FormUtils.renderLabel({ <Form.Label column xs={labelXS} xl={labelXL}>
title: intl.formatMessage({ id: "sub_tags" }), <FormattedMessage id="sub_tags" />
labelProps: { </Form.Label>
column: true, <Col xs={fieldXS} xl={fieldXL}>
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<TagSelect <TagSelect
isMulti isMulti
onSelect={(items) => onSelect={(items) =>
@@ -294,6 +283,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
<DetailsEditNavbar <DetailsEditNavbar
objectName={tag?.name ?? intl.formatMessage({ id: "tag" })} objectName={tag?.name ?? intl.formatMessage({ id: "tag" })}
classNames="col-xl-9 mt-3"
isNew={isNew} isNew={isNew}
isEditing={isEditing} isEditing={isEditing}
onToggleEdit={onCancel} onToggleEdit={onCancel}

View File

@@ -43,6 +43,18 @@ export interface IUIConfig {
ratingSystemOptions?: RatingSystemOptions; 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 // if true the chromecast option will enabled
enableChromecast?: boolean; enableChromecast?: boolean;
// if true continue scene will always play from the beginning // 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` | [Edit mode] Unset rating (stars) |
| `r {0-9} {0-9}` | [Edit mode] Set rating (decimal - `r 0 0` for `10.0`) | | `r {0-9} {0-9}` | [Edit mode] Set rating (decimal - `r 0 0` for `10.0`) |
| ``r ` `` | [Edit mode] Unset rating (decimal) | | ``r ` `` | [Edit mode] Unset rating (decimal) |
| `,` | Expand/Collapse Details |
| `Ctrl + v` | Paste Movie image | | `Ctrl + v` | Paste Movie image |
[//]: # "Commented until implementation is dealt with" [//]: # "Commented until implementation is dealt with"
@@ -144,11 +145,11 @@
| Keyboard sequence | Action | | Keyboard sequence | Action |
|-------------------|--------| |-------------------|--------|
| `a` | Details tab |
| `c` | Scenes tab | | `c` | Scenes tab |
| `e` | Edit tab | | `e` | Edit tab |
| `o` | Operations tab | | `o` | Operations tab |
| `f` | Toggle favourite | | `f` | Toggle favourite |
| `,` | Expand/Collapse Details |
### Edit Performer tab shortcuts ### Edit Performer tab shortcuts
@@ -171,6 +172,7 @@
| `e` | Edit Studio | | `e` | Edit Studio |
| `s s` | Save Studio | | `s s` | Save Studio |
| `d d` | Delete Studio | | `d d` | Delete Studio |
| `,` | Expand/Collapse Details |
| `Ctrl + v` | Paste Studio image | | `Ctrl + v` | Paste Studio image |
## Tags Page shortcuts ## Tags Page shortcuts
@@ -186,4 +188,5 @@
| `e` | Edit Tag | | `e` | Edit Tag |
| `s s` | Save Tag | | `s s` | Save Tag |
| `d d` | Delete Tag | | `d d` | Delete Tag |
| `,` | Expand/Collapse Details |
| `Ctrl + v` | Paste Tag image | | `Ctrl + v` | Paste Tag image |

View File

@@ -39,10 +39,11 @@ html {
body { body {
color: $text-color; color: $text-color;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
margin: 0; margin: 0;
padding: 4rem 0 0 0; overflow-x: hidden;
padding: 3.5rem 0 0 0;
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
@media (orientation: portrait) { @media (orientation: portrait) {
@@ -61,6 +62,303 @@ dd {
white-space: pre-line; 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, .input-control,
.text-input { .text-input {
border: 0; 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) { @media (min-width: 576px) {
.zoom-0 { .zoom-0 {
width: 240px; width: 240px;

View File

@@ -40,6 +40,7 @@
"download_backup": "Download Backup", "download_backup": "Download Backup",
"edit": "Edit", "edit": "Edit",
"edit_entity": "Edit {entityType}", "edit_entity": "Edit {entityType}",
"encoding_image": "Encoding image",
"export": "Export", "export": "Export",
"export_all": "Export all…", "export_all": "Export all…",
"find": "Find", "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": { "funscript_offset": {
"description": "Time offset in milliseconds for interactive scripts playback.", "description": "Time offset in milliseconds for interactive scripts playback.",
"heading": "Funscript Offset (ms)" "heading": "Funscript Offset (ms)"

View File

@@ -70,6 +70,17 @@ const ImageUtils = {
onImageChange, onImageChange,
usePasteImage, usePasteImage,
imageToDataURL, 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; export default ImageUtils;