This commit is contained in:
Stash-KennyG 2026-05-05 12:49:38 +00:00 committed by GitHub
commit 7e1c33b151
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 647 additions and 35 deletions

View file

@ -8,15 +8,20 @@ import { useConfigurationContext } from "../../hooks/Config";
import { Placement } from "react-bootstrap/esm/Overlay";
interface IPeromerPopoverCardProps {
id: string;
id?: string;
cardContent?: React.ReactNode;
loading?: boolean;
loadingText?: string;
cardExtras?: React.ReactNode;
}
export const PerformerPopoverCard: React.FC<IPeromerPopoverCardProps> = ({
id,
}) => {
const { data, loading, error } = useFindPerformer(id);
const PerformerPopoverCardByID: React.FC<{
id: string;
cardExtras?: React.ReactNode;
}> = ({ id, cardExtras }) => {
const { data, loading: isLoading, error } = useFindPerformer(id);
if (loading)
if (isLoading)
return (
<div className="tag-popover-card-placeholder">
<LoadingIndicator card={true} message={""} />
@ -29,25 +34,77 @@ export const PerformerPopoverCard: React.FC<IPeromerPopoverCardProps> = ({
const performer = data.findPerformer;
return (
<div className="tag-popover-card">
<PerformerCard performer={performer} zoomIndex={0} />
</div>
<>
<div className="tag-popover-card">
<PerformerCard performer={performer} zoomIndex={0} />
</div>
{cardExtras}
</>
);
};
export const PerformerPopoverCard: React.FC<IPeromerPopoverCardProps> = ({
id,
cardContent,
loading,
loadingText = "",
cardExtras,
}) => {
if (cardContent) {
return (
<>
{cardContent}
{cardExtras}
</>
);
}
if (loading) {
return (
<>
<div className="tag-popover-card performer-preview-popover p-3">
{loadingText}
</div>
{cardExtras}
</>
);
}
if (!id) return null;
return <PerformerPopoverCardByID id={id} cardExtras={cardExtras} />;
};
interface IPeroformerPopoverProps {
id: string;
id?: string;
cardContent?: React.ReactNode;
loading?: boolean;
loadingText?: string;
cardExtras?: React.ReactNode;
hide?: boolean;
placement?: Placement;
enterDelay?: number;
leaveDelay?: number;
target?: React.RefObject<HTMLElement>;
triggerClassName?: string;
onOpen?: () => void;
onClose?: () => void;
}
export const PerformerPopover: React.FC<IPeroformerPopoverProps> = ({
id,
cardContent,
loading,
loadingText,
cardExtras,
hide,
children,
placement = "top",
enterDelay = 500,
leaveDelay = 100,
target,
triggerClassName,
onOpen,
onClose,
}) => {
const { configuration: config } = useConfigurationContext();
@ -59,11 +116,22 @@ export const PerformerPopover: React.FC<IPeroformerPopoverProps> = ({
return (
<HoverPopover
className={triggerClassName}
target={target}
placement={placement}
enterDelay={500}
leaveDelay={100}
content={<PerformerPopoverCard id={id} />}
enterDelay={enterDelay}
leaveDelay={leaveDelay}
onOpen={onOpen}
onClose={onClose}
content={
<PerformerPopoverCard
id={id}
cardContent={cardContent}
loading={loading}
loadingText={loadingText}
cardExtras={cardExtras}
/>
}
>
{children}
</HoverPopover>

View file

@ -158,6 +158,16 @@
}
}
.performer-preview-popover {
.card {
width: 200px;
}
.performer-card-image {
max-height: 300px;
}
}
.scrape-dialog .performer-image {
display: block;
margin-bottom: 10px;

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

@ -12,6 +12,7 @@ import { getStashboxBase } from "src/utils/stashbox";
import { ExternalLink } from "src/components/Shared/ExternalLink";
import { Link } from "react-router-dom";
import { LinkButton } from "../LinkButton";
import { TaggerPerformerPopover } from "./TaggerPerformerPopover";
const PerformerLink: React.FC<{
performer: GQL.ScrapedPerformer | Performer;
@ -73,7 +74,6 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
stashID.endpoint === endpoint &&
stashID.stash_id === performer.remote_site_id
);
const [selectedPerformer, setSelectedPerformer] = useState<Performer>();
const stashboxPerformerPrefix = endpoint
@ -115,10 +115,15 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
<div className="entity-name">
<FormattedMessage id="countables.performers" values={{ count: 1 }} />:
<b className="ml-2">
<PerformerLink
performer={performer}
url={`${stashboxPerformerPrefix}${performer.remote_site_id}`}
/>
<TaggerPerformerPopover
scrapedPerformer={performer}
endpoint={endpoint}
>
<PerformerLink
performer={performer}
url={`${stashboxPerformerPrefix}${performer.remote_site_id}`}
/>
</TaggerPerformerPopover>
</b>
</div>
<span className="ml-auto">
@ -159,10 +164,15 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
<div className="entity-name">
<FormattedMessage id="countables.performers" values={{ count: 1 }} />:
<b className="ml-2">
<PerformerLink
performer={performer}
url={safeBuildPerformerScraperLink(performer.remote_site_id)}
/>
<TaggerPerformerPopover
scrapedPerformer={performer}
endpoint={endpoint}
>
<PerformerLink
performer={performer}
url={safeBuildPerformerScraperLink(performer.remote_site_id)}
/>
</TaggerPerformerPopover>
</b>
</div>
<ButtonGroup>
@ -175,13 +185,20 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
>
<FormattedMessage id="actions.skip" />
</Button>
<PerformerSelect
values={selectedPerformer ? [selectedPerformer] : []}
onSelect={handleSelect}
active={selectedSource === "existing"}
isClearable={false}
ageFromDate={ageFromDate}
/>
<TaggerPerformerPopover
performerID={selectedPerformer?.id}
scrapedPerformer={performer}
endpoint={endpoint}
includeMatchExtras
>
<PerformerSelect
values={selectedPerformer ? [selectedPerformer] : []}
onSelect={handleSelect}
active={selectedSource === "existing"}
isClearable={false}
ageFromDate={ageFromDate}
/>
</TaggerPerformerPopover>
{endpoint && onLink && (
<LinkButton disabled={selectedID === undefined} onLink={onLink} />
)}

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

@ -0,0 +1,304 @@
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;
onClose?: () => void;
}
export const TaggerPerformerPopover: React.FC<
React.PropsWithChildren<ITaggerPerformerPopoverProps>
> = ({
performer,
performerID,
scrapedPerformer,
endpoint,
cardExtras,
includeMatchExtras = false,
placement = "bottom",
triggerClassName = "d-inline-block",
onOpen,
onClose,
children,
}) => {
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={localPerformer} zoomIndex={0} />
</div>
) : scrapedPerformer ? (
<div className="tag-popover-card tagger-performer-popover-card">
<ScrapedPerformerCard
scrapedPerformer={scrapedPerformer}
endpoint={endpoint ?? ""}
zoomIndex={0}
/>
</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={
matchExtras || cardExtras ? (
<>
{matchExtras}
{cardExtras}
</>
) : null
}
placement={placement}
enterDelay={1000}
leaveDelay={500}
triggerClassName={triggerClassName}
onOpen={() => {
setIsOpened(true);
onOpen?.();
}}
onClose={() => {
setIsOpened(false);
onClose?.();
}}
>
{children}
</PerformerPopover>
);
};

View file

@ -95,6 +95,52 @@
}
}
.tagger-matched-performer-popover-extra {
border-top: 1px solid $secondary;
margin-top: 0.4rem;
padding-bottom: 0.4rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
padding-top: 0.5rem;
}
.tagger-performer-delta-rows {
font-size: 0.875rem;
line-height: 1.3;
text-align: left;
}
.tagger-performer-stashid-warning {
text-align: left;
.tagger-performer-stashid-warning-chip {
background-color: $danger;
border-radius: 0.25rem;
color: $white;
min-width: auto;
}
}
@media (max-height: 1049px) {
.tagger-performer-popover-card {
.card {
max-width: 160px;
width: 160px;
}
.card-section-title {
font-size: 1rem;
}
// Target BEM class from shared PerformerCard markup; cannot
// be renamed here without changing reusable performer card components.
// stylelint-disable-next-line selector-class-pattern
.performer-card__age {
font-size: 0.9rem;
}
}
}
.selected-result {
background-color: hsl(204, 20%, 30%);
border-radius: 3px;