Simplify and balance wrapper for performer popover

- Removed MatchedPerformerPreview component and integrated its functionality into TaggerPerformerPopover.
- Updated PerformerResult to utilize TaggerPerformerPopover for displaying performer details.
- Enhanced TaggerPerformerPopover to include delta row calculations for differences between local and scraped performer data.
- Improved code organization and maintainability by consolidating related logic into the popover component.
This commit is contained in:
KennyG 2026-05-04 11:27:50 -04:00
parent 6f655d2bda
commit d8cbabf383
3 changed files with 250 additions and 265 deletions

View file

@ -1,256 +0,0 @@
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 { useConfigurationContext } from "src/hooks/Config";
import { TaggerPerformerPopover } from "./TaggerPerformerPopover";
interface IPerformerDeltaRow {
label: string;
value: string;
}
interface IMatchedPerformerPreviewProps {
performerID?: string | null;
scrapedPerformer: GQL.ScrapedPerformer;
endpoint?: string;
placement?: Placement;
children?: ReactNode;
}
const normalizeValue = (value: unknown) =>
(() => {
const text = String(value ?? "").trim();
if (!text) return "";
const isNumericLike = /^[-+]?(?:\d+\.?\d*|\.\d+)$/.test(text);
if (isNumericLike) {
const numeric = Number(text);
if (!Number.isNaN(numeric)) {
return String(numeric);
}
}
return text.toLowerCase();
})();
const toStringOrNull = (value: unknown) => {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text.length > 0 ? text : null;
};
const pushDeltaIfDifferent = (
rows: IPerformerDeltaRow[],
label: string,
remoteValue: unknown,
localValue: unknown
) => {
const remoteText = toStringOrNull(remoteValue);
if (!remoteText) return;
if (normalizeValue(remoteText) === normalizeValue(localValue)) return;
rows.push({ label, value: remoteText });
};
const buildPerformerDeltaRows = (
remote: GQL.ScrapedPerformer,
local: GQL.PerformerDataFragment,
intl: IntlShape
): IPerformerDeltaRow[] => {
const rows: IPerformerDeltaRow[] = [];
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "birthdate" }),
remote.birthdate,
local.birthdate
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "death_date" }),
remote.death_date,
local.death_date
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "ethnicity" }),
remote.ethnicity,
local.ethnicity
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "hair_color" }),
remote.hair_color,
local.hair_color
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "eye_color" }),
remote.eye_color,
local.eye_color
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "height" }),
remote.height,
local.height_cm
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "weight" }),
remote.weight,
local.weight
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "penis_length" }),
remote.penis_length,
local.penis_length
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "circumcised" }),
remote.circumcised,
local.circumcised
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "measurements" }),
remote.measurements,
local.measurements
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "fake_tits" }),
remote.fake_tits,
local.fake_tits
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "tattoos" }),
remote.tattoos,
local.tattoos
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "piercings" }),
remote.piercings,
local.piercings
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "career_start" }),
remote.career_start,
local.career_start
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "career_end" }),
remote.career_end,
local.career_end
);
const remoteAliasesCount = remote.aliases
? remote.aliases
.split(",")
.map((a) => a.trim())
.filter(Boolean).length
: 0;
const localAliasesCount = local.alias_list?.length ?? 0;
if (remoteAliasesCount > localAliasesCount) {
rows.push({
label: intl.formatMessage({ id: "aliases" }),
value: String(remoteAliasesCount),
});
}
const remoteUrlsCount = remote.urls?.length ?? 0;
const localUrlsCount = local.urls?.length ?? 0;
if (remoteUrlsCount > localUrlsCount) {
rows.push({
label: intl.formatMessage({ id: "urls" }),
value: String(remoteUrlsCount),
});
}
return rows;
};
export const MatchedPerformerPreview = ({
performerID,
scrapedPerformer,
endpoint,
placement = "bottom",
children,
}: IMatchedPerformerPreviewProps) => {
const intl = useIntl();
const { configuration: config } = useConfigurationContext();
const showPerformerCardOnHover = config?.ui.showTagCardOnHover ?? true;
const [isOpened, setIsOpened] = useState(false);
const { data: selectedPerformerData } = GQL.useFindPerformerQuery({
variables: { id: performerID ?? "" },
skip: !performerID || !isOpened,
});
const performer = selectedPerformerData?.findPerformer;
const warningStashID =
endpoint && scrapedPerformer.remote_site_id && performer
? performer.stash_ids.find(
(stashID) =>
stashID.endpoint === endpoint &&
stashID.stash_id !== scrapedPerformer.remote_site_id
)
: undefined;
const deltaRows = performer
? buildPerformerDeltaRows(scrapedPerformer, performer, intl)
: [];
const warningEndpointName = warningStashID
? config?.general.stashBoxes.find(
(sb) => sb.endpoint === warningStashID.endpoint
)?.name ?? warningStashID.endpoint
: null;
if (!performerID || !showPerformerCardOnHover) {
return <>{children}</>;
}
return (
<TaggerPerformerPopover
performer={performer ?? undefined}
performerID={performerID ?? undefined}
cardExtras={
warningStashID || deltaRows.length > 0 ? (
<div className="tagger-matched-performer-popover-extra">
{warningStashID && (
<div className="tagger-performer-stashid-warning">
<span className="stash-id-pill">
<span
className="tagger-performer-stashid-warning-chip"
title={warningStashID.stash_id}
>
{warningEndpointName}
</span>
</span>
</div>
)}
{deltaRows.length > 0 && (
<div className="tagger-performer-delta-rows mt-2">
{deltaRows.map((row) => (
<div key={row.label}>
<span>{row.label}:</span> <span>{row.value}</span>
</div>
))}
</div>
)}
</div>
) : null
}
triggerClassName="d-inline-block"
placement={placement}
onOpen={() => setIsOpened(true)}
onClose={() => setIsOpened(false)}
>
{children}
</TaggerPerformerPopover>
);
};

