mirror of
https://github.com/stashapp/stash.git
synced 2026-05-09 05:05:29 +02:00
Enhance performer previews with delta rows and new scraped performer component
- Added delta row functionality to display differences between local and remote performer data in MatchedPerformerPreview. - Introduced ScrapedPerformerPreview component for displaying scraped performer details with hover functionality. - Updated PerformerResult to integrate ScrapedPerformerPreview and pass necessary props for delta rows and warnings. - Enhanced styling for performer popovers to improve user experience.
This commit is contained in:
parent
701bcf8bc3
commit
c7bfdfd3cd
5 changed files with 276 additions and 17 deletions
|
|
@ -43,6 +43,7 @@ interface IPeroformerPopoverProps {
|
|||
placement?: Placement;
|
||||
target?: React.RefObject<HTMLElement>;
|
||||
cardClassName?: string;
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
export const PerformerPopover: React.FC<IPeroformerPopoverProps> = ({
|
||||
|
|
@ -52,6 +53,7 @@ export const PerformerPopover: React.FC<IPeroformerPopoverProps> = ({
|
|||
placement = "top",
|
||||
target,
|
||||
cardClassName,
|
||||
triggerClassName,
|
||||
}) => {
|
||||
const { configuration: config } = useConfigurationContext();
|
||||
|
||||
|
|
@ -63,6 +65,7 @@ export const PerformerPopover: React.FC<IPeroformerPopoverProps> = ({
|
|||
|
||||
return (
|
||||
<HoverPopover
|
||||
className={triggerClassName}
|
||||
target={target}
|
||||
placement={placement}
|
||||
enterDelay={500}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,81 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Placement } from "react-bootstrap/esm/Overlay";
|
||||
import { PerformerPopover } from "src/components/Performers/PerformerPopover";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { PerformerCard } from "src/components/Performers/PerformerCard";
|
||||
import { HoverPopover } from "src/components/Shared/HoverPopover";
|
||||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
|
||||
interface IPerformerDeltaRow {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface IMatchedPerformerPreviewProps {
|
||||
performerID?: string | null;
|
||||
performer?: GQL.PerformerDataFragment | null;
|
||||
placement?: Placement;
|
||||
deltaRows?: IPerformerDeltaRow[];
|
||||
warningStashID?: Pick<GQL.StashId, "endpoint" | "stash_id">;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const MatchedPerformerPreview = ({
|
||||
performerID,
|
||||
placement = "right",
|
||||
performer,
|
||||
placement = "bottom",
|
||||
deltaRows = [],
|
||||
warningStashID,
|
||||
children,
|
||||
}: IMatchedPerformerPreviewProps) => {
|
||||
if (!performerID) {
|
||||
const { configuration: config } = useConfigurationContext();
|
||||
const showPerformerCardOnHover = config?.ui.showTagCardOnHover ?? true;
|
||||
const warningEndpointName = warningStashID
|
||||
? config?.general.stashBoxes.find((sb) => sb.endpoint === warningStashID.endpoint)
|
||||
?.name ?? warningStashID.endpoint
|
||||
: null;
|
||||
|
||||
if (!performerID || !performer || !showPerformerCardOnHover) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<PerformerPopover
|
||||
id={performerID}
|
||||
<HoverPopover
|
||||
className="d-inline-block"
|
||||
placement={placement}
|
||||
cardClassName="tagger-matched-performer-popover"
|
||||
enterDelay={500}
|
||||
leaveDelay={100}
|
||||
content={
|
||||
<div className="tag-popover-card tagger-matched-performer-popover">
|
||||
<PerformerCard performer={performer} zoomIndex={0} />
|
||||
{(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>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</PerformerPopover>
|
||||
</HoverPopover>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,88 @@ 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";
|
||||
|
||||
interface IPerformerDeltaRow {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const normalizeValue = (value: unknown) =>
|
||||
String(value ?? "")
|
||||
.trim()
|
||||
.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: Performer
|
||||
): IPerformerDeltaRow[] => {
|
||||
const rows: IPerformerDeltaRow[] = [];
|
||||
|
||||
pushDeltaIfDifferent(rows, "Birthdate", remote.birthdate, local.birthdate);
|
||||
pushDeltaIfDifferent(rows, "Death Date", remote.death_date, local.death_date);
|
||||
pushDeltaIfDifferent(rows, "Ethnicity", remote.ethnicity, local.ethnicity);
|
||||
pushDeltaIfDifferent(rows, "Hair Color", remote.hair_color, local.hair_color);
|
||||
pushDeltaIfDifferent(rows, "Eye Color", remote.eye_color, local.eye_color);
|
||||
pushDeltaIfDifferent(rows, "Height", remote.height, local.height_cm);
|
||||
pushDeltaIfDifferent(rows, "Weight", remote.weight, local.weight);
|
||||
pushDeltaIfDifferent(
|
||||
rows,
|
||||
"Penis Length",
|
||||
remote.penis_length,
|
||||
local.penis_length
|
||||
);
|
||||
pushDeltaIfDifferent(rows, "Circumcised", remote.circumcised, local.circumcised);
|
||||
pushDeltaIfDifferent(
|
||||
rows,
|
||||
"Measurements",
|
||||
remote.measurements,
|
||||
local.measurements
|
||||
);
|
||||
pushDeltaIfDifferent(rows, "Fake Tits", remote.fake_tits, local.fake_tits);
|
||||
pushDeltaIfDifferent(rows, "Tattoos", remote.tattoos, local.tattoos);
|
||||
pushDeltaIfDifferent(rows, "Piercings", remote.piercings, local.piercings);
|
||||
pushDeltaIfDifferent(rows, "Career Start", remote.career_start, local.career_start);
|
||||
pushDeltaIfDifferent(rows, "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 > 0 && remoteAliasesCount !== localAliasesCount) {
|
||||
rows.push({ label: "Aliases", value: String(remoteAliasesCount) });
|
||||
}
|
||||
|
||||
const remoteUrlsCount = remote.urls?.length ?? 0;
|
||||
const localUrlsCount = local.urls?.length ?? 0;
|
||||
if (remoteUrlsCount > 0 && remoteUrlsCount !== localUrlsCount) {
|
||||
rows.push({ label: "URLs", value: String(remoteUrlsCount) });
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
const PerformerLink: React.FC<{
|
||||
performer: GQL.ScrapedPerformer | Performer;
|
||||
|
|
@ -74,7 +156,6 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
|
|||
stashID.endpoint === endpoint &&
|
||||
stashID.stash_id === performer.remote_site_id
|
||||
);
|
||||
|
||||
const [selectedPerformer, setSelectedPerformer] = useState<Performer>();
|
||||
|
||||
const stashboxPerformerPrefix = endpoint
|
||||
|
|
@ -116,10 +197,12 @@ 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}`}
|
||||
/>
|
||||
<ScrapedPerformerPreview performer={performer}>
|
||||
<PerformerLink
|
||||
performer={performer}
|
||||
url={`${stashboxPerformerPrefix}${performer.remote_site_id}`}
|
||||
/>
|
||||
</ScrapedPerformerPreview>
|
||||
</b>
|
||||
</div>
|
||||
<span className="ml-auto">
|
||||
|
|
@ -148,6 +231,17 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
|
|||
}
|
||||
|
||||
const selectedSource = !selectedID ? "skip" : "existing";
|
||||
const selectedPerformerConflictStashID =
|
||||
endpoint && performer.remote_site_id && selectedPerformer
|
||||
? selectedPerformer.stash_ids.find(
|
||||
(stashID) =>
|
||||
stashID.endpoint === endpoint &&
|
||||
stashID.stash_id !== performer.remote_site_id
|
||||
)
|
||||
: undefined;
|
||||
const selectedPerformerDeltaRows = selectedPerformer
|
||||
? buildPerformerDeltaRows(performer, selectedPerformer)
|
||||
: [];
|
||||
|
||||
const safeBuildPerformerScraperLink = (id: string | null | undefined) => {
|
||||
return stashboxPerformerPrefix && id
|
||||
|
|
@ -160,10 +254,12 @@ 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)}
|
||||
/>
|
||||
<ScrapedPerformerPreview performer={performer}>
|
||||
<PerformerLink
|
||||
performer={performer}
|
||||
url={safeBuildPerformerScraperLink(performer.remote_site_id)}
|
||||
/>
|
||||
</ScrapedPerformerPreview>
|
||||
</b>
|
||||
</div>
|
||||
<ButtonGroup>
|
||||
|
|
@ -176,7 +272,12 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
|
|||
>
|
||||
<FormattedMessage id="actions.skip" />
|
||||
</Button>
|
||||
<MatchedPerformerPreview performerID={selectedPerformer?.id}>
|
||||
<MatchedPerformerPreview
|
||||
performerID={selectedPerformer?.id}
|
||||
performer={selectedPerformer}
|
||||
warningStashID={selectedPerformerConflictStashID}
|
||||
deltaRows={selectedPerformerDeltaRows}
|
||||
>
|
||||
<PerformerSelect
|
||||
values={selectedPerformer ? [selectedPerformer] : []}
|
||||
onSelect={handleSelect}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Placement } from "react-bootstrap/esm/Overlay";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
import { HoverPopover } from "src/components/Shared/HoverPopover";
|
||||
import { PerformerCard } from "src/components/Performers/PerformerCard";
|
||||
|
||||
interface IScrapedPerformerPreviewProps {
|
||||
performer: GQL.ScrapedPerformer;
|
||||
placement?: Placement;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const toPerformerCardData = (performer: GQL.ScrapedPerformer) =>
|
||||
({
|
||||
id:
|
||||
performer.stored_id ??
|
||||
performer.remote_site_id ??
|
||||
`scraped-${performer.name?.replace(/\s+/g, "-").toLowerCase() ?? "performer"}`,
|
||||
name: performer.name ?? "Unknown performer",
|
||||
disambiguation: performer.disambiguation ?? null,
|
||||
gender: performer.gender ?? null,
|
||||
birthdate: performer.birthdate ?? null,
|
||||
death_date: performer.death_date ?? null,
|
||||
country: performer.country ?? null,
|
||||
image_path: performer.images?.[0] ?? performer.image_path ?? null,
|
||||
tags: [],
|
||||
stash_ids: [],
|
||||
favorite: false,
|
||||
scene_count: null,
|
||||
image_count: null,
|
||||
gallery_count: null,
|
||||
group_count: null,
|
||||
o_counter: null,
|
||||
rating100: null,
|
||||
urls: performer.urls ?? [],
|
||||
}) as GQL.PerformerDataFragment;
|
||||
|
||||
const ScrapedPerformerCard = ({ performer }: { performer: GQL.ScrapedPerformer }) => {
|
||||
return (
|
||||
<div className="tag-popover-card tagger-scraped-performer-popover">
|
||||
<PerformerCard performer={toPerformerCardData(performer)} zoomIndex={0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScrapedPerformerPreview = ({
|
||||
performer,
|
||||
placement = "bottom",
|
||||
children,
|
||||
}: IScrapedPerformerPreviewProps) => {
|
||||
const { configuration: config } = useConfigurationContext();
|
||||
const showPerformerCardOnHover = config?.ui.showTagCardOnHover ?? true;
|
||||
|
||||
if (!showPerformerCardOnHover) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
className="d-inline-block"
|
||||
placement={placement}
|
||||
enterDelay={500}
|
||||
leaveDelay={100}
|
||||
content={<ScrapedPerformerCard performer={performer} />}
|
||||
>
|
||||
{children}
|
||||
</HoverPopover>
|
||||
);
|
||||
};
|
||||
|
|
@ -105,6 +105,39 @@
|
|||
}
|
||||
}
|
||||
|
||||
.tagger-matched-performer-popover-extra {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
margin-top: 0.4rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.tagger-scraped-performer-popover {
|
||||
.card {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.performer-card-image {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-result {
|
||||
background-color: hsl(204, 20%, 30%);
|
||||
border-radius: 3px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue