From 6f9dc1a09b0e2edc2da37294e480a4fecc3e2281 Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 13:10:06 -0400 Subject: [PATCH 01/41] Enhance PerformerPopover with cardClassName prop and add MatchedPerformerPreview component - Added cardClassName prop to PerformerPopoverCard and PerformerPopover for customizable styling. - Introduced MatchedPerformerPreview component to encapsulate PerformerPopover usage with a performerID. - Updated PerformerResult to utilize MatchedPerformerPreview for improved structure and styling. --- .../Performers/PerformerPopover.tsx | 8 +++-- .../Tagger/scenes/MatchedPerformerPreview.tsx | 29 +++++++++++++++++++ .../Tagger/scenes/PerformerResult.tsx | 13 +++++---- ui/v2.5/src/components/Tagger/styles.scss | 10 +++++++ 4 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx diff --git a/ui/v2.5/src/components/Performers/PerformerPopover.tsx b/ui/v2.5/src/components/Performers/PerformerPopover.tsx index 04cbf8e87..52f825a50 100644 --- a/ui/v2.5/src/components/Performers/PerformerPopover.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPopover.tsx @@ -9,10 +9,12 @@ import { Placement } from "react-bootstrap/esm/Overlay"; interface IPeromerPopoverCardProps { id: string; + cardClassName?: string; } export const PerformerPopoverCard: React.FC = ({ id, + cardClassName, }) => { const { data, loading, error } = useFindPerformer(id); @@ -29,7 +31,7 @@ export const PerformerPopoverCard: React.FC = ({ const performer = data.findPerformer; return ( -
+
); @@ -40,6 +42,7 @@ interface IPeroformerPopoverProps { hide?: boolean; placement?: Placement; target?: React.RefObject; + cardClassName?: string; } export const PerformerPopover: React.FC = ({ @@ -48,6 +51,7 @@ export const PerformerPopover: React.FC = ({ children, placement = "top", target, + cardClassName, }) => { const { configuration: config } = useConfigurationContext(); @@ -63,7 +67,7 @@ export const PerformerPopover: React.FC = ({ placement={placement} enterDelay={500} leaveDelay={100} - content={} + content={} > {children} diff --git a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx new file mode 100644 index 000000000..24d2b63b6 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Placement } from "react-bootstrap/esm/Overlay"; +import { PerformerPopover } from "src/components/Performers/PerformerPopover"; + +interface IMatchedPerformerPreviewProps { + performerID?: string | null; + placement?: Placement; +} + +export const MatchedPerformerPreview: React.FC = ({ + performerID, + placement = "right", + children, +}) => { + if (!performerID) { + return <>{children}; + } + + return ( + + {children} + + ); +}; + diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index 53caba2ff..c57f3dad2 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -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 { MatchedPerformerPreview } from "./MatchedPerformerPreview"; const PerformerLink: React.FC<{ performer: GQL.ScrapedPerformer | Performer; @@ -133,11 +134,13 @@ const PerformerResult: React.FC = ({ : - + + +
diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 0e9db45a6..006dba876 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -95,6 +95,16 @@ } } +.tagger-matched-performer-popover { + .card { + width: 200px; + } + + .performer-card-image { + max-height: 300px; + } +} + .selected-result { background-color: hsl(204, 20%, 30%); border-radius: 3px; From de8f8465f7da421dd528b4ed3056f09456d1dd82 Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 13:21:14 -0400 Subject: [PATCH 02/41] Format MatchedPerformerPreview for prettier Made-with: Cursor --- .../components/Tagger/scenes/MatchedPerformerPreview.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx index 24d2b63b6..2e2d4033d 100644 --- a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx @@ -7,11 +7,9 @@ interface IMatchedPerformerPreviewProps { placement?: Placement; } -export const MatchedPerformerPreview: React.FC = ({ - performerID, - placement = "right", - children, -}) => { +export const MatchedPerformerPreview: React.FC< + IMatchedPerformerPreviewProps +> = ({ performerID, placement = "right", children }) => { if (!performerID) { return <>{children}; } From 93d9a59976771974c1e28346870b718979867575 Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 13:24:09 -0400 Subject: [PATCH 03/41] Adjust MatchedPerformerPreview declaration formatting Made-with: Cursor --- .../components/Tagger/scenes/MatchedPerformerPreview.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx index 2e2d4033d..595137c77 100644 --- a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx @@ -7,9 +7,11 @@ interface IMatchedPerformerPreviewProps { placement?: Placement; } -export const MatchedPerformerPreview: React.FC< - IMatchedPerformerPreviewProps -> = ({ performerID, placement = "right", children }) => { +export const MatchedPerformerPreview = ({ + performerID, + placement = "right", + children, +}: IMatchedPerformerPreviewProps) => { if (!performerID) { return <>{children}; } From 8d1c21a705f2a0417b8942f6d2a6033df60b3272 Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 13:25:27 -0400 Subject: [PATCH 04/41] Remove unused React import in MatchedPerformerPreview Made-with: Cursor --- ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx index 595137c77..e39605739 100644 --- a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { Placement } from "react-bootstrap/esm/Overlay"; import { PerformerPopover } from "src/components/Performers/PerformerPopover"; From d0f1de3b9d17ecc72b1a397c1a76cb7c048bc64b Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 13:27:14 -0400 Subject: [PATCH 05/41] Add children prop typing for MatchedPerformerPreview Made-with: Cursor --- .../src/components/Tagger/scenes/MatchedPerformerPreview.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx index e39605739..02776fad1 100644 --- a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx @@ -1,9 +1,11 @@ +import { ReactNode } from "react"; import { Placement } from "react-bootstrap/esm/Overlay"; import { PerformerPopover } from "src/components/Performers/PerformerPopover"; interface IMatchedPerformerPreviewProps { performerID?: string | null; placement?: Placement; + children?: ReactNode; } export const MatchedPerformerPreview = ({ From 7b38f6c419a312ac2051ed150093bbd74e0081d7 Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 13:29:43 -0400 Subject: [PATCH 06/41] Remove extra trailing blank line in MatchedPerformerPreview Made-with: Cursor --- ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx index 02776fad1..a2818b6f8 100644 --- a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx @@ -27,4 +27,3 @@ export const MatchedPerformerPreview = ({ ); }; - From 701bcf8bc3cc8e21e123eb42186251d2fc1686e3 Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 14:54:47 -0400 Subject: [PATCH 07/41] Tagger: show performer hover on picker selection Made-with: Cursor --- .../Tagger/scenes/PerformerResult.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index c57f3dad2..f3c67e964 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -134,13 +134,11 @@ const PerformerResult: React.FC = ({ : - - - + @@ -178,13 +176,15 @@ const PerformerResult: React.FC = ({ > - + + + {endpoint && onLink && ( )} From c7bfdfd3cdf7a03a569ea5fe78abc00a71310283 Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 16:03:02 -0400 Subject: [PATCH 08/41] 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. --- .../Performers/PerformerPopover.tsx | 3 + .../Tagger/scenes/MatchedPerformerPreview.tsx | 66 +++++++++- .../Tagger/scenes/PerformerResult.tsx | 121 ++++++++++++++++-- .../Tagger/scenes/ScrapedPerformerPreview.tsx | 70 ++++++++++ ui/v2.5/src/components/Tagger/styles.scss | 33 +++++ 5 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx diff --git a/ui/v2.5/src/components/Performers/PerformerPopover.tsx b/ui/v2.5/src/components/Performers/PerformerPopover.tsx index 52f825a50..5c2815917 100644 --- a/ui/v2.5/src/components/Performers/PerformerPopover.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPopover.tsx @@ -43,6 +43,7 @@ interface IPeroformerPopoverProps { placement?: Placement; target?: React.RefObject; cardClassName?: string; + triggerClassName?: string; } export const PerformerPopover: React.FC = ({ @@ -52,6 +53,7 @@ export const PerformerPopover: React.FC = ({ placement = "top", target, cardClassName, + triggerClassName, }) => { const { configuration: config } = useConfigurationContext(); @@ -63,6 +65,7 @@ export const PerformerPopover: React.FC = ({ return ( ; 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 ( - + + {(warningStashID || deltaRows.length > 0) && ( +
+ {warningStashID && ( +
+ + + {warningEndpointName} + + +
+ )} + {deltaRows.length > 0 && ( +
+ {deltaRows.map((row) => ( +
+ {row.label}: {row.value} +
+ ))} +
+ )} +
+ )} + + } > {children} -
+
); }; diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index f3c67e964..a864617f2 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -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 = ({ stashID.endpoint === endpoint && stashID.stash_id === performer.remote_site_id ); - const [selectedPerformer, setSelectedPerformer] = useState(); const stashboxPerformerPrefix = endpoint @@ -116,10 +197,12 @@ const PerformerResult: React.FC = ({
: - + + +
@@ -148,6 +231,17 @@ const PerformerResult: React.FC = ({ } 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 = ({
: - + + +
@@ -176,7 +272,12 @@ const PerformerResult: React.FC = ({ > - + + ({ + 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 ( +
+ +
+ ); +}; + +export const ScrapedPerformerPreview = ({ + performer, + placement = "bottom", + children, +}: IScrapedPerformerPreviewProps) => { + const { configuration: config } = useConfigurationContext(); + const showPerformerCardOnHover = config?.ui.showTagCardOnHover ?? true; + + if (!showPerformerCardOnHover) { + return <>{children}; + } + + return ( + } + > + {children} + + ); +}; diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 006dba876..c76c20358 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -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; From d4080f2d9be36d3a30c1610436c146f51983c9aa Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 16:36:23 -0400 Subject: [PATCH 09/41] Refactor performer delta row conditions in PerformerResult - Simplified conditions for displaying remote aliases and URLs by removing unnecessary checks against local counts. - Ensured that rows are only pushed when remote counts exceed local counts, improving clarity and performance. --- ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index a864617f2..0a9cc2f8f 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -83,13 +83,13 @@ const buildPerformerDeltaRows = ( .filter(Boolean).length : 0; const localAliasesCount = local.alias_list?.length ?? 0; - if (remoteAliasesCount > 0 && remoteAliasesCount !== localAliasesCount) { + if (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) { + if (remoteUrlsCount > localUrlsCount) { rows.push({ label: "URLs", value: String(remoteUrlsCount) }); } From db693060ea5310effcda7d475b8c13766cd5feaa Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 16:36:52 -0400 Subject: [PATCH 10/41] Add local PR draft notes for feature 6853. Document summary, test plan, and compatibility note for card class shape changes. Made-with: Cursor --- docs/pr-6853-performer-hover-preview-draft.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/pr-6853-performer-hover-preview-draft.md diff --git a/docs/pr-6853-performer-hover-preview-draft.md b/docs/pr-6853-performer-hover-preview-draft.md new file mode 100644 index 000000000..172c2269c --- /dev/null +++ b/docs/pr-6853-performer-hover-preview-draft.md @@ -0,0 +1,75 @@ +# Draft PR: Tagger Performer Hover Preview Enhancements + +## PR Title +Enhance Tagger performer match UX with hover previews and mismatch warnings + +## Base / Head +- Base: `stashapp/stash` `develop` +- Head: `Stash-KennyG/stash` `feature/6853-performer-hover-preview` + +## Summary +- Adds a hover preview for matched performer selection in Scene Tagger by attaching the preview to the picker control, not the matched text label. +- Adds a scraped performer hover preview on the left performer label using scraped/remote data, while rendering through shared performer card UI for visual consistency. +- Adds mismatch context in the matched performer hover card for non-matching performer fields (including aliases/URLs as counts), to make potential cross-linkage easier to spot before save. +- Adds stash ID conflict warning inside the hover card when the local selected performer already has a stash ID for the same endpoint but with a different GUID. +- Uses a compact red endpoint chip for stash ID conflict warning, with full conflicting GUID available via chip tooltip. + +## Files Changed +- `ui/v2.5/src/components/Performers/PerformerPopover.tsx` +- `ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx` +- `ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx` +- `ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx` (new) +- `ui/v2.5/src/components/Tagger/styles.scss` + +## Behavior Details +- **Picker hover target**: hover card now opens from the selected picker chip/control. +- **Left-side scraped hover**: hover on scraped performer text shows card built from scraped data (image/name/etc.) but styled with shared `PerformerCard`. +- **Mismatch rows**: shown only for fields where scraped has a value and differs from selected local value. +- **Aliases / URLs**: reported as counts only. +- **Stash ID conflict warning**: + - shown only when `selected stash_id(endpoint) != incoming remote_site_id` + - displayed inside card, above mismatch rows + - compact red endpoint chip; GUID is tooltip text. + +## Mismatch Fields Included +- Birthdate +- Death Date +- Ethnicity +- Hair Color +- Eye Color +- Height +- Weight +- Penis Length +- Circumcised +- Measurements +- Fake Tits +- Tattoos +- Piercings +- Career Start +- Career End +- Aliases (count) +- URLs (count) + +## Test Plan +- [ ] Hover selected performer picker in Scene Tagger matched result and confirm card opens below picker. +- [ ] Hover left scraped performer name and confirm scraped preview card appears and matches shared performer card styling. +- [ ] Confirm mismatch rows render only for differing values and are hidden for exact matches. +- [ ] Confirm aliases/URLs mismatch rows show count only. +- [ ] Confirm stash ID warning appears only when endpoint matches and GUID differs. +- [ ] Confirm warning chip is compact red endpoint chip and GUID is visible in tooltip. +- [ ] Confirm no stash ID warning appears when GUIDs match. +- [ ] Confirm no regressions in create/skip/select/link flow for performer rows. +- [ ] Confirm lint/build checks pass. + +## Notes +- This feature is designed to improve confidence in match correctness before save, especially in larger datasets where name collisions are more common. + +## Compatibility Note: CSS Class Shape +- `PerformerPopover` now supports an optional `cardClassName` prop and applies it by appending to the existing wrapper class: + - before: `className="tag-popover-card"` + - after: `className={\`tag-popover-card ${cardClassName ?? ""}\`.trim()}` +- The base class `tag-popover-card` is preserved, so existing styling and selectors that target it continue to work. +- This is expected to be low-risk for downstream usage because: + - the prop is optional + - existing callers do not need to change + - DOM structure is unchanged (same wrapper element). From 8fcaf3a596f919aa2583512b9a46191385219263 Mon Sep 17 00:00:00 2001 From: Stash-KennyG <138793998+Stash-KennyG@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:38:14 -0400 Subject: [PATCH 11/41] Delete docs/pr-6853-performer-hover-preview-draft.md Cursor overzealous. --- docs/pr-6853-performer-hover-preview-draft.md | 75 ------------------- 1 file changed, 75 deletions(-) delete mode 100644 docs/pr-6853-performer-hover-preview-draft.md diff --git a/docs/pr-6853-performer-hover-preview-draft.md b/docs/pr-6853-performer-hover-preview-draft.md deleted file mode 100644 index 172c2269c..000000000 --- a/docs/pr-6853-performer-hover-preview-draft.md +++ /dev/null @@ -1,75 +0,0 @@ -# Draft PR: Tagger Performer Hover Preview Enhancements - -## PR Title -Enhance Tagger performer match UX with hover previews and mismatch warnings - -## Base / Head -- Base: `stashapp/stash` `develop` -- Head: `Stash-KennyG/stash` `feature/6853-performer-hover-preview` - -## Summary -- Adds a hover preview for matched performer selection in Scene Tagger by attaching the preview to the picker control, not the matched text label. -- Adds a scraped performer hover preview on the left performer label using scraped/remote data, while rendering through shared performer card UI for visual consistency. -- Adds mismatch context in the matched performer hover card for non-matching performer fields (including aliases/URLs as counts), to make potential cross-linkage easier to spot before save. -- Adds stash ID conflict warning inside the hover card when the local selected performer already has a stash ID for the same endpoint but with a different GUID. -- Uses a compact red endpoint chip for stash ID conflict warning, with full conflicting GUID available via chip tooltip. - -## Files Changed -- `ui/v2.5/src/components/Performers/PerformerPopover.tsx` -- `ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx` -- `ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx` -- `ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx` (new) -- `ui/v2.5/src/components/Tagger/styles.scss` - -## Behavior Details -- **Picker hover target**: hover card now opens from the selected picker chip/control. -- **Left-side scraped hover**: hover on scraped performer text shows card built from scraped data (image/name/etc.) but styled with shared `PerformerCard`. -- **Mismatch rows**: shown only for fields where scraped has a value and differs from selected local value. -- **Aliases / URLs**: reported as counts only. -- **Stash ID conflict warning**: - - shown only when `selected stash_id(endpoint) != incoming remote_site_id` - - displayed inside card, above mismatch rows - - compact red endpoint chip; GUID is tooltip text. - -## Mismatch Fields Included -- Birthdate -- Death Date -- Ethnicity -- Hair Color -- Eye Color -- Height -- Weight -- Penis Length -- Circumcised -- Measurements -- Fake Tits -- Tattoos -- Piercings -- Career Start -- Career End -- Aliases (count) -- URLs (count) - -## Test Plan -- [ ] Hover selected performer picker in Scene Tagger matched result and confirm card opens below picker. -- [ ] Hover left scraped performer name and confirm scraped preview card appears and matches shared performer card styling. -- [ ] Confirm mismatch rows render only for differing values and are hidden for exact matches. -- [ ] Confirm aliases/URLs mismatch rows show count only. -- [ ] Confirm stash ID warning appears only when endpoint matches and GUID differs. -- [ ] Confirm warning chip is compact red endpoint chip and GUID is visible in tooltip. -- [ ] Confirm no stash ID warning appears when GUIDs match. -- [ ] Confirm no regressions in create/skip/select/link flow for performer rows. -- [ ] Confirm lint/build checks pass. - -## Notes -- This feature is designed to improve confidence in match correctness before save, especially in larger datasets where name collisions are more common. - -## Compatibility Note: CSS Class Shape -- `PerformerPopover` now supports an optional `cardClassName` prop and applies it by appending to the existing wrapper class: - - before: `className="tag-popover-card"` - - after: `className={\`tag-popover-card ${cardClassName ?? ""}\`.trim()}` -- The base class `tag-popover-card` is preserved, so existing styling and selectors that target it continue to work. -- This is expected to be low-risk for downstream usage because: - - the prop is optional - - existing callers do not need to change - - DOM structure is unchanged (same wrapper element). From 9ba90cb7153fe092470a2237460cb132743a676d Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 16:44:54 -0400 Subject: [PATCH 12/41] Fix performer preview TypeScript types for CI. Use full findPerformer data for mismatch comparisons and selected hover content, and normalize scraped card mapping to satisfy PerformerDataFragment shape. Made-with: Cursor --- .../Tagger/scenes/PerformerResult.tsx | 20 ++++++++++++------- .../Tagger/scenes/ScrapedPerformerPreview.tsx | 13 ++++++++++-- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index 0a9cc2f8f..63850071a 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -46,7 +46,7 @@ const pushDeltaIfDifferent = ( const buildPerformerDeltaRows = ( remote: GQL.ScrapedPerformer, - local: Performer + local: GQL.PerformerDataFragment ): IPerformerDeltaRow[] => { const rows: IPerformerDeltaRow[] = []; @@ -157,6 +157,12 @@ const PerformerResult: React.FC = ({ stashID.stash_id === performer.remote_site_id ); const [selectedPerformer, setSelectedPerformer] = useState(); + const { data: selectedPerformerData, loading: selectedPerformerLoading } = + GQL.useFindPerformerQuery({ + variables: { id: selectedID ?? "" }, + skip: !selectedID, + }); + const selectedPerformerDetails = selectedPerformerData?.findPerformer; const stashboxPerformerPrefix = endpoint ? `${getStashboxBase(endpoint)}performers/` @@ -189,7 +195,7 @@ const PerformerResult: React.FC = ({ selectPerformer(undefined); }; - if (stashLoading) return
Loading performer
; + if (stashLoading || selectedPerformerLoading) return
Loading performer
; if (matchedPerformer && matchedStashID) { return ( @@ -232,15 +238,15 @@ const PerformerResult: React.FC = ({ const selectedSource = !selectedID ? "skip" : "existing"; const selectedPerformerConflictStashID = - endpoint && performer.remote_site_id && selectedPerformer - ? selectedPerformer.stash_ids.find( + endpoint && performer.remote_site_id && selectedPerformerDetails + ? selectedPerformerDetails.stash_ids.find( (stashID) => stashID.endpoint === endpoint && stashID.stash_id !== performer.remote_site_id ) : undefined; - const selectedPerformerDeltaRows = selectedPerformer - ? buildPerformerDeltaRows(performer, selectedPerformer) + const selectedPerformerDeltaRows = selectedPerformerDetails + ? buildPerformerDeltaRows(performer, selectedPerformerDetails) : []; const safeBuildPerformerScraperLink = (id: string | null | undefined) => { @@ -274,7 +280,7 @@ const PerformerResult: React.FC = ({ diff --git a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx index 7f762620f..490d85cf2 100644 --- a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx @@ -18,23 +18,32 @@ const toPerformerCardData = (performer: GQL.ScrapedPerformer) => performer.remote_site_id ?? `scraped-${performer.name?.replace(/\s+/g, "-").toLowerCase() ?? "performer"}`, name: performer.name ?? "Unknown performer", + alias_list: performer.aliases + ? performer.aliases + .split(",") + .map((a) => a.trim()) + .filter(Boolean) + : [], 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, + image_path: performer.images?.[0] ?? null, tags: [], + custom_fields: [], stash_ids: [], favorite: false, + ignore_auto_tag: false, scene_count: null, image_count: null, gallery_count: null, group_count: null, + performer_count: null, o_counter: null, rating100: null, urls: performer.urls ?? [], - }) as GQL.PerformerDataFragment; + } as unknown as GQL.PerformerDataFragment); const ScrapedPerformerCard = ({ performer }: { performer: GQL.ScrapedPerformer }) => { return ( From 211243919f6264873df0687cefca8ce5db2b655f Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 16:49:26 -0400 Subject: [PATCH 13/41] Fix CI formatting for tagger performer preview files. Apply Prettier-compatible formatting updates in matched preview, performer result, and scraped preview components. Made-with: Cursor --- .../Tagger/scenes/MatchedPerformerPreview.tsx | 5 +++-- .../components/Tagger/scenes/PerformerResult.tsx | 6 ++++-- .../Tagger/scenes/ScrapedPerformerPreview.tsx | 16 +++++++++------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx index 6738fe22d..a253f0bb0 100644 --- a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx @@ -30,8 +30,9 @@ export const MatchedPerformerPreview = ({ 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 + ? config?.general.stashBoxes.find( + (sb) => sb.endpoint === warningStashID.endpoint + )?.name ?? warningStashID.endpoint : null; if (!performerID || !performer || !showPerformerCardOnHover) { diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index 63850071a..e0a02f4cc 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -195,7 +195,9 @@ const PerformerResult: React.FC = ({ selectPerformer(undefined); }; - if (stashLoading || selectedPerformerLoading) return
Loading performer
; + if (stashLoading || selectedPerformerLoading) { + return
Loading performer
; + } if (matchedPerformer && matchedStashID) { return ( @@ -280,7 +282,7 @@ const PerformerResult: React.FC = ({ diff --git a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx index 490d85cf2..c61190545 100644 --- a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx @@ -45,13 +45,15 @@ const toPerformerCardData = (performer: GQL.ScrapedPerformer) => urls: performer.urls ?? [], } as unknown as GQL.PerformerDataFragment); -const ScrapedPerformerCard = ({ performer }: { performer: GQL.ScrapedPerformer }) => { - return ( -
- -
- ); -}; +const ScrapedPerformerCard = ({ + performer, +}: { + performer: GQL.ScrapedPerformer; +}) => ( +
+ +
+); export const ScrapedPerformerPreview = ({ performer, From 609395cbebf2a8dc68d7f8311697481b57615e67 Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 16:53:01 -0400 Subject: [PATCH 14/41] Adjust formatting in performer result and scraped preview. Normalize formatting patterns to align with repository Prettier output. Made-with: Cursor --- .../src/components/Tagger/scenes/PerformerResult.tsx | 12 +++++++----- .../Tagger/scenes/ScrapedPerformerPreview.tsx | 6 ++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index e0a02f4cc..265cc820a 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -157,11 +157,13 @@ const PerformerResult: React.FC = ({ stashID.stash_id === performer.remote_site_id ); const [selectedPerformer, setSelectedPerformer] = useState(); - const { data: selectedPerformerData, loading: selectedPerformerLoading } = - GQL.useFindPerformerQuery({ - variables: { id: selectedID ?? "" }, - skip: !selectedID, - }); + const { + data: selectedPerformerData, + loading: selectedPerformerLoading, + } = GQL.useFindPerformerQuery({ + variables: { id: selectedID ?? "" }, + skip: !selectedID, + }); const selectedPerformerDetails = selectedPerformerData?.findPerformer; const stashboxPerformerPrefix = endpoint diff --git a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx index c61190545..36dceb063 100644 --- a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx @@ -11,7 +11,9 @@ interface IScrapedPerformerPreviewProps { children?: ReactNode; } -const toPerformerCardData = (performer: GQL.ScrapedPerformer) => +const toPerformerCardData = ( + performer: GQL.ScrapedPerformer +): GQL.PerformerDataFragment => ({ id: performer.stored_id ?? @@ -43,7 +45,7 @@ const toPerformerCardData = (performer: GQL.ScrapedPerformer) => o_counter: null, rating100: null, urls: performer.urls ?? [], - } as unknown as GQL.PerformerDataFragment); + }) as unknown as GQL.PerformerDataFragment; const ScrapedPerformerCard = ({ performer, From ba2ec74f4bf18f55fc0a8b600815fb3b45c01241 Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 16:55:41 -0400 Subject: [PATCH 15/41] Normalize formatting patterns in tagger performer preview files. Restructure imports and helper formatting to better match repository Prettier conventions. Made-with: Cursor --- .../Tagger/scenes/PerformerResult.tsx | 5 +---- .../Tagger/scenes/ScrapedPerformerPreview.tsx | 21 +++++++++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index 265cc820a..35fd9d012 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -4,10 +4,7 @@ import { FormattedMessage } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { OptionalField } from "../IncludeButton"; -import { - Performer, - PerformerSelect, -} from "src/components/Performers/PerformerSelect"; +import { Performer, PerformerSelect } from "src/components/Performers/PerformerSelect"; import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { Link } from "react-router-dom"; diff --git a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx index 36dceb063..21eaef5e4 100644 --- a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx @@ -13,19 +13,21 @@ interface IScrapedPerformerPreviewProps { const toPerformerCardData = ( performer: GQL.ScrapedPerformer -): GQL.PerformerDataFragment => - ({ +): GQL.PerformerDataFragment => { + const aliasList = performer.aliases + ? performer.aliases + .split(",") + .map((a) => a.trim()) + .filter(Boolean) + : []; + + return { id: performer.stored_id ?? performer.remote_site_id ?? `scraped-${performer.name?.replace(/\s+/g, "-").toLowerCase() ?? "performer"}`, name: performer.name ?? "Unknown performer", - alias_list: performer.aliases - ? performer.aliases - .split(",") - .map((a) => a.trim()) - .filter(Boolean) - : [], + alias_list: aliasList, disambiguation: performer.disambiguation ?? null, gender: performer.gender ?? null, birthdate: performer.birthdate ?? null, @@ -45,7 +47,8 @@ const toPerformerCardData = ( o_counter: null, rating100: null, urls: performer.urls ?? [], - }) as unknown as GQL.PerformerDataFragment; + } as unknown as GQL.PerformerDataFragment; +}; const ScrapedPerformerCard = ({ performer, From accc6ad1ae4733c02473294403709e23e6a32456 Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 16:59:50 -0400 Subject: [PATCH 16/41] Align tagger preview files with repository formatting patterns. Rework formatting structure in performer result and scraped preview to match established Prettier output style. Made-with: Cursor --- .../Tagger/scenes/PerformerResult.tsx | 21 +++++++++---------- .../Tagger/scenes/ScrapedPerformerPreview.tsx | 20 +++++++----------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index 35fd9d012..c322e35f9 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -4,7 +4,10 @@ import { FormattedMessage } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { OptionalField } from "../IncludeButton"; -import { Performer, PerformerSelect } from "src/components/Performers/PerformerSelect"; +import { + Performer, + PerformerSelect, +} from "src/components/Performers/PerformerSelect"; import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { Link } from "react-router-dom"; @@ -154,13 +157,11 @@ const PerformerResult: React.FC = ({ stashID.stash_id === performer.remote_site_id ); const [selectedPerformer, setSelectedPerformer] = useState(); - const { - data: selectedPerformerData, - loading: selectedPerformerLoading, - } = GQL.useFindPerformerQuery({ - variables: { id: selectedID ?? "" }, - skip: !selectedID, - }); + const { data: selectedPerformerData, loading: selectedPerformerLoading } = + GQL.useFindPerformerQuery({ + variables: { id: selectedID ?? "" }, + skip: !selectedID, + }); const selectedPerformerDetails = selectedPerformerData?.findPerformer; const stashboxPerformerPrefix = endpoint @@ -194,9 +195,7 @@ const PerformerResult: React.FC = ({ selectPerformer(undefined); }; - if (stashLoading || selectedPerformerLoading) { - return
Loading performer
; - } + if (stashLoading || selectedPerformerLoading) return
Loading performer
; if (matchedPerformer && matchedStashID) { return ( diff --git a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx index 21eaef5e4..d1cc8d338 100644 --- a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx @@ -11,9 +11,7 @@ interface IScrapedPerformerPreviewProps { children?: ReactNode; } -const toPerformerCardData = ( - performer: GQL.ScrapedPerformer -): GQL.PerformerDataFragment => { +const toPerformerCardData = (performer: GQL.ScrapedPerformer) => { const aliasList = performer.aliases ? performer.aliases .split(",") @@ -50,15 +48,13 @@ const toPerformerCardData = ( } as unknown as GQL.PerformerDataFragment; }; -const ScrapedPerformerCard = ({ - performer, -}: { - performer: GQL.ScrapedPerformer; -}) => ( -
- -
-); +const ScrapedPerformerCard = ({ performer }: { performer: GQL.ScrapedPerformer }) => { + return ( +
+ +
+ ); +}; export const ScrapedPerformerPreview = ({ performer, From 49a777c97fcb278ba09cffe682300a0c8f5d281d Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 17:02:46 -0400 Subject: [PATCH 17/41] Apply Prettier formatting to tagger preview files. Format PerformerResult and ScrapedPerformerPreview with Prettier to satisfy validate-ui format checks. Made-with: Cursor --- .../Tagger/scenes/PerformerResult.tsx | 29 +++++++++++++------ .../Tagger/scenes/ScrapedPerformerPreview.tsx | 6 +++- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index c322e35f9..72a99649f 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -35,7 +35,7 @@ const pushDeltaIfDifferent = ( rows: IPerformerDeltaRow[], label: string, remoteValue: unknown, - localValue: unknown + localValue: unknown, ) => { const remoteText = toStringOrNull(remoteValue); if (!remoteText) return; @@ -46,7 +46,7 @@ const pushDeltaIfDifferent = ( const buildPerformerDeltaRows = ( remote: GQL.ScrapedPerformer, - local: GQL.PerformerDataFragment + local: GQL.PerformerDataFragment, ): IPerformerDeltaRow[] => { const rows: IPerformerDeltaRow[] = []; @@ -61,19 +61,29 @@ const buildPerformerDeltaRows = ( rows, "Penis Length", remote.penis_length, - local.penis_length + local.penis_length, + ); + pushDeltaIfDifferent( + rows, + "Circumcised", + remote.circumcised, + local.circumcised, ); - pushDeltaIfDifferent(rows, "Circumcised", remote.circumcised, local.circumcised); pushDeltaIfDifferent( rows, "Measurements", remote.measurements, - local.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 Start", + remote.career_start, + local.career_start, + ); pushDeltaIfDifferent(rows, "Career End", remote.career_end, local.career_end); const remoteAliasesCount = remote.aliases @@ -154,7 +164,7 @@ const PerformerResult: React.FC = ({ const matchedStashID = matchedPerformer?.stash_ids.some( (stashID) => stashID.endpoint === endpoint && - stashID.stash_id === performer.remote_site_id + stashID.stash_id === performer.remote_site_id, ); const [selectedPerformer, setSelectedPerformer] = useState(); const { data: selectedPerformerData, loading: selectedPerformerLoading } = @@ -195,7 +205,8 @@ const PerformerResult: React.FC = ({ selectPerformer(undefined); }; - if (stashLoading || selectedPerformerLoading) return
Loading performer
; + if (stashLoading || selectedPerformerLoading) + return
Loading performer
; if (matchedPerformer && matchedStashID) { return ( @@ -242,7 +253,7 @@ const PerformerResult: React.FC = ({ ? selectedPerformerDetails.stash_ids.find( (stashID) => stashID.endpoint === endpoint && - stashID.stash_id !== performer.remote_site_id + stashID.stash_id !== performer.remote_site_id, ) : undefined; const selectedPerformerDeltaRows = selectedPerformerDetails diff --git a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx index d1cc8d338..7c87052bb 100644 --- a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx @@ -48,7 +48,11 @@ const toPerformerCardData = (performer: GQL.ScrapedPerformer) => { } as unknown as GQL.PerformerDataFragment; }; -const ScrapedPerformerCard = ({ performer }: { performer: GQL.ScrapedPerformer }) => { +const ScrapedPerformerCard = ({ + performer, +}: { + performer: GQL.ScrapedPerformer; +}) => { return (
From c0313fea374236b8a03f11e42da89b8f69323387 Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 21 Apr 2026 17:05:25 -0400 Subject: [PATCH 18/41] Format tagger preview files with Prettier 2.8.4. Use repository Prettier version to align with CI format-check expectations. Made-with: Cursor --- .../components/Tagger/scenes/PerformerResult.tsx | 16 ++++++++-------- .../Tagger/scenes/ScrapedPerformerPreview.tsx | 4 +++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index 72a99649f..6af2a9eda 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -35,7 +35,7 @@ const pushDeltaIfDifferent = ( rows: IPerformerDeltaRow[], label: string, remoteValue: unknown, - localValue: unknown, + localValue: unknown ) => { const remoteText = toStringOrNull(remoteValue); if (!remoteText) return; @@ -46,7 +46,7 @@ const pushDeltaIfDifferent = ( const buildPerformerDeltaRows = ( remote: GQL.ScrapedPerformer, - local: GQL.PerformerDataFragment, + local: GQL.PerformerDataFragment ): IPerformerDeltaRow[] => { const rows: IPerformerDeltaRow[] = []; @@ -61,19 +61,19 @@ const buildPerformerDeltaRows = ( rows, "Penis Length", remote.penis_length, - local.penis_length, + local.penis_length ); pushDeltaIfDifferent( rows, "Circumcised", remote.circumcised, - local.circumcised, + local.circumcised ); pushDeltaIfDifferent( rows, "Measurements", remote.measurements, - local.measurements, + local.measurements ); pushDeltaIfDifferent(rows, "Fake Tits", remote.fake_tits, local.fake_tits); pushDeltaIfDifferent(rows, "Tattoos", remote.tattoos, local.tattoos); @@ -82,7 +82,7 @@ const buildPerformerDeltaRows = ( rows, "Career Start", remote.career_start, - local.career_start, + local.career_start ); pushDeltaIfDifferent(rows, "Career End", remote.career_end, local.career_end); @@ -164,7 +164,7 @@ const PerformerResult: React.FC = ({ const matchedStashID = matchedPerformer?.stash_ids.some( (stashID) => stashID.endpoint === endpoint && - stashID.stash_id === performer.remote_site_id, + stashID.stash_id === performer.remote_site_id ); const [selectedPerformer, setSelectedPerformer] = useState(); const { data: selectedPerformerData, loading: selectedPerformerLoading } = @@ -253,7 +253,7 @@ const PerformerResult: React.FC = ({ ? selectedPerformerDetails.stash_ids.find( (stashID) => stashID.endpoint === endpoint && - stashID.stash_id !== performer.remote_site_id, + stashID.stash_id !== performer.remote_site_id ) : undefined; const selectedPerformerDeltaRows = selectedPerformerDetails diff --git a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx index 7c87052bb..c57ac77c5 100644 --- a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx @@ -23,7 +23,9 @@ const toPerformerCardData = (performer: GQL.ScrapedPerformer) => { id: performer.stored_id ?? performer.remote_site_id ?? - `scraped-${performer.name?.replace(/\s+/g, "-").toLowerCase() ?? "performer"}`, + `scraped-${ + performer.name?.replace(/\s+/g, "-").toLowerCase() ?? "performer" + }`, name: performer.name ?? "Unknown performer", alias_list: aliasList, disambiguation: performer.disambiguation ?? null, From 64c8a22cf5130422b0a8d360b11affb17b60b975 Mon Sep 17 00:00:00 2001 From: KennyG Date: Wed, 22 Apr 2026 08:48:29 -0400 Subject: [PATCH 19/41] Enhance PerformerResult component with internationalization support. - Refactored buildPerformerDeltaRows to utilize `intl` for label formatting, improving localization. - Updated loading state message to use `FormattedMessage` for better internationalization. - Ensured all performer attributes are displayed with localized labels, enhancing user experience across different languages. --- .../Tagger/scenes/PerformerResult.tsx | 112 ++++++++++++++---- 1 file changed, 90 insertions(+), 22 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index 6af2a9eda..90edb8f1c 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; -import { FormattedMessage } from "react-intl"; +import { FormattedMessage, IntlShape, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { OptionalField } from "../IncludeButton"; @@ -46,45 +46,101 @@ const pushDeltaIfDifferent = ( const buildPerformerDeltaRows = ( remote: GQL.ScrapedPerformer, - local: GQL.PerformerDataFragment + local: GQL.PerformerDataFragment, + intl: IntlShape ): 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", + intl.formatMessage({ id: "birthdate", defaultMessage: "Birthdate" }), + remote.birthdate, + local.birthdate + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "death_date", defaultMessage: "Death Date" }), + remote.death_date, + local.death_date + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "ethnicity", defaultMessage: "Ethnicity" }), + remote.ethnicity, + local.ethnicity + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "hair_color", defaultMessage: "Hair Color" }), + remote.hair_color, + local.hair_color + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "eye_color", defaultMessage: "Eye Color" }), + remote.eye_color, + local.eye_color + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "height", defaultMessage: "Height" }), + remote.height, + local.height_cm + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "weight", defaultMessage: "Weight" }), + remote.weight, + local.weight + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "penis_length", defaultMessage: "Penis Length" }), remote.penis_length, local.penis_length ); pushDeltaIfDifferent( rows, - "Circumcised", + intl.formatMessage({ id: "circumcised", defaultMessage: "Circumcised" }), remote.circumcised, local.circumcised ); pushDeltaIfDifferent( rows, - "Measurements", + intl.formatMessage({ id: "measurements", defaultMessage: "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", + intl.formatMessage({ id: "fake_tits", defaultMessage: "Fake Tits" }), + remote.fake_tits, + local.fake_tits + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "tattoos", defaultMessage: "Tattoos" }), + remote.tattoos, + local.tattoos + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "piercings", defaultMessage: "Piercings" }), + remote.piercings, + local.piercings + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "career_start", defaultMessage: "Career Start" }), remote.career_start, local.career_start ); - pushDeltaIfDifferent(rows, "Career End", remote.career_end, local.career_end); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "career_end", defaultMessage: "Career End" }), + remote.career_end, + local.career_end + ); const remoteAliasesCount = remote.aliases ? remote.aliases @@ -94,13 +150,19 @@ const buildPerformerDeltaRows = ( : 0; const localAliasesCount = local.alias_list?.length ?? 0; if (remoteAliasesCount > localAliasesCount) { - rows.push({ label: "Aliases", value: String(remoteAliasesCount) }); + rows.push({ + label: intl.formatMessage({ id: "aliases", defaultMessage: "Aliases" }), + value: String(remoteAliasesCount), + }); } const remoteUrlsCount = remote.urls?.length ?? 0; const localUrlsCount = local.urls?.length ?? 0; if (remoteUrlsCount > localUrlsCount) { - rows.push({ label: "URLs", value: String(remoteUrlsCount) }); + rows.push({ + label: intl.formatMessage({ id: "urls", defaultMessage: "URLs" }), + value: String(remoteUrlsCount), + }); } return rows; @@ -154,6 +216,7 @@ const PerformerResult: React.FC = ({ endpoint, ageFromDate, }) => { + const intl = useIntl(); const { data: performerData, loading: stashLoading } = GQL.useFindPerformerQuery({ variables: { id: performer.stored_id ?? "" }, @@ -205,8 +268,13 @@ const PerformerResult: React.FC = ({ selectPerformer(undefined); }; - if (stashLoading || selectedPerformerLoading) - return
Loading performer
; + if (stashLoading || selectedPerformerLoading) { + return ( +
+ +
+ ); + } if (matchedPerformer && matchedStashID) { return ( @@ -257,7 +325,7 @@ const PerformerResult: React.FC = ({ ) : undefined; const selectedPerformerDeltaRows = selectedPerformerDetails - ? buildPerformerDeltaRows(performer, selectedPerformerDetails) + ? buildPerformerDeltaRows(performer, selectedPerformerDetails, intl) : []; const safeBuildPerformerScraperLink = (id: string | null | undefined) => { From 505753d8052e574ac5b0e31b3873288810ae6695 Mon Sep 17 00:00:00 2001 From: KennyG Date: Wed, 22 Apr 2026 09:22:56 -0400 Subject: [PATCH 20/41] Implement internationalization in ScrapedPerformerPreview component. - Added `useIntl` hook to support localized performer names. - Updated `toPerformerCardData` function to utilize `intl` for formatting unknown performer names, enhancing user experience across different languages. --- .../Tagger/scenes/ScrapedPerformerPreview.tsx | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx index c57ac77c5..68a95ec4e 100644 --- a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx @@ -1,5 +1,6 @@ import { ReactNode } 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 { HoverPopover } from "src/components/Shared/HoverPopover"; @@ -11,22 +12,26 @@ interface IScrapedPerformerPreviewProps { children?: ReactNode; } -const toPerformerCardData = (performer: GQL.ScrapedPerformer) => { +const toPerformerCardData = ( + performer: GQL.ScrapedPerformer, + intl: IntlShape +) => { const aliasList = performer.aliases ? performer.aliases .split(",") .map((a) => a.trim()) .filter(Boolean) : []; - + const unknownPerformerName = intl.formatMessage({ + id: "component_tagger.results.unnamed", + defaultMessage: "Unnamed", + }); return { id: performer.stored_id ?? performer.remote_site_id ?? - `scraped-${ - performer.name?.replace(/\s+/g, "-").toLowerCase() ?? "performer" - }`, - name: performer.name ?? "Unknown performer", + null, + name: performer.name ?? unknownPerformerName, alias_list: aliasList, disambiguation: performer.disambiguation ?? null, gender: performer.gender ?? null, @@ -55,9 +60,13 @@ const ScrapedPerformerCard = ({ }: { performer: GQL.ScrapedPerformer; }) => { + const intl = useIntl(); return (
- +
); }; From 7a87e15f41ed903c71f2bab433d59a7c2ad8373b Mon Sep 17 00:00:00 2001 From: KennyG Date: Wed, 22 Apr 2026 11:29:57 -0400 Subject: [PATCH 21/41] Refactor performer preview components for improved structure and functionality. - Replaced `PerformerCard` with `LocalPerformerCard` in `MatchedPerformerPreview` for better encapsulation. - Introduced `RemotePerformerCard` in `ScrapedPerformerPreview` to enhance the display of performer details, including gender and country flag. - Updated content rendering in `ScrapedPerformerPreview` to utilize the new `RemotePerformerCard` component. --- .../Tagger/scenes/MatchedPerformerPreview.tsx | 6 +- .../Tagger/scenes/ScrapedPerformerPreview.tsx | 110 ++++++++++-------- 2 files changed, 64 insertions(+), 52 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx index a253f0bb0..4f9c386b4 100644 --- a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx @@ -1,9 +1,9 @@ import { ReactNode } from "react"; import { Placement } from "react-bootstrap/esm/Overlay"; 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"; +import { LocalPerformerCard } from "./ScrapedPerformerPreview"; interface IPerformerDeltaRow { label: string; @@ -46,8 +46,8 @@ export const MatchedPerformerPreview = ({ enterDelay={500} leaveDelay={100} content={ -
- +
+ {(warningStashID || deltaRows.length > 0) && (
{warningStashID && ( diff --git a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx index 68a95ec4e..0465ca4ba 100644 --- a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx @@ -1,10 +1,13 @@ import { ReactNode } from "react"; import { Placement } from "react-bootstrap/esm/Overlay"; -import { IntlShape, useIntl } from "react-intl"; +import { useIntl } from "react-intl"; 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"; +import GenderIcon from "src/components/Performers/GenderIcon"; +import { CountryFlag } from "src/components/Shared/CountryFlag"; +import TextUtils from "src/utils/text"; interface IScrapedPerformerPreviewProps { performer: GQL.ScrapedPerformer; @@ -12,61 +15,70 @@ interface IScrapedPerformerPreviewProps { children?: ReactNode; } -const toPerformerCardData = ( - performer: GQL.ScrapedPerformer, - intl: IntlShape -) => { - const aliasList = performer.aliases - ? performer.aliases - .split(",") - .map((a) => a.trim()) - .filter(Boolean) - : []; - const unknownPerformerName = intl.formatMessage({ - id: "component_tagger.results.unnamed", - defaultMessage: "Unnamed", - }); - return { - id: - performer.stored_id ?? - performer.remote_site_id ?? - null, - name: performer.name ?? unknownPerformerName, - alias_list: aliasList, - 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] ?? null, - tags: [], - custom_fields: [], - stash_ids: [], - favorite: false, - ignore_auto_tag: false, - scene_count: null, - image_count: null, - gallery_count: null, - group_count: null, - performer_count: null, - o_counter: null, - rating100: null, - urls: performer.urls ?? [], - } as unknown as GQL.PerformerDataFragment; -}; +export const LocalPerformerCard = ({ + performer, +}: { + performer: GQL.PerformerDataFragment; +}) => ( +
+ +
+); -const ScrapedPerformerCard = ({ +export const RemotePerformerCard = ({ performer, }: { performer: GQL.ScrapedPerformer; }) => { 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", + }), + } + ); + return (
- +
+
+ {name} + {performer.country && ( + + )} +
+
+
+ + {name} + {performer.disambiguation && ( + + {` (${performer.disambiguation})`} + + )} +
+ {age !== 0 &&
{ageString}
} +
+
); }; @@ -89,7 +101,7 @@ export const ScrapedPerformerPreview = ({ placement={placement} enterDelay={500} leaveDelay={100} - content={} + content={} > {children} From db7164d254ee77e23de2f6e03f235ee4a8fb25f5 Mon Sep 17 00:00:00 2001 From: KennyG Date: Wed, 22 Apr 2026 12:02:30 -0400 Subject: [PATCH 22/41] Refactor normalizeValue function in PerformerResult component for improved value handling. - Enhanced the normalizeValue function to handle numeric-like strings, converting them to numbers when applicable. - Maintained existing functionality for trimming and lowercasing non-numeric strings, ensuring consistent value normalization. --- .../Tagger/scenes/PerformerResult.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index 90edb8f1c..5a79a2c04 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -21,9 +21,20 @@ interface IPerformerDeltaRow { } const normalizeValue = (value: unknown) => - String(value ?? "") - .trim() - .toLowerCase(); + (() => { + 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; From 8aae9216e12faca56449bde743c6e8dc41b50bdc Mon Sep 17 00:00:00 2001 From: KennyG Date: Wed, 22 Apr 2026 13:27:48 -0400 Subject: [PATCH 23/41] Fix scraped performer gender type for preview card. Convert scraped performer gender strings to GenderEnum using stringToGender before rendering GenderIcon so UI type-check passes in CI. Made-with: Cursor --- .../components/Tagger/scenes/ScrapedPerformerPreview.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx index 0465ca4ba..4f6ee8ee4 100644 --- a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx @@ -8,6 +8,7 @@ import { PerformerCard } from "src/components/Performers/PerformerCard"; import GenderIcon from "src/components/Performers/GenderIcon"; import { CountryFlag } from "src/components/Shared/CountryFlag"; import TextUtils from "src/utils/text"; +import { stringToGender } from "src/utils/gender"; interface IScrapedPerformerPreviewProps { performer: GQL.ScrapedPerformer; @@ -68,7 +69,10 @@ export const RemotePerformerCard = ({
- + {name} {performer.disambiguation && ( From bfe73cdf09d3cce3b6bd23dbce7e0720405fd0a5 Mon Sep 17 00:00:00 2001 From: KennyG Date: Wed, 22 Apr 2026 14:10:29 -0400 Subject: [PATCH 24/41] Refactor MatchedPerformerPreview and PerformerResult components for lazy/async functionality - Updated MatchedPerformerPreview to utilize scraped performer data and handle loading states more effectively. - Introduced delta row calculations for displaying differences between local and scraped performer data. - Removed redundant code and improved the overall organization of the components for better maintainability. --- .../Tagger/scenes/MatchedPerformerPreview.tsx | 207 +++++++++++++++++- .../Tagger/scenes/PerformerResult.tsx | 197 +---------------- 2 files changed, 202 insertions(+), 202 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx index 4f9c386b4..c6b0f3040 100644 --- a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx @@ -1,5 +1,6 @@ -import { ReactNode } from "react"; +import { ReactNode, useState } from "react"; import { Placement } from "react-bootstrap/esm/Overlay"; +import { FormattedMessage, IntlShape, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { HoverPopover } from "src/components/Shared/HoverPopover"; import { useConfigurationContext } from "src/hooks/Config"; @@ -12,30 +13,206 @@ interface IPerformerDeltaRow { interface IMatchedPerformerPreviewProps { performerID?: string | null; - performer?: GQL.PerformerDataFragment | null; + scrapedPerformer: GQL.ScrapedPerformer; + endpoint?: string; placement?: Placement; - deltaRows?: IPerformerDeltaRow[]; - warningStashID?: Pick; 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", defaultMessage: "Birthdate" }), + remote.birthdate, + local.birthdate + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "death_date", defaultMessage: "Death Date" }), + remote.death_date, + local.death_date + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "ethnicity", defaultMessage: "Ethnicity" }), + remote.ethnicity, + local.ethnicity + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "hair_color", defaultMessage: "Hair Color" }), + remote.hair_color, + local.hair_color + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "eye_color", defaultMessage: "Eye Color" }), + remote.eye_color, + local.eye_color + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "height", defaultMessage: "Height" }), + remote.height, + local.height_cm + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "weight", defaultMessage: "Weight" }), + remote.weight, + local.weight + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "penis_length", defaultMessage: "Penis Length" }), + remote.penis_length, + local.penis_length + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "circumcised", defaultMessage: "Circumcised" }), + remote.circumcised, + local.circumcised + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "measurements", defaultMessage: "Measurements" }), + remote.measurements, + local.measurements + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "fake_tits", defaultMessage: "Fake Tits" }), + remote.fake_tits, + local.fake_tits + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "tattoos", defaultMessage: "Tattoos" }), + remote.tattoos, + local.tattoos + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "piercings", defaultMessage: "Piercings" }), + remote.piercings, + local.piercings + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "career_start", defaultMessage: "Career Start" }), + remote.career_start, + local.career_start + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "career_end", defaultMessage: "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", defaultMessage: "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", defaultMessage: "URLs" }), + value: String(remoteUrlsCount), + }); + } + + return rows; +}; + export const MatchedPerformerPreview = ({ performerID, - performer, + scrapedPerformer, + endpoint, placement = "bottom", - deltaRows = [], - warningStashID, children, }: IMatchedPerformerPreviewProps) => { + const intl = useIntl(); const { configuration: config } = useConfigurationContext(); const showPerformerCardOnHover = config?.ui.showTagCardOnHover ?? true; + const [isOpened, setIsOpened] = useState(false); + const { data: selectedPerformerData, loading: selectedPerformerLoading } = + 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 || !performer || !showPerformerCardOnHover) { + if (!performerID || !showPerformerCardOnHover) { return <>{children}; } @@ -45,9 +222,21 @@ export const MatchedPerformerPreview = ({ placement={placement} enterDelay={500} leaveDelay={100} + onOpen={() => setIsOpened(true)} content={
- + {performer ? ( + + ) : ( +
+ {selectedPerformerLoading ? ( + + ) : null} +
+ )} {(warningStashID || deltaRows.length > 0) && (
{warningStashID && ( diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index 5a79a2c04..859ae2b83 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; -import { FormattedMessage, IntlShape, useIntl } from "react-intl"; +import { FormattedMessage } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { OptionalField } from "../IncludeButton"; @@ -15,170 +15,6 @@ import { LinkButton } from "../LinkButton"; import { MatchedPerformerPreview } from "./MatchedPerformerPreview"; import { ScrapedPerformerPreview } from "./ScrapedPerformerPreview"; -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", defaultMessage: "Birthdate" }), - remote.birthdate, - local.birthdate - ); - pushDeltaIfDifferent( - rows, - intl.formatMessage({ id: "death_date", defaultMessage: "Death Date" }), - remote.death_date, - local.death_date - ); - pushDeltaIfDifferent( - rows, - intl.formatMessage({ id: "ethnicity", defaultMessage: "Ethnicity" }), - remote.ethnicity, - local.ethnicity - ); - pushDeltaIfDifferent( - rows, - intl.formatMessage({ id: "hair_color", defaultMessage: "Hair Color" }), - remote.hair_color, - local.hair_color - ); - pushDeltaIfDifferent( - rows, - intl.formatMessage({ id: "eye_color", defaultMessage: "Eye Color" }), - remote.eye_color, - local.eye_color - ); - pushDeltaIfDifferent( - rows, - intl.formatMessage({ id: "height", defaultMessage: "Height" }), - remote.height, - local.height_cm - ); - pushDeltaIfDifferent( - rows, - intl.formatMessage({ id: "weight", defaultMessage: "Weight" }), - remote.weight, - local.weight - ); - pushDeltaIfDifferent( - rows, - intl.formatMessage({ id: "penis_length", defaultMessage: "Penis Length" }), - remote.penis_length, - local.penis_length - ); - pushDeltaIfDifferent( - rows, - intl.formatMessage({ id: "circumcised", defaultMessage: "Circumcised" }), - remote.circumcised, - local.circumcised - ); - pushDeltaIfDifferent( - rows, - intl.formatMessage({ id: "measurements", defaultMessage: "Measurements" }), - remote.measurements, - local.measurements - ); - pushDeltaIfDifferent( - rows, - intl.formatMessage({ id: "fake_tits", defaultMessage: "Fake Tits" }), - remote.fake_tits, - local.fake_tits - ); - pushDeltaIfDifferent( - rows, - intl.formatMessage({ id: "tattoos", defaultMessage: "Tattoos" }), - remote.tattoos, - local.tattoos - ); - pushDeltaIfDifferent( - rows, - intl.formatMessage({ id: "piercings", defaultMessage: "Piercings" }), - remote.piercings, - local.piercings - ); - pushDeltaIfDifferent( - rows, - intl.formatMessage({ id: "career_start", defaultMessage: "Career Start" }), - remote.career_start, - local.career_start - ); - pushDeltaIfDifferent( - rows, - intl.formatMessage({ id: "career_end", defaultMessage: "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", defaultMessage: "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", defaultMessage: "URLs" }), - value: String(remoteUrlsCount), - }); - } - - return rows; -}; - const PerformerLink: React.FC<{ performer: GQL.ScrapedPerformer | Performer; url: string | undefined; @@ -227,7 +63,6 @@ const PerformerResult: React.FC = ({ endpoint, ageFromDate, }) => { - const intl = useIntl(); const { data: performerData, loading: stashLoading } = GQL.useFindPerformerQuery({ variables: { id: performer.stored_id ?? "" }, @@ -241,12 +76,6 @@ const PerformerResult: React.FC = ({ stashID.stash_id === performer.remote_site_id ); const [selectedPerformer, setSelectedPerformer] = useState(); - const { data: selectedPerformerData, loading: selectedPerformerLoading } = - GQL.useFindPerformerQuery({ - variables: { id: selectedID ?? "" }, - skip: !selectedID, - }); - const selectedPerformerDetails = selectedPerformerData?.findPerformer; const stashboxPerformerPrefix = endpoint ? `${getStashboxBase(endpoint)}performers/` @@ -279,13 +108,7 @@ const PerformerResult: React.FC = ({ selectPerformer(undefined); }; - if (stashLoading || selectedPerformerLoading) { - return ( -
- -
- ); - } + if (stashLoading) return
Loading performer
; if (matchedPerformer && matchedStashID) { return ( @@ -327,17 +150,6 @@ const PerformerResult: React.FC = ({ } const selectedSource = !selectedID ? "skip" : "existing"; - const selectedPerformerConflictStashID = - endpoint && performer.remote_site_id && selectedPerformerDetails - ? selectedPerformerDetails.stash_ids.find( - (stashID) => - stashID.endpoint === endpoint && - stashID.stash_id !== performer.remote_site_id - ) - : undefined; - const selectedPerformerDeltaRows = selectedPerformerDetails - ? buildPerformerDeltaRows(performer, selectedPerformerDetails, intl) - : []; const safeBuildPerformerScraperLink = (id: string | null | undefined) => { return stashboxPerformerPrefix && id @@ -370,9 +182,8 @@ const PerformerResult: React.FC = ({ Date: Thu, 23 Apr 2026 10:39:37 -0400 Subject: [PATCH 25/41] Addressing static code review issues. Refactor PerformerPopover and related components for improved functionality and structure - Updated PerformerPopover to accept preview data, loading states, and additional card extras for enhanced flexibility. - Replaced PerformerCard with PerformerPreviewCard in PerformerPopoverCard for better encapsulation and display of performer details. - Refactored MatchedPerformerPreview and ScrapedPerformerPreview to utilize PerformerPopover, streamlining the rendering of performer information and loading states. - Improved styling by renaming CSS classes for consistency and clarity. --- .../Performers/PerformerPopover.tsx | 88 +++++++++-- .../Performers/PerformerPreviewCard.tsx | 51 ++++++ .../Tagger/scenes/MatchedPerformerPreview.tsx | 147 ++++++++++-------- .../Tagger/scenes/ScrapedPerformerPreview.tsx | 113 +++++--------- ui/v2.5/src/components/Tagger/styles.scss | 14 +- 5 files changed, 245 insertions(+), 168 deletions(-) create mode 100644 ui/v2.5/src/components/Performers/PerformerPreviewCard.tsx diff --git a/ui/v2.5/src/components/Performers/PerformerPopover.tsx b/ui/v2.5/src/components/Performers/PerformerPopover.tsx index 5c2815917..f987c9343 100644 --- a/ui/v2.5/src/components/Performers/PerformerPopover.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPopover.tsx @@ -3,22 +3,25 @@ import { ErrorMessage } from "../Shared/ErrorMessage"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { HoverPopover } from "../Shared/HoverPopover"; import { useFindPerformer } from "../../core/StashService"; -import { PerformerCard } from "./PerformerCard"; import { useConfigurationContext } from "../../hooks/Config"; import { Placement } from "react-bootstrap/esm/Overlay"; +import { IPerformerPreviewData, PerformerPreviewCard } from "./PerformerPreviewCard"; interface IPeromerPopoverCardProps { - id: string; - cardClassName?: string; + id?: string; + previewData?: IPerformerPreviewData; + loading?: boolean; + loadingText?: string; + cardExtras?: React.ReactNode; } -export const PerformerPopoverCard: React.FC = ({ - id, - cardClassName, -}) => { - 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 (
@@ -31,29 +34,72 @@ export const PerformerPopoverCard: React.FC = ({ const performer = data.findPerformer; return ( -
- -
+ <> + + {cardExtras} + ); }; +export const PerformerPopoverCard: React.FC = ({ + id, + previewData, + loading, + loadingText = "", + cardExtras, +}) => { + if (previewData || loading) { + return ( + <> + {previewData ? ( + + ) : ( +
+ {loading ? loadingText : null} +
+ )} + {cardExtras} + + ); + } + + if (!id) return null; + return ; +}; + interface IPeroformerPopoverProps { - id: string; + id?: string; + previewData?: IPerformerPreviewData; + loading?: boolean; + loadingText?: string; + cardExtras?: React.ReactNode; hide?: boolean; placement?: Placement; target?: React.RefObject; - cardClassName?: string; triggerClassName?: string; + onOpen?: () => void; + onClose?: () => void; } export const PerformerPopover: React.FC = ({ id, + previewData, + loading, + loadingText, + cardExtras, hide, children, placement = "top", target, - cardClassName, triggerClassName, + onOpen, + onClose, }) => { const { configuration: config } = useConfigurationContext(); @@ -70,7 +116,17 @@ export const PerformerPopover: React.FC = ({ placement={placement} enterDelay={500} leaveDelay={100} - content={} + onOpen={onOpen} + onClose={onClose} + content={ + + } > {children} diff --git a/ui/v2.5/src/components/Performers/PerformerPreviewCard.tsx b/ui/v2.5/src/components/Performers/PerformerPreviewCard.tsx new file mode 100644 index 000000000..858d8eafe --- /dev/null +++ b/ui/v2.5/src/components/Performers/PerformerPreviewCard.tsx @@ -0,0 +1,51 @@ +import * as GQL from "src/core/generated-graphql"; +import { CountryFlag } from "src/components/Shared/CountryFlag"; +import GenderIcon from "./GenderIcon"; + +export interface IPerformerPreviewData { + name: string; + image?: string | null; + country?: string | null; + gender?: GQL.Maybe; + disambiguation?: string | null; + ageString?: string | null; +} + +export const PerformerPreviewCard = ({ + name, + image, + country, + gender, + disambiguation, + ageString, +}: IPerformerPreviewData) => ( +
+
+
+ {name} + {country && ( + + )} +
+
+
+ + {name} + {disambiguation && ( + {` (${disambiguation})`} + )} +
+ {ageString ?
{ageString}
: null} +
+
+
+); diff --git a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx index c6b0f3040..53291f781 100644 --- a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx @@ -1,10 +1,11 @@ import { ReactNode, useState } from "react"; import { Placement } from "react-bootstrap/esm/Overlay"; -import { FormattedMessage, IntlShape, useIntl } from "react-intl"; +import { IntlShape, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; -import { HoverPopover } from "src/components/Shared/HoverPopover"; +import { PerformerPopover } from "src/components/Performers/PerformerPopover"; import { useConfigurationContext } from "src/hooks/Config"; -import { LocalPerformerCard } from "./ScrapedPerformerPreview"; +import TextUtils from "src/utils/text"; +import { localPerformerToPreviewData } from "./ScrapedPerformerPreview"; interface IPerformerDeltaRow { label: string; @@ -63,91 +64,91 @@ const buildPerformerDeltaRows = ( pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "birthdate", defaultMessage: "Birthdate" }), + intl.formatMessage({ id: "birthdate" }), remote.birthdate, local.birthdate ); pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "death_date", defaultMessage: "Death Date" }), + intl.formatMessage({ id: "death_date" }), remote.death_date, local.death_date ); pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "ethnicity", defaultMessage: "Ethnicity" }), + intl.formatMessage({ id: "ethnicity" }), remote.ethnicity, local.ethnicity ); pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "hair_color", defaultMessage: "Hair Color" }), + intl.formatMessage({ id: "hair_color" }), remote.hair_color, local.hair_color ); pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "eye_color", defaultMessage: "Eye Color" }), + intl.formatMessage({ id: "eye_color" }), remote.eye_color, local.eye_color ); pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "height", defaultMessage: "Height" }), + intl.formatMessage({ id: "height" }), remote.height, local.height_cm ); pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "weight", defaultMessage: "Weight" }), + intl.formatMessage({ id: "weight" }), remote.weight, local.weight ); pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "penis_length", defaultMessage: "Penis Length" }), + intl.formatMessage({ id: "penis_length" }), remote.penis_length, local.penis_length ); pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "circumcised", defaultMessage: "Circumcised" }), + intl.formatMessage({ id: "circumcised" }), remote.circumcised, local.circumcised ); pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "measurements", defaultMessage: "Measurements" }), + intl.formatMessage({ id: "measurements" }), remote.measurements, local.measurements ); pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "fake_tits", defaultMessage: "Fake Tits" }), + intl.formatMessage({ id: "fake_tits" }), remote.fake_tits, local.fake_tits ); pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "tattoos", defaultMessage: "Tattoos" }), + intl.formatMessage({ id: "tattoos" }), remote.tattoos, local.tattoos ); pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "piercings", defaultMessage: "Piercings" }), + intl.formatMessage({ id: "piercings" }), remote.piercings, local.piercings ); pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "career_start", defaultMessage: "Career Start" }), + intl.formatMessage({ id: "career_start" }), remote.career_start, local.career_start ); pushDeltaIfDifferent( rows, - intl.formatMessage({ id: "career_end", defaultMessage: "Career End" }), + intl.formatMessage({ id: "career_end" }), remote.career_end, local.career_end ); @@ -161,7 +162,7 @@ const buildPerformerDeltaRows = ( const localAliasesCount = local.alias_list?.length ?? 0; if (remoteAliasesCount > localAliasesCount) { rows.push({ - label: intl.formatMessage({ id: "aliases", defaultMessage: "Aliases" }), + label: intl.formatMessage({ id: "aliases" }), value: String(remoteAliasesCount), }); } @@ -170,7 +171,7 @@ const buildPerformerDeltaRows = ( const localUrlsCount = local.urls?.length ?? 0; if (remoteUrlsCount > localUrlsCount) { rows.push({ - label: intl.formatMessage({ id: "urls", defaultMessage: "URLs" }), + label: intl.formatMessage({ id: "urls" }), value: String(remoteUrlsCount), }); } @@ -186,6 +187,10 @@ export const MatchedPerformerPreview = ({ children, }: IMatchedPerformerPreviewProps) => { const intl = useIntl(); + const loadingText = intl.formatMessage({ + id: "loading.generic", + defaultMessage: "Loading...", + }); const { configuration: config } = useConfigurationContext(); const showPerformerCardOnHover = config?.ui.showTagCardOnHover ?? true; const [isOpened, setIsOpened] = useState(false); @@ -206,6 +211,22 @@ export const MatchedPerformerPreview = ({ const deltaRows = performer ? buildPerformerDeltaRows(scrapedPerformer, performer, intl) : []; + const matchedAge = performer + ? TextUtils.age(performer.birthdate, performer.death_date) + : 0; + const matchedAgeString = + performer && matchedAge !== 0 + ? intl.formatMessage( + { id: "media_info.performer_card.age" }, + { + age: matchedAge, + years_old: intl.formatMessage({ + id: "years_old", + defaultMessage: "years old", + }), + } + ) + : null; const warningEndpointName = warningStashID ? config?.general.stashBoxes.find( (sb) => sb.endpoint === warningStashID.endpoint @@ -217,55 +238,47 @@ export const MatchedPerformerPreview = ({ } return ( - setIsOpened(true)} - content={ -
- {performer ? ( - - ) : ( -
- {selectedPerformerLoading ? ( - - ) : null} -
- )} - {(warningStashID || deltaRows.length > 0) && ( -
- {warningStashID && ( -
- - - {warningEndpointName} - - -
- )} - {deltaRows.length > 0 && ( -
- {deltaRows.map((row) => ( -
- {row.label}: {row.value} -
- ))} -
- )} -
- )} -
+ 0 ? ( +
+ {warningStashID && ( +
+ + + {warningEndpointName} + + +
+ )} + {deltaRows.length > 0 && ( +
+ {deltaRows.map((row) => ( +
+ {row.label}: {row.value} +
+ ))} +
+ )} +
+ ) : null + } + triggerClassName="d-inline-block" + placement={placement} + onOpen={() => setIsOpened(true)} + onClose={() => setIsOpened(false)} > {children} -
+ ); }; diff --git a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx index 4f6ee8ee4..580444432 100644 --- a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx @@ -2,11 +2,10 @@ 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 { useConfigurationContext } from "src/hooks/Config"; -import { HoverPopover } from "src/components/Shared/HoverPopover"; -import { PerformerCard } from "src/components/Performers/PerformerCard"; -import GenderIcon from "src/components/Performers/GenderIcon"; -import { CountryFlag } from "src/components/Shared/CountryFlag"; +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"; @@ -16,21 +15,36 @@ interface IScrapedPerformerPreviewProps { children?: ReactNode; } -export const LocalPerformerCard = ({ - performer, -}: { - performer: GQL.PerformerDataFragment; -}) => ( -
- -
-); +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 RemotePerformerCard = ({ +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, -}: { - performer: GQL.ScrapedPerformer; -}) => { + placement = "bottom", + children, +}: IScrapedPerformerPreviewProps) => { const intl = useIntl(); const unknownPerformerName = intl.formatMessage({ id: "component_tagger.results.unnamed", @@ -48,66 +62,19 @@ export const RemotePerformerCard = ({ }), } ); - - return ( -
-
-
- {name} - {performer.country && ( - - )} -
-
-
- - {name} - {performer.disambiguation && ( - - {` (${performer.disambiguation})`} - - )} -
- {age !== 0 &&
{ageString}
} -
-
-
+ const previewData = scrapedPerformerToPreviewData( + performer, + name, + age !== 0 ? ageString : null ); -}; - -export const ScrapedPerformerPreview = ({ - performer, - placement = "bottom", - children, -}: IScrapedPerformerPreviewProps) => { - const { configuration: config } = useConfigurationContext(); - const showPerformerCardOnHover = config?.ui.showTagCardOnHover ?? true; - - if (!showPerformerCardOnHover) { - return <>{children}; - } return ( - } + triggerClassName="d-inline-block" > {children} - + ); }; diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index c76c20358..571d992c6 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -95,7 +95,7 @@ } } -.tagger-matched-performer-popover { +.tagger-performer-popover { .card { width: 200px; } @@ -106,21 +106,11 @@ } .tagger-matched-performer-popover-extra { - border-top: 1px solid rgba(255, 255, 255, 0.12); + border-top: 1px solid $secondary; 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; From 4c6938ca3a1afff67a914c3733718c7fb22bf2b0 Mon Sep 17 00:00:00 2001 From: KennyG Date: Thu, 23 Apr 2026 11:18:34 -0400 Subject: [PATCH 26/41] Consolidate styling of performer popover card - Replaced PerformerPreviewCard with PerformerCard in PerformerPopover for better encapsulation and display. - Simplified MatchedPerformerPreview by removing unnecessary loading state handling and age calculations. - Enhanced styling in Tagger component with additional padding for better layout consistency. --- .../Performers/PerformerPopover.tsx | 11 ++---- .../Tagger/scenes/MatchedPerformerPreview.tsx | 39 +++---------------- ui/v2.5/src/components/Tagger/styles.scss | 3 ++ 3 files changed, 12 insertions(+), 41 deletions(-) diff --git a/ui/v2.5/src/components/Performers/PerformerPopover.tsx b/ui/v2.5/src/components/Performers/PerformerPopover.tsx index f987c9343..84657cd17 100644 --- a/ui/v2.5/src/components/Performers/PerformerPopover.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPopover.tsx @@ -5,6 +5,7 @@ import { HoverPopover } from "../Shared/HoverPopover"; import { useFindPerformer } from "../../core/StashService"; import { useConfigurationContext } from "../../hooks/Config"; import { Placement } from "react-bootstrap/esm/Overlay"; +import { PerformerCard } from "./PerformerCard"; import { IPerformerPreviewData, PerformerPreviewCard } from "./PerformerPreviewCard"; interface IPeromerPopoverCardProps { @@ -35,13 +36,9 @@ const PerformerPopoverCardByID: React.FC<{ return ( <> - +
+ +
{cardExtras} ); diff --git a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx index 53291f781..c0dbec4d1 100644 --- a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx @@ -4,8 +4,6 @@ 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 TextUtils from "src/utils/text"; -import { localPerformerToPreviewData } from "./ScrapedPerformerPreview"; interface IPerformerDeltaRow { label: string; @@ -187,18 +185,13 @@ export const MatchedPerformerPreview = ({ children, }: IMatchedPerformerPreviewProps) => { const intl = useIntl(); - const loadingText = intl.formatMessage({ - id: "loading.generic", - defaultMessage: "Loading...", - }); const { configuration: config } = useConfigurationContext(); const showPerformerCardOnHover = config?.ui.showTagCardOnHover ?? true; const [isOpened, setIsOpened] = useState(false); - const { data: selectedPerformerData, loading: selectedPerformerLoading } = - GQL.useFindPerformerQuery({ - variables: { id: performerID ?? "" }, - skip: !performerID || !isOpened, - }); + const { data: selectedPerformerData } = GQL.useFindPerformerQuery({ + variables: { id: performerID ?? "" }, + skip: !performerID || !isOpened, + }); const performer = selectedPerformerData?.findPerformer; const warningStashID = endpoint && scrapedPerformer.remote_site_id && performer @@ -211,22 +204,6 @@ export const MatchedPerformerPreview = ({ const deltaRows = performer ? buildPerformerDeltaRows(scrapedPerformer, performer, intl) : []; - const matchedAge = performer - ? TextUtils.age(performer.birthdate, performer.death_date) - : 0; - const matchedAgeString = - performer && matchedAge !== 0 - ? intl.formatMessage( - { id: "media_info.performer_card.age" }, - { - age: matchedAge, - years_old: intl.formatMessage({ - id: "years_old", - defaultMessage: "years old", - }), - } - ) - : null; const warningEndpointName = warningStashID ? config?.general.stashBoxes.find( (sb) => sb.endpoint === warningStashID.endpoint @@ -239,13 +216,7 @@ export const MatchedPerformerPreview = ({ return ( 0 ? (
diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 571d992c6..c3e5b3cf8 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -109,6 +109,9 @@ border-top: 1px solid $secondary; margin-top: 0.4rem; padding-top: 0.5rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + padding-bottom: 0.4rem; } .tagger-performer-delta-rows { From 47d81afe9e0e748c250d04e9b13af5b2fda1ad33 Mon Sep 17 00:00:00 2001 From: KennyG Date: Thu, 23 Apr 2026 11:24:16 -0400 Subject: [PATCH 27/41] Refactor css components for improved styling and consistency - Updated CSS class names from `tagger-performer-popover` to `performer-preview-popover` for clarity. - Enhanced styling for the new `performer-preview-popover` class to ensure consistent dimensions and image handling. - Removed outdated styles related to the previous tagger popover implementation. --- ui/v2.5/src/components/Performers/PerformerPopover.tsx | 2 +- .../src/components/Performers/PerformerPreviewCard.tsx | 2 +- ui/v2.5/src/components/Performers/styles.scss | 10 ++++++++++ ui/v2.5/src/components/Tagger/styles.scss | 10 ---------- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/ui/v2.5/src/components/Performers/PerformerPopover.tsx b/ui/v2.5/src/components/Performers/PerformerPopover.tsx index 84657cd17..219e98371 100644 --- a/ui/v2.5/src/components/Performers/PerformerPopover.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPopover.tsx @@ -57,7 +57,7 @@ export const PerformerPopoverCard: React.FC = ({ {previewData ? ( ) : ( -
+
{loading ? loadingText : null}
)} diff --git a/ui/v2.5/src/components/Performers/PerformerPreviewCard.tsx b/ui/v2.5/src/components/Performers/PerformerPreviewCard.tsx index 858d8eafe..b345f6d15 100644 --- a/ui/v2.5/src/components/Performers/PerformerPreviewCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPreviewCard.tsx @@ -19,7 +19,7 @@ export const PerformerPreviewCard = ({ disambiguation, ageString, }: IPerformerPreviewData) => ( -
+
Date: Thu, 23 Apr 2026 11:30:59 -0400 Subject: [PATCH 28/41] Fix stylelint property order in tagger popover styles. Reorder padding declarations in the matched performer popover extra block to satisfy alphabetical property order enforced by stylelint. Made-with: Cursor --- ui/v2.5/src/components/Tagger/styles.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 2333d8eb6..d026538d8 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -98,10 +98,10 @@ .tagger-matched-performer-popover-extra { border-top: 1px solid $secondary; margin-top: 0.4rem; - padding-top: 0.5rem; + padding-bottom: 0.4rem; padding-left: 0.5rem; padding-right: 0.5rem; - padding-bottom: 0.4rem; + padding-top: 0.5rem; } .tagger-performer-delta-rows { From 372945a80ce637ccdf104cda7b941447e02ec672 Mon Sep 17 00:00:00 2001 From: KennyG Date: Thu, 23 Apr 2026 11:34:34 -0400 Subject: [PATCH 29/41] Restore full performer card for ID popovers. Use full PerformerCard in PerformerPopover ID mode so favorites and card button overlays render again, while keeping previewData mode for scraped/tagger previews. Made-with: Cursor --- ui/v2.5/src/components/Performers/PerformerPopover.tsx | 5 ++++- .../src/components/Performers/PerformerPreviewCard.tsx | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Performers/PerformerPopover.tsx b/ui/v2.5/src/components/Performers/PerformerPopover.tsx index 219e98371..287900b09 100644 --- a/ui/v2.5/src/components/Performers/PerformerPopover.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPopover.tsx @@ -6,7 +6,10 @@ import { useFindPerformer } from "../../core/StashService"; import { useConfigurationContext } from "../../hooks/Config"; import { Placement } from "react-bootstrap/esm/Overlay"; import { PerformerCard } from "./PerformerCard"; -import { IPerformerPreviewData, PerformerPreviewCard } from "./PerformerPreviewCard"; +import { + IPerformerPreviewData, + PerformerPreviewCard, +} from "./PerformerPreviewCard"; interface IPeromerPopoverCardProps { id?: string; diff --git a/ui/v2.5/src/components/Performers/PerformerPreviewCard.tsx b/ui/v2.5/src/components/Performers/PerformerPreviewCard.tsx index b345f6d15..cecd5534e 100644 --- a/ui/v2.5/src/components/Performers/PerformerPreviewCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPreviewCard.tsx @@ -41,10 +41,14 @@ export const PerformerPreviewCard = ({ {name} {disambiguation && ( - {` (${disambiguation})`} + + {` (${disambiguation})`} + )}
- {ageString ?
{ageString}
: null} + {ageString ? ( +
{ageString}
+ ) : null}
From 9d1813e215a65f3ff4d2892c8a2e0af7f10e92b5 Mon Sep 17 00:00:00 2001 From: KennyG Date: Thu, 23 Apr 2026 11:37:16 -0400 Subject: [PATCH 30/41] Apply Prettier formatting to scraped performer preview. Normalize import formatting in ScrapedPerformerPreview to match repository Prettier output and unblock validate-ui format-check. Made-with: Cursor --- .../src/components/Tagger/scenes/ScrapedPerformerPreview.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx index 580444432..fb570e33c 100644 --- a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx @@ -2,9 +2,7 @@ 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 { 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"; From 455cac489f0e36168162fa660b120939cf400cdc Mon Sep 17 00:00:00 2001 From: KennyG Date: Mon, 4 May 2026 10:45:43 -0400 Subject: [PATCH 31/41] 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. --- .../Performers/PerformerPopover.tsx | 22 ++- .../components/Shared/GridCard/GridCard.tsx | 34 +++- .../Tagger/scenes/MatchedPerformerPreview.tsx | 9 +- .../Tagger/scenes/PerformerResult.tsx | 16 +- .../Tagger/scenes/ScrapedPerformerCard.tsx | 145 ++++++++++++++++++ .../Tagger/scenes/ScrapedPerformerPreview.tsx | 78 ---------- .../Tagger/scenes/TaggerPerformerPopover.tsx | 63 ++++++++ ui/v2.5/src/components/Tags/styles.scss | 17 ++ 8 files changed, 289 insertions(+), 95 deletions(-) create mode 100644 ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerCard.tsx delete mode 100644 ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx create mode 100644 ui/v2.5/src/components/Tagger/scenes/TaggerPerformerPopover.tsx diff --git a/ui/v2.5/src/components/Performers/PerformerPopover.tsx b/ui/v2.5/src/components/Performers/PerformerPopover.tsx index 287900b09..b28f73e9f 100644 --- a/ui/v2.5/src/components/Performers/PerformerPopover.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPopover.tsx @@ -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 = ({ id, + cardContent, previewData, loading, loadingText = "", cardExtras, }) => { + if (cardContent) { + return ( + <> + {cardContent} + {cardExtras} + + ); + } + if (previewData || loading) { return ( <> @@ -75,12 +86,15 @@ export const PerformerPopoverCard: React.FC = ({ 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; triggerClassName?: string; onOpen?: () => void; @@ -89,6 +103,7 @@ interface IPeroformerPopoverProps { export const PerformerPopover: React.FC = ({ id, + cardContent, previewData, loading, loadingText, @@ -96,6 +111,8 @@ export const PerformerPopover: React.FC = ({ hide, children, placement = "top", + enterDelay = 500, + leaveDelay = 100, target, triggerClassName, onOpen, @@ -114,13 +131,14 @@ export const PerformerPopover: React.FC = ({ className={triggerClassName} target={target} placement={placement} - enterDelay={500} - leaveDelay={100} + enterDelay={enterDelay} + leaveDelay={leaveDelay} onOpen={onOpen} onClose={onClose} content={ = ({ dragSide }) => { ); }; +function CardNavLink(props: { + url: string; + linkClassName?: string; + onClick: (event: React.MouseEvent) => void; + children: React.ReactNode; +}) { + const { url, linkClassName, onClick, children } = props; + if (/^https?:\/\//i.test(url)) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} + export const GridCard: React.FC = PatchComponent( "GridCard", (props: ICardProps) => { @@ -256,24 +278,24 @@ export const GridCard: React.FC = PatchComponent(
- {props.image} - + {props.overlays} {maybeRenderProgressBar()}
{maybeRenderInteractiveHeatmap()}
- +
{props.pretitleIcon}
- +
{props.details}
diff --git a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx index c0dbec4d1..afe74ca90 100644 --- a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx @@ -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 ( - 0 ? (
@@ -250,6 +251,6 @@ export const MatchedPerformerPreview = ({ onClose={() => setIsOpened(false)} > {children} - + ); }; diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index 859ae2b83..f71cdae3f 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -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 = ({
: - + - +
@@ -162,12 +165,15 @@ const PerformerResult: React.FC = ({
: - + - +
diff --git a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerCard.tsx b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerCard.tsx new file mode 100644 index 000000000..f9f057421 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerCard.tsx @@ -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 = + PatchComponent("ScrapedPerformerCard.Overlays", ({ scrapedPerformer }) => { + function maybeRenderFlag() { + if (!scrapedPerformer.country) { + return; + } + return ( + + ); + } + + return <>{maybeRenderFlag()}; + }); + +const ScrapedPerformerCardDetails: React.FC = + 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 ? ( +
{ageString}
+ ) : ( + "" + )} + + ); + }); + +const ScrapedPerformerCardImage: React.FC = + PatchComponent("ScrapedPerformerCard.Image", ({ scrapedPerformer }) => { + const intl = useIntl(); + const unknownName = intl.formatMessage({ + id: "component_tagger.results.unnamed", + }); + const alt = scrapedPerformer.name ?? unknownName; + return ( + {alt} + ); + }); + +const ScrapedPerformerCardTitle: React.FC = + PatchComponent("ScrapedPerformerCard.Title", ({ scrapedPerformer }) => { + const intl = useIntl(); + const unknownPerformerName = intl.formatMessage({ + id: "component_tagger.results.unnamed", + }); + const name = scrapedPerformer.name ?? unknownPerformerName; + return ( +
+ {name} + {scrapedPerformer.disambiguation && ( + + {` (${scrapedPerformer.disambiguation})`} + + )} +
+ ); + }); + +export const ScrapedPerformerCard: React.FC = + 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 ( + + } + title={} + image={} + overlays={} + details={} + selected={selected} + selecting={selecting} + onSelectedChanged={onSelectedChanged} + /> + ); + }); diff --git a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx deleted file mode 100644 index fb570e33c..000000000 --- a/ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerPreview.tsx +++ /dev/null @@ -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 ( - - {children} - - ); -}; diff --git a/ui/v2.5/src/components/Tagger/scenes/TaggerPerformerPopover.tsx b/ui/v2.5/src/components/Tagger/scenes/TaggerPerformerPopover.tsx new file mode 100644 index 000000000..7f278db62 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/scenes/TaggerPerformerPopover.tsx @@ -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 +> = ({ + performer, + performerID, + scrapedPerformer, + endpoint, + cardExtras, + placement = "bottom", + triggerClassName = "d-inline-block", + onOpen, + onClose, + children, +}) => { + const cardContent = performer ? ( +
+ +
+ ) : scrapedPerformer ? ( +
+ +
+ ) : undefined; + + return ( + + {children} + + ); +}; diff --git a/ui/v2.5/src/components/Tags/styles.scss b/ui/v2.5/src/components/Tags/styles.scss index 2e6b73d73..9038c52a0 100644 --- a/ui/v2.5/src/components/Tags/styles.scss +++ b/ui/v2.5/src/components/Tags/styles.scss @@ -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; From 255b1d6b9a518ce3711a8b012218aef6a35f6bb1 Mon Sep 17 00:00:00 2001 From: KennyG Date: Mon, 4 May 2026 10:56:25 -0400 Subject: [PATCH 32/41] Cleanup PerformerPopover to remove old loading state - Eliminated the PerformerPreviewCard component, simplifying the PerformerPopoverCard structure. - Updated loading state logic to directly display loading text without relying on preview data. - Removed unused props related to performer preview data for cleaner code and improved maintainability. --- .../Performers/PerformerPopover.tsx | 21 ++----- .../Performers/PerformerPreviewCard.tsx | 55 ------------------- 2 files changed, 4 insertions(+), 72 deletions(-) delete mode 100644 ui/v2.5/src/components/Performers/PerformerPreviewCard.tsx diff --git a/ui/v2.5/src/components/Performers/PerformerPopover.tsx b/ui/v2.5/src/components/Performers/PerformerPopover.tsx index b28f73e9f..70052686b 100644 --- a/ui/v2.5/src/components/Performers/PerformerPopover.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPopover.tsx @@ -6,15 +6,10 @@ import { useFindPerformer } from "../../core/StashService"; import { useConfigurationContext } from "../../hooks/Config"; import { Placement } from "react-bootstrap/esm/Overlay"; import { PerformerCard } from "./PerformerCard"; -import { - IPerformerPreviewData, - PerformerPreviewCard, -} from "./PerformerPreviewCard"; interface IPeromerPopoverCardProps { id?: string; cardContent?: React.ReactNode; - previewData?: IPerformerPreviewData; loading?: boolean; loadingText?: string; cardExtras?: React.ReactNode; @@ -51,7 +46,6 @@ const PerformerPopoverCardByID: React.FC<{ export const PerformerPopoverCard: React.FC = ({ id, cardContent, - previewData, loading, loadingText = "", cardExtras, @@ -65,16 +59,12 @@ export const PerformerPopoverCard: React.FC = ({ ); } - if (previewData || loading) { + if (loading) { return ( <> - {previewData ? ( - - ) : ( -
- {loading ? loadingText : null} -
- )} +
+ {loadingText} +
{cardExtras} ); @@ -87,7 +77,6 @@ export const PerformerPopoverCard: React.FC = ({ interface IPeroformerPopoverProps { id?: string; cardContent?: React.ReactNode; - previewData?: IPerformerPreviewData; loading?: boolean; loadingText?: string; cardExtras?: React.ReactNode; @@ -104,7 +93,6 @@ interface IPeroformerPopoverProps { export const PerformerPopover: React.FC = ({ id, cardContent, - previewData, loading, loadingText, cardExtras, @@ -139,7 +127,6 @@ export const PerformerPopover: React.FC = ({ ; - disambiguation?: string | null; - ageString?: string | null; -} - -export const PerformerPreviewCard = ({ - name, - image, - country, - gender, - disambiguation, - ageString, -}: IPerformerPreviewData) => ( -
-
-
- {name} - {country && ( - - )} -
-
-
- - {name} - {disambiguation && ( - - {` (${disambiguation})`} - - )} -
- {ageString ? ( -
{ageString}
- ) : null} -
-
-
-); From 6f655d2bda50d5182e7b28fd9251c980de81d7f7 Mon Sep 17 00:00:00 2001 From: KennyG Date: Mon, 4 May 2026 11:06:17 -0400 Subject: [PATCH 33/41] Enhance Tagger performer popover styles for improved layout - Added media query for max-height to adjust dimensions of the tagger performer popover card. - Updated class names in TaggerPerformerPopover to ensure consistent styling with the new card structure. - Removed outdated styles from the Tags component to streamline CSS and improve maintainability. --- .../Tagger/scenes/TaggerPerformerPopover.tsx | 4 ++-- ui/v2.5/src/components/Tagger/styles.scss | 17 +++++++++++++++++ ui/v2.5/src/components/Tags/styles.scss | 17 ----------------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/TaggerPerformerPopover.tsx b/ui/v2.5/src/components/Tagger/scenes/TaggerPerformerPopover.tsx index 7f278db62..0ecb17547 100644 --- a/ui/v2.5/src/components/Tagger/scenes/TaggerPerformerPopover.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/TaggerPerformerPopover.tsx @@ -32,11 +32,11 @@ export const TaggerPerformerPopover: React.FC< children, }) => { const cardContent = performer ? ( -
+
) : scrapedPerformer ? ( -
+
Date: Mon, 4 May 2026 11:27:50 -0400 Subject: [PATCH 34/41] 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. --- .../Tagger/scenes/MatchedPerformerPreview.tsx | 256 ------------------ .../Tagger/scenes/PerformerResult.tsx | 6 +- .../Tagger/scenes/TaggerPerformerPopover.tsx | 253 ++++++++++++++++- 3 files changed, 250 insertions(+), 265 deletions(-) delete mode 100644 ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx diff --git a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx b/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx deleted file mode 100644 index afe74ca90..000000000 --- a/ui/v2.5/src/components/Tagger/scenes/MatchedPerformerPreview.tsx +++ /dev/null @@ -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 ( - 0 ? ( -
- {warningStashID && ( -
- - - {warningEndpointName} - - -
- )} - {deltaRows.length > 0 && ( -
- {deltaRows.map((row) => ( -
- {row.label}: {row.value} -
- ))} -
- )} -
- ) : null - } - triggerClassName="d-inline-block" - placement={placement} - onOpen={() => setIsOpened(true)} - onClose={() => setIsOpened(false)} - > - {children} -
- ); -}; diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index f71cdae3f..e16b98a88 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -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 = ({ > - = ({ isClearable={false} ageFromDate={ageFromDate} /> - + {endpoint && onLink && ( )} diff --git a/ui/v2.5/src/components/Tagger/scenes/TaggerPerformerPopover.tsx b/ui/v2.5/src/components/Tagger/scenes/TaggerPerformerPopover.tsx index 0ecb17547..4a69f7a00 100644 --- a/ui/v2.5/src/components/Tagger/scenes/TaggerPerformerPopover.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/TaggerPerformerPopover.tsx @@ -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 ? (
- +
) : scrapedPerformer ? (
@@ -45,17 +223,80 @@ export const TaggerPerformerPopover: React.FC<
) : 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 ? ( +
+ {warningStashID && ( +
+ + + {warningEndpointName} + + +
+ )} + {deltaRows.length > 0 && ( +
+ {deltaRows.map((row) => ( +
+ {row.label}: {row.value} +
+ ))} +
+ )} +
+ ) : null; + return ( + {matchExtras} + {cardExtras} + + ) : null + } placement={placement} enterDelay={1000} leaveDelay={500} triggerClassName={triggerClassName} - onOpen={onOpen} - onClose={onClose} + onOpen={() => { + setIsOpened(true); + onOpen?.(); + }} + onClose={() => { + setIsOpened(false); + onClose?.(); + }} > {children} From aa721c38ea363a6e12ed963f1e0f28d396c33f26 Mon Sep 17 00:00:00 2001 From: KennyG Date: Mon, 4 May 2026 11:37:00 -0400 Subject: [PATCH 35/41] CSS comment to fix SyleLint. - Added a comment to clarify the use of the BEM class for the performer card age styling. - Included a stylelint directive to prevent naming conflicts with reusable components. --- ui/v2.5/src/components/Tagger/styles.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index ba6aee7af..6b94db775 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -132,6 +132,9 @@ 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; } From 929bd58054bdc04f3afcc9ca28145138ccbbca6c Mon Sep 17 00:00:00 2001 From: KennyG Date: Mon, 4 May 2026 11:43:36 -0400 Subject: [PATCH 36/41] Refactor PerformerPopover imports for improved clarity - Adjusted import order for better organization and readability. --- ui/v2.5/src/components/Performers/PerformerPopover.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Performers/PerformerPopover.tsx b/ui/v2.5/src/components/Performers/PerformerPopover.tsx index 70052686b..21ef1ed84 100644 --- a/ui/v2.5/src/components/Performers/PerformerPopover.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPopover.tsx @@ -3,9 +3,10 @@ import { ErrorMessage } from "../Shared/ErrorMessage"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { HoverPopover } from "../Shared/HoverPopover"; import { useFindPerformer } from "../../core/StashService"; +import { PerformerCard } from "./PerformerCard"; import { useConfigurationContext } from "../../hooks/Config"; import { Placement } from "react-bootstrap/esm/Overlay"; -import { PerformerCard } from "./PerformerCard"; + interface IPeromerPopoverCardProps { id?: string; From a26db614efd6e93a0a73dc006de7006f8550beaa Mon Sep 17 00:00:00 2001 From: KennyG Date: Mon, 4 May 2026 12:01:08 -0400 Subject: [PATCH 37/41] Prettier fix. --- ui/v2.5/src/components/Performers/PerformerPopover.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/v2.5/src/components/Performers/PerformerPopover.tsx b/ui/v2.5/src/components/Performers/PerformerPopover.tsx index 21ef1ed84..ba0fa2340 100644 --- a/ui/v2.5/src/components/Performers/PerformerPopover.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPopover.tsx @@ -7,7 +7,6 @@ import { PerformerCard } from "./PerformerCard"; import { useConfigurationContext } from "../../hooks/Config"; import { Placement } from "react-bootstrap/esm/Overlay"; - interface IPeromerPopoverCardProps { id?: string; cardContent?: React.ReactNode; From 2a69ea1096c9cd37440a775fcd9f03630d5864ea Mon Sep 17 00:00:00 2001 From: KennyG Date: Mon, 4 May 2026 12:09:09 -0400 Subject: [PATCH 38/41] Force commit bump --- ui/v2.5/src/components/Performers/PerformerPopover.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/v2.5/src/components/Performers/PerformerPopover.tsx b/ui/v2.5/src/components/Performers/PerformerPopover.tsx index ba0fa2340..21ef1ed84 100644 --- a/ui/v2.5/src/components/Performers/PerformerPopover.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPopover.tsx @@ -7,6 +7,7 @@ import { PerformerCard } from "./PerformerCard"; import { useConfigurationContext } from "../../hooks/Config"; import { Placement } from "react-bootstrap/esm/Overlay"; + interface IPeromerPopoverCardProps { id?: string; cardContent?: React.ReactNode; From a6b489e23840488c7242b132bedc141b32f466bb Mon Sep 17 00:00:00 2001 From: KennyG Date: Mon, 4 May 2026 12:09:20 -0400 Subject: [PATCH 39/41] Pushing prettier fix --- ui/v2.5/src/components/Performers/PerformerPopover.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/v2.5/src/components/Performers/PerformerPopover.tsx b/ui/v2.5/src/components/Performers/PerformerPopover.tsx index 21ef1ed84..ba0fa2340 100644 --- a/ui/v2.5/src/components/Performers/PerformerPopover.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPopover.tsx @@ -7,7 +7,6 @@ import { PerformerCard } from "./PerformerCard"; import { useConfigurationContext } from "../../hooks/Config"; import { Placement } from "react-bootstrap/esm/Overlay"; - interface IPeromerPopoverCardProps { id?: string; cardContent?: React.ReactNode; From d9d50237d826165a1b5e3881d50238d16db6882e Mon Sep 17 00:00:00 2001 From: Stash-KennyG <138793998+Stash-KennyG@users.noreply.github.com> Date: Tue, 5 May 2026 00:02:33 -0400 Subject: [PATCH 40/41] Align release Dockerfiles with Go 1.25 for backend builds. (#6889) The x86_64 and CUDA backend stages still used golang:1.24.3 while go.mod requires Go 1.25, which broke make docker-build under GOTOOLCHAIN=local. Bump both images to golang:1.25.9 to match docker/compiler/Dockerfile and PR #6869. Verified with: make docker-build Fixes https://github.com/stashapp/stash/issues/6887 Co-authored-by: KennyG Co-authored-by: Cursor (cherry picked from commit 3afe29215d02882cc4c706edd3e3fe5d956c4559) --- docker/build/x86_64/Dockerfile | 2 +- docker/build/x86_64/Dockerfile-CUDA | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/build/x86_64/Dockerfile b/docker/build/x86_64/Dockerfile index 163bd64b2..da70d6e49 100644 --- a/docker/build/x86_64/Dockerfile +++ b/docker/build/x86_64/Dockerfile @@ -18,7 +18,7 @@ ARG STASH_VERSION RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only # Build Backend -FROM golang:1.24.3-alpine AS backend +FROM golang:1.25.9-alpine AS backend RUN apk add --no-cache make alpine-sdk WORKDIR /stash COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/ diff --git a/docker/build/x86_64/Dockerfile-CUDA b/docker/build/x86_64/Dockerfile-CUDA index 8a0b02e10..3bbb9ce84 100644 --- a/docker/build/x86_64/Dockerfile-CUDA +++ b/docker/build/x86_64/Dockerfile-CUDA @@ -19,7 +19,7 @@ ARG STASH_VERSION RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only # Build Backend -FROM golang:1.24.3-bullseye AS backend +FROM golang:1.25.9-bullseye AS backend RUN apt update && apt install -y build-essential golang WORKDIR /stash COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/ From 78cd9c36f3e88abb6f0cba982233801e94a57a0d Mon Sep 17 00:00:00 2001 From: KennyG Date: Tue, 5 May 2026 08:49:32 -0400 Subject: [PATCH 41/41] Revert "Align release Dockerfiles with Go 1.25 for backend builds. (#6889)" This reverts commit d9d50237d826165a1b5e3881d50238d16db6882e. --- docker/build/x86_64/Dockerfile | 2 +- docker/build/x86_64/Dockerfile-CUDA | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/build/x86_64/Dockerfile b/docker/build/x86_64/Dockerfile index da70d6e49..163bd64b2 100644 --- a/docker/build/x86_64/Dockerfile +++ b/docker/build/x86_64/Dockerfile @@ -18,7 +18,7 @@ ARG STASH_VERSION RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only # Build Backend -FROM golang:1.25.9-alpine AS backend +FROM golang:1.24.3-alpine AS backend RUN apk add --no-cache make alpine-sdk WORKDIR /stash COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/ diff --git a/docker/build/x86_64/Dockerfile-CUDA b/docker/build/x86_64/Dockerfile-CUDA index 3bbb9ce84..8a0b02e10 100644 --- a/docker/build/x86_64/Dockerfile-CUDA +++ b/docker/build/x86_64/Dockerfile-CUDA @@ -19,7 +19,7 @@ ARG STASH_VERSION RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only # Build Backend -FROM golang:1.25.9-bullseye AS backend +FROM golang:1.24.3-bullseye AS backend RUN apt update && apt install -y build-essential golang WORKDIR /stash COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/