View file

@ -12,7 +12,6 @@ import { getStashboxBase } from "src/utils/stashbox";
import { ExternalLink } from "src/components/Shared/ExternalLink";
import { Link } from "react-router-dom";
import { LinkButton } from "../LinkButton";
import { MatchedPerformerPreview } from "./MatchedPerformerPreview";
import { TaggerPerformerPopover } from "./TaggerPerformerPopover";
const PerformerLink: React.FC<{
@ -186,10 +185,11 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
>
<FormattedMessage id="actions.skip" />
</Button>
<MatchedPerformerPreview
<TaggerPerformerPopover
performerID={selectedPerformer?.id}
scrapedPerformer={performer}
endpoint={endpoint}
includeMatchExtras
>
<PerformerSelect
values={selectedPerformer ? [selectedPerformer] : []}
@ -198,7 +198,7 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
isClearable={false}
ageFromDate={ageFromDate}
/>
</MatchedPerformerPreview>
</TaggerPerformerPopover>
{endpoint && onLink && (
<LinkButton disabled={selectedID === undefined} onLink={onLink} />
)}

View file

@ -1,16 +1,183 @@
import React from "react";
import React, { useState } from "react";
import * as GQL from "src/core/generated-graphql";
import { Placement } from "react-bootstrap/esm/Overlay";
import { IntlShape, useIntl } from "react-intl";
import { PerformerPopover } from "src/components/Performers/PerformerPopover";
import { PerformerCard } from "src/components/Performers/PerformerCard";
import { useConfigurationContext } from "src/hooks/Config";
import { ScrapedPerformerCard } from "./ScrapedPerformerCard";
interface IPerformerDeltaRow {
label: string;
value: string;
}
const normalizeValue = (value: unknown) =>
(() => {
const text = String(value ?? "").trim();
if (!text) return "";
const isNumericLike = /^[-+]?(?:\d+\.?\d*|\.\d+)$/.test(text);
if (isNumericLike) {
const numeric = Number(text);
if (!Number.isNaN(numeric)) {
return String(numeric);
}
}
return text.toLowerCase();
})();
const toStringOrNull = (value: unknown) => {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text.length > 0 ? text : null;
};
const pushDeltaIfDifferent = (
rows: IPerformerDeltaRow[],
label: string,
remoteValue: unknown,
localValue: unknown
) => {
const remoteText = toStringOrNull(remoteValue);
if (!remoteText) return;
if (normalizeValue(remoteText) === normalizeValue(localValue)) return;
rows.push({ label, value: remoteText });
};
const buildPerformerDeltaRows = (
remote: GQL.ScrapedPerformer,
local: GQL.PerformerDataFragment,
intl: IntlShape
): IPerformerDeltaRow[] => {
const rows: IPerformerDeltaRow[] = [];
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "birthdate" }),
remote.birthdate,
local.birthdate
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "death_date" }),
remote.death_date,
local.death_date
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "ethnicity" }),
remote.ethnicity,
local.ethnicity
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "hair_color" }),
remote.hair_color,
local.hair_color
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "eye_color" }),
remote.eye_color,
local.eye_color
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "height" }),
remote.height,
local.height_cm
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "weight" }),
remote.weight,
local.weight
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "penis_length" }),
remote.penis_length,
local.penis_length
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "circumcised" }),
remote.circumcised,
local.circumcised
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "measurements" }),
remote.measurements,
local.measurements
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "fake_tits" }),
remote.fake_tits,
local.fake_tits
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "tattoos" }),
remote.tattoos,
local.tattoos
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "piercings" }),
remote.piercings,
local.piercings
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "career_start" }),
remote.career_start,
local.career_start
);
pushDeltaIfDifferent(
rows,
intl.formatMessage({ id: "career_end" }),
remote.career_end,
local.career_end
);
const remoteAliasesCount = remote.aliases
? remote.aliases
.split(",")
.map((a) => a.trim())
.filter(Boolean).length
: 0;
const localAliasesCount = local.alias_list?.length ?? 0;
if (remoteAliasesCount > localAliasesCount) {
rows.push({
label: intl.formatMessage({ id: "aliases" }),
value: String(remoteAliasesCount),
});
}
const remoteUrlsCount = remote.urls?.length ?? 0;
const localUrlsCount = local.urls?.length ?? 0;
if (remoteUrlsCount > localUrlsCount) {
rows.push({
label: intl.formatMessage({ id: "urls" }),
value: String(remoteUrlsCount),
});
}
return rows;
};
interface ITaggerPerformerPopoverProps {
performer?: GQL.PerformerDataFragment;
performerID?: string;
scrapedPerformer?: GQL.ScrapedPerformer;
endpoint?: string;
cardExtras?: React.ReactNode;
includeMatchExtras?: boolean;
placement?: Placement;
triggerClassName?: string;
onOpen?: () => void;
@ -25,15 +192,26 @@ export const TaggerPerformerPopover: React.FC<
scrapedPerformer,
endpoint,
cardExtras,
includeMatchExtras = false,
placement = "bottom",
triggerClassName = "d-inline-block",
onOpen,
onClose,
children,
}) => {
const cardContent = performer ? (
const intl = useIntl();
const { configuration: config } = useConfigurationContext();
const [isOpened, setIsOpened] = useState(false);
const { data: selectedPerformerData } = GQL.useFindPerformerQuery({
variables: { id: performerID ?? "" },
skip: !performerID || !!performer || !isOpened,
});
const localPerformer = performer ?? selectedPerformerData?.findPerformer;
const cardContent = localPerformer ? (
<div className="tag-popover-card tagger-performer-popover-card">
<PerformerCard performer={performer} zoomIndex={0} />
<PerformerCard performer={localPerformer} zoomIndex={0} />
</div>
) : scrapedPerformer ? (
<div className="tag-popover-card tagger-performer-popover-card">
@ -45,17 +223,80 @@ export const TaggerPerformerPopover: React.FC<
</div>
) : undefined;
const warningStashID =
includeMatchExtras &&
endpoint &&
scrapedPerformer?.remote_site_id &&
localPerformer
? localPerformer.stash_ids.find(
(stashID) =>
stashID.endpoint === endpoint &&
stashID.stash_id !== scrapedPerformer.remote_site_id
)
: undefined;
const deltaRows =
includeMatchExtras && scrapedPerformer && localPerformer
? buildPerformerDeltaRows(scrapedPerformer, localPerformer, intl)
: [];
const warningEndpointName = warningStashID
? config?.general.stashBoxes.find(
(sb) => sb.endpoint === warningStashID.endpoint
)?.name ?? warningStashID.endpoint
: null;
const matchExtras =
warningStashID || deltaRows.length > 0 ? (
<div className="tagger-matched-performer-popover-extra">
{warningStashID && (
<div className="tagger-performer-stashid-warning">
<span className="stash-id-pill">
<span
className="tagger-performer-stashid-warning-chip"
title={warningStashID.stash_id}
>
{warningEndpointName}
</span>
</span>
</div>
)}
{deltaRows.length > 0 && (
<div className="tagger-performer-delta-rows mt-2">
{deltaRows.map((row) => (
<div key={row.label}>
<span>{row.label}:</span> <span>{row.value}</span>
</div>
))}
</div>
)}
</div>
) : null;
return (
<PerformerPopover
id={cardContent ? undefined : performerID}
cardContent={cardContent}
cardExtras={cardExtras}
cardExtras={
matchExtras || cardExtras ? (
<>
{matchExtras}
{cardExtras}
</>
) : null
}
placement={placement}
enterDelay={1000}
leaveDelay={500}
triggerClassName={triggerClassName}
onOpen={onOpen}
onClose={onClose}
onOpen={() => {
setIsOpened(true);
onOpen?.();
}}
onClose={() => {
setIsOpened(false);
onClose?.();
}}
>
{children}
</PerformerPopover>