New patchable performer page components (#5897)

This commit is contained in:
CJ 2025-06-02 17:16:57 -07:00 committed by GitHub
parent d9a316d083
commit c66ef42480
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 263 additions and 213 deletions

View file

@ -46,6 +46,7 @@ import { AliasList } from "src/components/Shared/DetailsPage/AliasList";
import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
import { LightboxLink } from "src/hooks/Lightbox/LightboxLink"; import { LightboxLink } from "src/hooks/Lightbox/LightboxLink";
import { PatchComponent } from "src/patch"; import { PatchComponent } from "src/patch";
import { ILightboxImage } from "src/hooks/Lightbox/types";
interface IProps { interface IProps {
performer: GQL.PerformerDataFragment; performer: GQL.PerformerDataFragment;
@ -201,6 +202,34 @@ const PerformerTabs: React.FC<{
); );
}; };
interface IPerformerHeaderImageProps {
activeImage: string | null | undefined;
collapsed: boolean;
encodingImage: boolean;
lightboxImages: ILightboxImage[];
performer: GQL.PerformerDataFragment;
}
const PerformerHeaderImage: React.FC<IPerformerHeaderImageProps> =
PatchComponent(
"PerformerHeaderImage",
({ encodingImage, activeImage, lightboxImages, performer }) => {
return (
<HeaderImage encodingImage={encodingImage}>
{!!activeImage && (
<LightboxLink images={lightboxImages}>
<DetailImage
className="performer"
src={activeImage}
alt={performer.name}
/>
</LightboxLink>
)}
</HeaderImage>
);
}
);
const PerformerPage: React.FC<IProps> = PatchComponent( const PerformerPage: React.FC<IProps> = PatchComponent(
"PerformerPage", "PerformerPage",
({ performer, tabKey }) => { ({ performer, tabKey }) => {
@ -364,18 +393,13 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
show={enableBackgroundImage && !isEditing} show={enableBackgroundImage && !isEditing}
/> />
<div className="detail-container"> <div className="detail-container">
<HeaderImage encodingImage={encodingImage}> <PerformerHeaderImage
{!!activeImage && ( activeImage={activeImage}
<LightboxLink images={lightboxImages}> collapsed={collapsed}
<DetailImage encodingImage={encodingImage}
className="performer" lightboxImages={lightboxImages}
src={activeImage} performer={performer}
alt={performer.name} />
/>
</LightboxLink>
)}
</HeaderImage>
<div className="row"> <div className="row">
<div className="performer-head col"> <div className="performer-head col">
<DetailTitle <DetailTitle

View file

@ -7,6 +7,7 @@ import { cloneDeep } from "@apollo/client/utilities";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import cx from "classnames"; import cx from "classnames";
import { PatchComponent } from "src/patch";
const maxFieldNameLength = 64; const maxFieldNameLength = 64;
@ -90,76 +91,85 @@ const CustomFieldInput: React.FC<{
onChange: (field: string, value: unknown) => void; onChange: (field: string, value: unknown) => void;
isNew?: boolean; isNew?: boolean;
error?: string; error?: string;
}> = ({ field, value, onChange, isNew = false, error }) => { }> = PatchComponent(
const intl = useIntl(); "CustomFieldInput",
const [currentField, setCurrentField] = useState(field); ({ field, value, onChange, isNew = false, error }) => {
const [currentValue, setCurrentValue] = useState(value as string); const intl = useIntl();
const [currentField, setCurrentField] = useState(field);
const [currentValue, setCurrentValue] = useState(value as string);
const fieldRef = useRef<HTMLInputElement>(null); const fieldRef = useRef<HTMLInputElement>(null);
const valueRef = useRef<HTMLInputElement>(null); const valueRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
setCurrentField(field); setCurrentField(field);
setCurrentValue(value as string); setCurrentValue(value as string);
}, [field, value]); }, [field, value]);
function onBlur() { function onBlur() {
onChange(currentField, convertCustomValue(currentValue)); onChange(currentField, convertCustomValue(currentValue));
} }
function onDelete() { function onDelete() {
onChange("", ""); onChange("", "");
} }
return ( return (
<FormGroup> <FormGroup>
<Row className={cx("custom-fields-row", { "custom-fields-new": isNew })}> <Row
<Col sm={3} xl={2} className="custom-fields-field"> className={cx("custom-fields-row", { "custom-fields-new": isNew })}
{isNew ? ( >
<> <Col sm={3} xl={2} className="custom-fields-field">
{isNew ? (
<>
<Form.Control
ref={fieldRef}
className="input-control"
type="text"
value={currentField ?? ""}
placeholder={intl.formatMessage({
id: "custom_fields.field",
})}
onChange={(event) =>
setCurrentField(event.currentTarget.value)
}
onBlur={onBlur}
/>
</>
) : (
<Form.Label title={currentField}>{currentField}</Form.Label>
)}
</Col>
<Col sm={9} xl={7}>
<InputGroup>
<Form.Control <Form.Control
ref={fieldRef} ref={valueRef}
className="input-control" className="input-control"
type="text" type="text"
value={currentField ?? ""} value={(currentValue as string) ?? ""}
placeholder={intl.formatMessage({ id: "custom_fields.field" })} placeholder={currentField}
onChange={(event) => setCurrentField(event.currentTarget.value)} onChange={(event) => setCurrentValue(event.currentTarget.value)}
onBlur={onBlur} onBlur={onBlur}
/> />
</> <InputGroup.Append>
) : ( {!isNew && (
<Form.Label title={currentField}>{currentField}</Form.Label> <Button
)} className="custom-fields-remove"
</Col> variant="danger"
<Col sm={9} xl={7}> onClick={() => onDelete()}
<InputGroup> >
<Form.Control <Icon icon={faMinus} />
ref={valueRef} </Button>
className="input-control" )}
type="text" </InputGroup.Append>
value={(currentValue as string) ?? ""} </InputGroup>
placeholder={currentField} </Col>
onChange={(event) => setCurrentValue(event.currentTarget.value)} </Row>
onBlur={onBlur} <Form.Control.Feedback type="invalid">{error}</Form.Control.Feedback>
/> </FormGroup>
<InputGroup.Append> );
{!isNew && ( }
<Button );
className="custom-fields-remove"
variant="danger"
onClick={() => onDelete()}
>
<Icon icon={faMinus} />
</Button>
)}
</InputGroup.Append>
</InputGroup>
</Col>
</Row>
<Form.Control.Feedback type="invalid">{error}</Form.Control.Feedback>
</FormGroup>
);
};
interface ICustomField { interface ICustomField {
field: string; field: string;

View file

@ -1,4 +1,5 @@
import { useLayoutEffect, useRef } from "react"; import { useLayoutEffect, useRef } from "react";
import { PatchComponent } from "src/patch";
import { remToPx } from "src/utils/units"; import { remToPx } from "src/utils/units";
const DEFAULT_WIDTH = Math.round(remToPx(30)); const DEFAULT_WIDTH = Math.round(remToPx(30));
@ -6,34 +7,37 @@ const DEFAULT_WIDTH = Math.round(remToPx(30));
// Props used by the <img> element // Props used by the <img> element
type IDetailImageProps = JSX.IntrinsicElements["img"]; type IDetailImageProps = JSX.IntrinsicElements["img"];
export const DetailImage = (props: IDetailImageProps) => { export const DetailImage = PatchComponent(
const imgRef = useRef<HTMLImageElement>(null); "DetailImage",
(props: IDetailImageProps) => {
const imgRef = useRef<HTMLImageElement>(null);
function fixWidth() { function fixWidth() {
const img = imgRef.current; const img = imgRef.current;
if (!img) return; if (!img) return;
// prevent SVG's w/o intrinsic size from rendering as 0x0 // prevent SVG's w/o intrinsic size from rendering as 0x0
if (img.naturalWidth === 0) { if (img.naturalWidth === 0) {
// If the naturalWidth is zero, it means the image either hasn't loaded yet // If the naturalWidth is zero, it means the image either hasn't loaded yet
// or we're on Firefox and it is an SVG w/o an intrinsic size. // or we're on Firefox and it is an SVG w/o an intrinsic size.
// So set the width to our fallback width. // So set the width to our fallback width.
img.setAttribute("width", String(DEFAULT_WIDTH)); img.setAttribute("width", String(DEFAULT_WIDTH));
} else { } else {
// If we have a `naturalWidth`, this could either be the actual intrinsic width // If we have a `naturalWidth`, this could either be the actual intrinsic width
// of the image, or the image is an SVG w/o an intrinsic size and we're on Chrome or Safari, // of the image, or the image is an SVG w/o an intrinsic size and we're on Chrome or Safari,
// which seem to return a size calculated in some browser-specific way. // which seem to return a size calculated in some browser-specific way.
// Worse yet, once rendered, Safari will then return the value of `img.width` as `img.naturalWidth`, // Worse yet, once rendered, Safari will then return the value of `img.width` as `img.naturalWidth`,
// so we need to clone the image to disconnect it from the DOM, and then get the `naturalWidth` of the clone, // so we need to clone the image to disconnect it from the DOM, and then get the `naturalWidth` of the clone,
// in order to always return the same `naturalWidth` for a given src. // in order to always return the same `naturalWidth` for a given src.
const i = img.cloneNode() as HTMLImageElement; const i = img.cloneNode() as HTMLImageElement;
img.setAttribute("width", String(i.naturalWidth || DEFAULT_WIDTH)); img.setAttribute("width", String(i.naturalWidth || DEFAULT_WIDTH));
}
} }
useLayoutEffect(() => {
fixWidth();
}, [props.src]);
return <img ref={imgRef} onLoad={() => fixWidth()} {...props} />;
} }
);
useLayoutEffect(() => {
fixWidth();
}, [props.src]);
return <img ref={imgRef} onLoad={() => fixWidth()} {...props} />;
};

View file

@ -1,12 +1,13 @@
import { PropsWithChildren } from "react"; import { PropsWithChildren } from "react";
import { LoadingIndicator } from "../LoadingIndicator"; import { LoadingIndicator } from "../LoadingIndicator";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
export const HeaderImage: React.FC< export const HeaderImage: React.FC<
PropsWithChildren<{ PropsWithChildren<{
encodingImage: boolean; encodingImage: boolean;
}> }>
> = ({ encodingImage, children }) => { > = PatchComponent("HeaderImage", ({ encodingImage, children }) => {
return ( return (
<div className="detail-header-image"> <div className="detail-header-image">
{encodingImage ? ( {encodingImage ? (
@ -18,4 +19,4 @@ export const HeaderImage: React.FC<
)} )}
</div> </div>
); );
}; });

View file

@ -11,6 +11,7 @@ import { useIntl } from "react-intl";
import { ModalComponent } from "./Modal"; import { ModalComponent } from "./Modal";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { faFile, faLink } from "@fortawesome/free-solid-svg-icons"; import { faFile, faLink } from "@fortawesome/free-solid-svg-icons";
import { PatchComponent } from "src/patch";
interface IImageInput { interface IImageInput {
isEditing: boolean; isEditing: boolean;
@ -24,122 +25,119 @@ function acceptExtensions(acceptSVG: boolean = false) {
return `.jpg,.jpeg,.png,.webp,.gif${acceptSVG ? ",.svg" : ""}`; return `.jpg,.jpeg,.png,.webp,.gif${acceptSVG ? ",.svg" : ""}`;
} }
export const ImageInput: React.FC<IImageInput> = ({ export const ImageInput: React.FC<IImageInput> = PatchComponent(
isEditing, "ImageInput",
text, ({ isEditing, text, onImageChange, onImageURL, acceptSVG = false }) => {
onImageChange, const [isShowDialog, setIsShowDialog] = useState(false);
onImageURL, const [url, setURL] = useState("");
acceptSVG = false, const intl = useIntl();
}) => {
const [isShowDialog, setIsShowDialog] = useState(false);
const [url, setURL] = useState("");
const intl = useIntl();
if (!isEditing) return <div />; if (!isEditing) return <div />;
if (!onImageURL) {
// just return the file input
return (
<Form.Label className="image-input">
<Button variant="secondary">
{text ?? intl.formatMessage({ id: "actions.browse_for_image" })}
</Button>
<Form.Control
type="file"
onChange={onImageChange}
accept={acceptExtensions(acceptSVG)}
/>
</Form.Label>
);
}
function showDialog() {
setURL("");
setIsShowDialog(true);
}
function onConfirmURL() {
if (!onImageURL) { if (!onImageURL) {
return; // just return the file input
return (
<Form.Label className="image-input">
<Button variant="secondary">
{text ?? intl.formatMessage({ id: "actions.browse_for_image" })}
</Button>
<Form.Control
type="file"
onChange={onImageChange}
accept={acceptExtensions(acceptSVG)}
/>
</Form.Label>
);
} }
setIsShowDialog(false); function showDialog() {
onImageURL(url); setURL("");
} setIsShowDialog(true);
}
function onConfirmURL() {
if (!onImageURL) {
return;
}
setIsShowDialog(false);
onImageURL(url);
}
function renderDialog() {
return (
<ModalComponent
show={!!isShowDialog}
onHide={() => setIsShowDialog(false)}
header={intl.formatMessage({ id: "dialogs.set_image_url_title" })}
accept={{
onClick: onConfirmURL,
text: intl.formatMessage({ id: "actions.confirm" }),
}}
>
<div className="dialog-content">
<Form.Group controlId="url" as={Row}>
<Form.Label column xs={3}>
{intl.formatMessage({ id: "url" })}
</Form.Label>
<Col xs={9}>
<Form.Control
className="text-input"
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setURL(event.currentTarget.value)
}
value={url}
placeholder={intl.formatMessage({ id: "url" })}
/>
</Col>
</Form.Group>
</div>
</ModalComponent>
);
}
const popover = (
<Popover id="set-image-popover">
<Popover.Content>
<>
<div>
<Form.Label className="image-input">
<Button variant="secondary">
<Icon icon={faFile} className="fa-fw" />
<span>{intl.formatMessage({ id: "actions.from_file" })}</span>
</Button>
<Form.Control
type="file"
onChange={onImageChange}
accept={acceptExtensions(acceptSVG)}
/>
</Form.Label>
</div>
<div>
<Button className="minimal" onClick={showDialog}>
<Icon icon={faLink} className="fa-fw" />
<span>{intl.formatMessage({ id: "actions.from_url" })}</span>
</Button>
</div>
</>
</Popover.Content>
</Popover>
);
function renderDialog() {
return ( return (
<ModalComponent <>
show={!!isShowDialog} {renderDialog()}
onHide={() => setIsShowDialog(false)} <OverlayTrigger
header={intl.formatMessage({ id: "dialogs.set_image_url_title" })} trigger="click"
accept={{ placement="top"
onClick: onConfirmURL, overlay={popover}
text: intl.formatMessage({ id: "actions.confirm" }), rootClose
}} >
> <Button variant="secondary" className="mr-2">
<div className="dialog-content"> {text ?? intl.formatMessage({ id: "actions.set_image" })}
<Form.Group controlId="url" as={Row}> </Button>
<Form.Label column xs={3}> </OverlayTrigger>
{intl.formatMessage({ id: "url" })} </>
</Form.Label>
<Col xs={9}>
<Form.Control
className="text-input"
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setURL(event.currentTarget.value)
}
value={url}
placeholder={intl.formatMessage({ id: "url" })}
/>
</Col>
</Form.Group>
</div>
</ModalComponent>
); );
} }
);
const popover = (
<Popover id="set-image-popover">
<Popover.Content>
<>
<div>
<Form.Label className="image-input">
<Button variant="secondary">
<Icon icon={faFile} className="fa-fw" />
<span>{intl.formatMessage({ id: "actions.from_file" })}</span>
</Button>
<Form.Control
type="file"
onChange={onImageChange}
accept={acceptExtensions(acceptSVG)}
/>
</Form.Label>
</div>
<div>
<Button className="minimal" onClick={showDialog}>
<Icon icon={faLink} className="fa-fw" />
<span>{intl.formatMessage({ id: "actions.from_url" })}</span>
</Button>
</div>
</>
</Popover.Content>
</Popover>
);
return (
<>
{renderDialog()}
<OverlayTrigger
trigger="click"
placement="top"
overlay={popover}
rootClose
>
<Button variant="secondary" className="mr-2">
{text ?? intl.formatMessage({ id: "actions.set_image" })}
</Button>
</OverlayTrigger>
</>
);
};

View file

@ -149,7 +149,9 @@ Returns `void`.
- `CompressedPerformerDetailsPanel` - `CompressedPerformerDetailsPanel`
- `ConstantSetting` - `ConstantSetting`
- `CountrySelect` - `CountrySelect`
- `CustomFieldInput`
- `DateInput` - `DateInput`
- `DetailImage`
- `ExternalLinkButtons` - `ExternalLinkButtons`
- `ExternalLinksButton` - `ExternalLinksButton`
- `FolderSelect` - `FolderSelect`
@ -162,9 +164,12 @@ Returns `void`.
- `GalleryIDSelect` - `GalleryIDSelect`
- `GallerySelect` - `GallerySelect`
- `GallerySelect.sort` - `GallerySelect.sort`
- `HeaderImage`
- `HoverPopover` - `HoverPopover`
- `Icon` - `Icon`
- `ImageDetailPanel` - `ImageDetailPanel`
- `ImageInput`
- `LightboxLink`
- `LoadingIndicator` - `LoadingIndicator`
- `ModalSetting` - `ModalSetting`
- `GroupIDSelect` - `GroupIDSelect`
@ -186,6 +191,7 @@ Returns `void`.
- `PerformerSelect.sort` - `PerformerSelect.sort`
- `PerformerGalleriesPanel` - `PerformerGalleriesPanel`
- `PerformerGroupsPanel` - `PerformerGroupsPanel`
- `PerformerHeaderImage`
- `PerformerImagesPanel` - `PerformerImagesPanel`
- `PerformerScenesPanel` - `PerformerScenesPanel`
- `PluginRoutes` - `PluginRoutes`

View file

@ -2,10 +2,11 @@ import { PropsWithChildren } from "react";
import { useLightbox } from "./hooks"; import { useLightbox } from "./hooks";
import { ILightboxImage } from "./types"; import { ILightboxImage } from "./types";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import { PatchComponent } from "src/patch";
export const LightboxLink: React.FC< export const LightboxLink: React.FC<
PropsWithChildren<{ images?: ILightboxImage[] | undefined; index?: number }> PropsWithChildren<{ images?: ILightboxImage[] | undefined; index?: number }>
> = ({ images, index, children }) => { > = PatchComponent("LightboxLink", ({ images, index, children }) => {
const showLightbox = useLightbox(); const showLightbox = useLightbox();
if (!images || images.length === 0) { if (!images || images.length === 0) {
@ -20,4 +21,4 @@ export const LightboxLink: React.FC<
{children} {children}
</Button> </Button>
); );
}; });

View file

@ -694,12 +694,18 @@ declare namespace PluginApi {
PerformerAppearsWithPanel: React.FC<any>; PerformerAppearsWithPanel: React.FC<any>;
PerformerGalleriesPanel: React.FC<any>; PerformerGalleriesPanel: React.FC<any>;
PerformerGroupsPanel: React.FC<any>; PerformerGroupsPanel: React.FC<any>;
PerformerHeaderImage: React.FC<any>;
PerformerScenesPanel: React.FC<any>; PerformerScenesPanel: React.FC<any>;
PerformerImagesPanel: React.FC<any>; PerformerImagesPanel: React.FC<any>;
TabTitleCounter: React.FC<any>; TabTitleCounter: React.FC<any>;
PerformerCard: React.FC<any>; PerformerCard: React.FC<any>;
ExternalLinkButtons: React.FC<any>; ExternalLinkButtons: React.FC<any>;
ExternalLinksButton: React.FC<any>; ExternalLinksButton: React.FC<any>;
CustomFieldInput: React.FC<any>;
ImageInput: React.FC<any>;
DetailImage: React.FC<any>;
HeaderImage: React.FC<any>;
LightboxLink: React.FC<any>;
"PerformerCard.Popovers": React.FC<any>; "PerformerCard.Popovers": React.FC<any>;
"PerformerCard.Details": React.FC<any>; "PerformerCard.Details": React.FC<any>;
"PerformerCard.Overlays": React.FC<any>; "PerformerCard.Overlays": React.FC<any>;