Enhance performer popover functionality and sizing

- Added `cardContent` prop to `PerformerPopover` and `PerformerPopoverCard` for flexible content rendering.
- Introduced `TaggerPerformerPopover` to handle both local and scraped performer data, replacing the previous `ScrapedPerformerPreview`.
- Created `ScrapedPerformerCard` for displaying scraped performer details, including age and country flag.
- Updated styles for the tag popover card to ensure consistent dimensions and improved layout.

This refactor improves the encapsulation and display of performer information across the tagger components.
This commit is contained in:
KennyG 2026-05-04 10:45:43 -04:00
parent 9d1813e215
commit 455cac489f
8 changed files with 289 additions and 95 deletions

View file

@ -13,6 +13,7 @@ import {
interface IPeromerPopoverCardProps {
id?: string;
cardContent?: React.ReactNode;
previewData?: IPerformerPreviewData;
loading?: boolean;
loadingText?: string;
@ -49,11 +50,21 @@ const PerformerPopoverCardByID: React.FC<{
export const PerformerPopoverCard: React.FC<IPeromerPopoverCardProps> = ({
id,
cardContent,
previewData,
loading,
loadingText = "",
cardExtras,
}) => {
if (cardContent) {
return (
<>
{cardContent}
{cardExtras}
</>
);
}
if (previewData || loading) {
return (
<>
@ -75,12 +86,15 @@ export const PerformerPopoverCard: React.FC<IPeromerPopoverCardProps> = ({
interface IPeroformerPopoverProps {
id?: string;
cardContent?: React.ReactNode;
previewData?: IPerformerPreviewData;
loading?: boolean;
loadingText?: string;
cardExtras?: React.ReactNode;
hide?: boolean;
placement?: Placement;
enterDelay?: number;
leaveDelay?: number;
target?: React.RefObject<HTMLElement>;
triggerClassName?: string;
onOpen?: () => void;
@ -89,6 +103,7 @@ interface IPeroformerPopoverProps {
export const PerformerPopover: React.FC<IPeroformerPopoverProps> = ({
id,
cardContent,
previewData,
loading,
loadingText,
@ -96,6 +111,8 @@ export const PerformerPopover: React.FC<IPeroformerPopoverProps> = ({
hide,
children,
placement = "top",
enterDelay = 500,
leaveDelay = 100,
target,
triggerClassName,
onOpen,
@ -114,13 +131,14 @@ export const PerformerPopover: React.FC<IPeroformerPopoverProps> = ({
className={triggerClassName}
target={target}
placement={placement}
enterDelay={500}
leaveDelay={100}
enterDelay={enterDelay}
leaveDelay={leaveDelay}
onOpen={onOpen}
onClose={onClose}
content={
<PerformerPopoverCard
id={id}
cardContent={cardContent}
previewData={previewData}
loading={loading}
loadingText={loadingText}

View file

@ -16,6 +16,7 @@ import { faGripLines } from "@fortawesome/free-solid-svg-icons";
import { DragSide, useDragMoveSelect } from "./dragMoveSelect";
import { useDebounce } from "src/hooks/debounce";
import { PatchComponent } from "src/patch";
import { ExternalLink } from "../ExternalLink";
interface ICardProps {
className?: string;
@ -172,6 +173,27 @@ const MoveTarget: React.FC<{ dragSide: DragSide }> = ({ dragSide }) => {
);
};
function CardNavLink(props: {
url: string;
linkClassName?: string;
onClick: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
children: React.ReactNode;
}) {
const { url, linkClassName, onClick, children } = props;
if (/^https?:\/\//i.test(url)) {
return (
<ExternalLink href={url} className={linkClassName} onClick={onClick}>
{children}
</ExternalLink>
);
}
return (
<Link to={url} className={linkClassName} onClick={onClick}>
{children}
</Link>
);
}
export const GridCard: React.FC<ICardProps> = PatchComponent(
"GridCard",
(props: ICardProps) => {
@ -256,24 +278,24 @@ export const GridCard: React.FC<ICardProps> = PatchComponent(
<div
className={cx(props.thumbnailSectionClassName, "thumbnail-section")}
>
<Link
to={props.url}
className={props.linkClassName}
<CardNavLink
url={props.url}
linkClassName={props.linkClassName}
onClick={handleImageClick}
>
{props.image}
</Link>
</CardNavLink>
{props.overlays}
{maybeRenderProgressBar()}
</div>
{maybeRenderInteractiveHeatmap()}
<div className="card-section">
<Link to={props.url} onClick={handleImageClick}>
<CardNavLink url={props.url} onClick={handleImageClick}>
<h5 className="card-section-title flex-aligned">
{props.pretitleIcon}
<TruncatedText text={props.title} lineCount={2} />
</h5>
</Link>
</CardNavLink>
{props.details}
</div>

View file

@ -2,8 +2,8 @@ import { ReactNode, useState } from "react";
import { Placement } from "react-bootstrap/esm/Overlay";
import { IntlShape, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { PerformerPopover } from "src/components/Performers/PerformerPopover";
import { useConfigurationContext } from "src/hooks/Config";
import { TaggerPerformerPopover } from "./TaggerPerformerPopover";
interface IPerformerDeltaRow {
label: string;
@ -215,8 +215,9 @@ export const MatchedPerformerPreview = ({
}
return (
<PerformerPopover
id={performerID ?? undefined}
<TaggerPerformerPopover
performer={performer ?? undefined}
performerID={performerID ?? undefined}
cardExtras={
warningStashID || deltaRows.length > 0 ? (
<div className="tagger-matched-performer-popover-extra">
@ -250,6 +251,6 @@ export const MatchedPerformerPreview = ({
onClose={() => setIsOpened(false)}
>
{children}
</PerformerPopover>
</TaggerPerformerPopover>
);
};

View file

@ -13,7 +13,7 @@ import { ExternalLink } from "src/components/Shared/ExternalLink";
import { Link } from "react-router-dom";
import { LinkButton } from "../LinkButton";
import { MatchedPerformerPreview } from "./MatchedPerformerPreview";
import { ScrapedPerformerPreview } from "./ScrapedPerformerPreview";
import { TaggerPerformerPopover } from "./TaggerPerformerPopover";
const PerformerLink: React.FC<{
performer: GQL.ScrapedPerformer | Performer;
@ -116,12 +116,15 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
<div className="entity-name">
<FormattedMessage id="countables.performers" values={{ count: 1 }} />:
<b className="ml-2">
<ScrapedPerformerPreview performer={performer}>
<TaggerPerformerPopover
scrapedPerformer={performer}
endpoint={endpoint}
>
<PerformerLink
performer={performer}
url={`${stashboxPerformerPrefix}${performer.remote_site_id}`}
/>
</ScrapedPerformerPreview>
</TaggerPerformerPopover>
</b>
</div>
<span className="ml-auto">
@ -162,12 +165,15 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
<div className="entity-name">
<FormattedMessage id="countables.performers" values={{ count: 1 }} />:
<b className="ml-2">
<ScrapedPerformerPreview performer={performer}>
<TaggerPerformerPopover
scrapedPerformer={performer}
endpoint={endpoint}
>
<PerformerLink
performer={performer}
url={safeBuildPerformerScraperLink(performer.remote_site_id)}
/>
</ScrapedPerformerPreview>
</TaggerPerformerPopover>
</b>
</div>
<ButtonGroup>

View file

@ -0,0 +1,145 @@
import React from "react";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import TextUtils from "src/utils/text";
import { stringToGender } from "src/utils/gender";
import { getStashboxBase } from "src/utils/stashbox";
import { GridCard } from "src/components/Shared/GridCard/GridCard";
import { CountryFlag } from "src/components/Shared/CountryFlag";
import { PatchComponent } from "src/patch";
import GenderIcon from "src/components/Performers/GenderIcon";
export interface IScrapedPerformerCardProps {
scrapedPerformer: GQL.ScrapedPerformer;
endpoint: string;
cardWidth?: number;
ageFromDate?: string | null;
selecting?: boolean;
selected?: boolean;
zoomIndex?: number;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
}
const ScrapedPerformerCardOverlays: React.FC<IScrapedPerformerCardProps> =
PatchComponent("ScrapedPerformerCard.Overlays", ({ scrapedPerformer }) => {
function maybeRenderFlag() {
if (!scrapedPerformer.country) {
return;
}
return (
<CountryFlag
className="performer-card__country-flag"
country={scrapedPerformer.country}
includeOverlay
/>
);
}
return <>{maybeRenderFlag()}</>;
});
const ScrapedPerformerCardDetails: React.FC<IScrapedPerformerCardProps> =
PatchComponent("ScrapedPerformerCard.Details", (props) => {
const { scrapedPerformer, ageFromDate } = props;
const intl = useIntl();
const age = TextUtils.age(
scrapedPerformer.birthdate,
ageFromDate ?? scrapedPerformer.death_date
);
const ageL10nId = ageFromDate
? "media_info.performer_card.age_context"
: "media_info.performer_card.age";
const ageL10String = intl.formatMessage({
id: "years_old",
});
const ageString = intl.formatMessage(
{ id: ageL10nId },
{ age, years_old: ageL10String }
);
return (
<>
{age !== 0 ? (
<div className="performer-card__age">{ageString}</div>
) : (
""
)}
</>
);
});
const ScrapedPerformerCardImage: React.FC<IScrapedPerformerCardProps> =
PatchComponent("ScrapedPerformerCard.Image", ({ scrapedPerformer }) => {
const intl = useIntl();
const unknownName = intl.formatMessage({
id: "component_tagger.results.unnamed",
});
const alt = scrapedPerformer.name ?? unknownName;
return (
<img
loading="lazy"
className="performer-card-image"
alt={alt}
src={scrapedPerformer.images?.[0] ?? ""}
/>
);
});
const ScrapedPerformerCardTitle: React.FC<IScrapedPerformerCardProps> =
PatchComponent("ScrapedPerformerCard.Title", ({ scrapedPerformer }) => {
const intl = useIntl();
const unknownPerformerName = intl.formatMessage({
id: "component_tagger.results.unnamed",
});
const name = scrapedPerformer.name ?? unknownPerformerName;
return (
<div>
<span className="performer-name">{name}</span>
{scrapedPerformer.disambiguation && (
<span className="performer-disambiguation">
{` (${scrapedPerformer.disambiguation})`}
</span>
)}
</div>
);
});
export const ScrapedPerformerCard: React.FC<IScrapedPerformerCardProps> =
PatchComponent("ScrapedPerformerCard", (props) => {
const {
scrapedPerformer,
endpoint,
cardWidth,
selecting,
selected,
onSelectedChanged,
zoomIndex,
} = props;
const base = getStashboxBase(endpoint);
const stashboxUrl =
base && scrapedPerformer.remote_site_id
? `${base}performers/${scrapedPerformer.remote_site_id}`
: "#";
return (
<GridCard
className={`performer-card zoom-${zoomIndex}`}
url={stashboxUrl}
width={cardWidth}
pretitleIcon={
<GenderIcon
className="gender-icon"
gender={stringToGender(scrapedPerformer.gender, true)}
/>
}
title={<ScrapedPerformerCardTitle {...props} />}
image={<ScrapedPerformerCardImage {...props} />}
overlays={<ScrapedPerformerCardOverlays {...props} />}
details={<ScrapedPerformerCardDetails {...props} />}
selected={selected}
selecting={selecting}
onSelectedChanged={onSelectedChanged}
/>
);
});

View file

@ -1,78 +0,0 @@
import { ReactNode } from "react";
import { Placement } from "react-bootstrap/esm/Overlay";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { IPerformerPreviewData } from "src/components/Performers/PerformerPreviewCard";
import { PerformerPopover } from "src/components/Performers/PerformerPopover";
import TextUtils from "src/utils/text";
import { stringToGender } from "src/utils/gender";
interface IScrapedPerformerPreviewProps {
performer: GQL.ScrapedPerformer;
placement?: Placement;
children?: ReactNode;
}
export const localPerformerToPreviewData = (
performer: GQL.PerformerDataFragment,
ageString: string | null
): IPerformerPreviewData => ({
name: performer.name,
image: performer.image_path,
country: performer.country,
gender: performer.gender,
disambiguation: performer.disambiguation,
ageString,
});
export const scrapedPerformerToPreviewData = (
performer: GQL.ScrapedPerformer,
name: string,
ageString: string | null
): IPerformerPreviewData => ({
name,
image: performer.images?.[0],
country: performer.country,
gender: stringToGender(performer.gender, true),
disambiguation: performer.disambiguation,
ageString,
});
export const ScrapedPerformerPreview = ({
performer,
placement = "bottom",
children,
}: IScrapedPerformerPreviewProps) => {
const intl = useIntl();
const unknownPerformerName = intl.formatMessage({
id: "component_tagger.results.unnamed",
defaultMessage: "Unnamed",
});
const name = performer.name ?? unknownPerformerName;
const age = TextUtils.age(performer.birthdate, performer.death_date);
const ageString = intl.formatMessage(
{ id: "media_info.performer_card.age" },
{
age,
years_old: intl.formatMessage({
id: "years_old",
defaultMessage: "years old",
}),
}
);
const previewData = scrapedPerformerToPreviewData(
performer,
name,
age !== 0 ? ageString : null
);
return (
<PerformerPopover
previewData={previewData}
placement={placement}
triggerClassName="d-inline-block"
>
{children}
</PerformerPopover>
);
};

View file

@ -0,0 +1,63 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { Placement } from "react-bootstrap/esm/Overlay";
import { PerformerPopover } from "src/components/Performers/PerformerPopover";
import { PerformerCard } from "src/components/Performers/PerformerCard";
import { ScrapedPerformerCard } from "./ScrapedPerformerCard";
interface ITaggerPerformerPopoverProps {
performer?: GQL.PerformerDataFragment;
performerID?: string;
scrapedPerformer?: GQL.ScrapedPerformer;
endpoint?: string;
cardExtras?: React.ReactNode;
placement?: Placement;
triggerClassName?: string;
onOpen?: () => void;
onClose?: () => void;
}
export const TaggerPerformerPopover: React.FC<
React.PropsWithChildren<ITaggerPerformerPopoverProps>
> = ({
performer,
performerID,
scrapedPerformer,
endpoint,
cardExtras,
placement = "bottom",
triggerClassName = "d-inline-block",
onOpen,
onClose,
children,
}) => {
const cardContent = performer ? (
<div className="tag-popover-card">
<PerformerCard performer={performer} zoomIndex={0} />
</div>
) : scrapedPerformer ? (
<div className="tag-popover-card">
<ScrapedPerformerCard
scrapedPerformer={scrapedPerformer}
endpoint={endpoint ?? ""}
zoomIndex={0}
/>
</div>
) : undefined;
return (
<PerformerPopover
id={cardContent ? undefined : performerID}
cardContent={cardContent}
cardExtras={cardExtras}
placement={placement}
enterDelay={1000}
leaveDelay={500}
triggerClassName={triggerClassName}
onOpen={onOpen}
onClose={onClose}
>
{children}
</PerformerPopover>
);
};

View file

@ -125,6 +125,23 @@
}
}
@media (max-height: 1049px) {
.tag-popover-card {
.card {
max-width: 160px;
width: 160px;
}
.card-section-title {
font-size: 1rem;
}
.performer-card__age {
font-size: 0.9rem;
}
}
}
.tag-item {
.icon-wrapper {
color: #202b33;