diff --git a/ui/v2.5/src/components/Performers/PerformerPopover.tsx b/ui/v2.5/src/components/Performers/PerformerPopover.tsx index 04cbf8e87..ba0fa2340 100644 --- a/ui/v2.5/src/components/Performers/PerformerPopover.tsx +++ b/ui/v2.5/src/components/Performers/PerformerPopover.tsx @@ -8,15 +8,20 @@ import { useConfigurationContext } from "../../hooks/Config"; import { Placement } from "react-bootstrap/esm/Overlay"; interface IPeromerPopoverCardProps { - id: string; + id?: string; + cardContent?: React.ReactNode; + loading?: boolean; + loadingText?: string; + cardExtras?: React.ReactNode; } -export const PerformerPopoverCard: React.FC = ({ - id, -}) => { - const { data, loading, error } = useFindPerformer(id); +const PerformerPopoverCardByID: React.FC<{ + id: string; + cardExtras?: React.ReactNode; +}> = ({ id, cardExtras }) => { + const { data, loading: isLoading, error } = useFindPerformer(id); - if (loading) + if (isLoading) return (
@@ -29,25 +34,77 @@ export const PerformerPopoverCard: React.FC = ({ const performer = data.findPerformer; return ( -
- -
+ <> +
+ +
+ {cardExtras} + ); }; +export const PerformerPopoverCard: React.FC = ({ + id, + cardContent, + loading, + loadingText = "", + cardExtras, +}) => { + if (cardContent) { + return ( + <> + {cardContent} + {cardExtras} + + ); + } + + if (loading) { + return ( + <> +
+ {loadingText} +
+ {cardExtras} + + ); + } + + if (!id) return null; + return ; +}; + interface IPeroformerPopoverProps { - id: string; + id?: string; + cardContent?: React.ReactNode; + loading?: boolean; + loadingText?: string; + cardExtras?: React.ReactNode; hide?: boolean; placement?: Placement; + enterDelay?: number; + leaveDelay?: number; target?: React.RefObject; + triggerClassName?: string; + onOpen?: () => void; + onClose?: () => void; } export const PerformerPopover: React.FC = ({ id, + cardContent, + loading, + loadingText, + cardExtras, hide, children, placement = "top", + enterDelay = 500, + leaveDelay = 100, target, + triggerClassName, + onOpen, + onClose, }) => { const { configuration: config } = useConfigurationContext(); @@ -59,11 +116,22 @@ export const PerformerPopover: React.FC = ({ return ( } + enterDelay={enterDelay} + leaveDelay={leaveDelay} + onOpen={onOpen} + onClose={onClose} + content={ + + } > {children} diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 49dc27550..815622119 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -158,6 +158,16 @@ } } +.performer-preview-popover { + .card { + width: 200px; + } + + .performer-card-image { + max-height: 300px; + } +} + .scrape-dialog .performer-image { display: block; margin-bottom: 10px; diff --git a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx index 817527101..0a4ebfc74 100644 --- a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx +++ b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx @@ -16,6 +16,7 @@ import { faGripLines } from "@fortawesome/free-solid-svg-icons"; import { DragSide, useDragMoveSelect } from "./dragMoveSelect"; import { useDebounce } from "src/hooks/debounce"; import { PatchComponent } from "src/patch"; +import { ExternalLink } from "../ExternalLink"; interface ICardProps { className?: string; @@ -172,6 +173,27 @@ const MoveTarget: React.FC<{ dragSide: DragSide }> = ({ dragSide }) => { ); }; +function CardNavLink(props: { + url: string; + linkClassName?: string; + onClick: (event: React.MouseEvent) => 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/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index 53caba2ff..e16b98a88 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 { TaggerPerformerPopover } from "./TaggerPerformerPopover"; const PerformerLink: React.FC<{ performer: GQL.ScrapedPerformer | Performer; @@ -73,7 +74,6 @@ const PerformerResult: React.FC = ({ stashID.endpoint === endpoint && stashID.stash_id === performer.remote_site_id ); - const [selectedPerformer, setSelectedPerformer] = useState(); const stashboxPerformerPrefix = endpoint @@ -115,10 +115,15 @@ const PerformerResult: React.FC = ({
: - + + +
@@ -159,10 +164,15 @@ const PerformerResult: React.FC = ({
: - + + +
@@ -175,13 +185,20 @@ const PerformerResult: React.FC = ({ > - + + + {endpoint && onLink && ( )} 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/TaggerPerformerPopover.tsx b/ui/v2.5/src/components/Tagger/scenes/TaggerPerformerPopover.tsx new file mode 100644 index 000000000..4a69f7a00 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/scenes/TaggerPerformerPopover.tsx @@ -0,0 +1,304 @@ +import React, { useState } from "react"; +import * as GQL from "src/core/generated-graphql"; +import { Placement } from "react-bootstrap/esm/Overlay"; +import { IntlShape, useIntl } from "react-intl"; +import { PerformerPopover } from "src/components/Performers/PerformerPopover"; +import { PerformerCard } from "src/components/Performers/PerformerCard"; +import { useConfigurationContext } from "src/hooks/Config"; +import { ScrapedPerformerCard } from "./ScrapedPerformerCard"; + +interface IPerformerDeltaRow { + label: string; + value: string; +} + +const normalizeValue = (value: unknown) => + (() => { + const text = String(value ?? "").trim(); + if (!text) return ""; + + const isNumericLike = /^[-+]?(?:\d+\.?\d*|\.\d+)$/.test(text); + if (isNumericLike) { + const numeric = Number(text); + if (!Number.isNaN(numeric)) { + return String(numeric); + } + } + + return text.toLowerCase(); + })(); + +const toStringOrNull = (value: unknown) => { + if (value === null || value === undefined) return null; + const text = String(value).trim(); + return text.length > 0 ? text : null; +}; + +const pushDeltaIfDifferent = ( + rows: IPerformerDeltaRow[], + label: string, + remoteValue: unknown, + localValue: unknown +) => { + const remoteText = toStringOrNull(remoteValue); + if (!remoteText) return; + + if (normalizeValue(remoteText) === normalizeValue(localValue)) return; + rows.push({ label, value: remoteText }); +}; + +const buildPerformerDeltaRows = ( + remote: GQL.ScrapedPerformer, + local: GQL.PerformerDataFragment, + intl: IntlShape +): IPerformerDeltaRow[] => { + const rows: IPerformerDeltaRow[] = []; + + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "birthdate" }), + remote.birthdate, + local.birthdate + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "death_date" }), + remote.death_date, + local.death_date + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "ethnicity" }), + remote.ethnicity, + local.ethnicity + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "hair_color" }), + remote.hair_color, + local.hair_color + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "eye_color" }), + remote.eye_color, + local.eye_color + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "height" }), + remote.height, + local.height_cm + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "weight" }), + remote.weight, + local.weight + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "penis_length" }), + remote.penis_length, + local.penis_length + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "circumcised" }), + remote.circumcised, + local.circumcised + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "measurements" }), + remote.measurements, + local.measurements + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "fake_tits" }), + remote.fake_tits, + local.fake_tits + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "tattoos" }), + remote.tattoos, + local.tattoos + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "piercings" }), + remote.piercings, + local.piercings + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "career_start" }), + remote.career_start, + local.career_start + ); + pushDeltaIfDifferent( + rows, + intl.formatMessage({ id: "career_end" }), + remote.career_end, + local.career_end + ); + + const remoteAliasesCount = remote.aliases + ? remote.aliases + .split(",") + .map((a) => a.trim()) + .filter(Boolean).length + : 0; + const localAliasesCount = local.alias_list?.length ?? 0; + if (remoteAliasesCount > localAliasesCount) { + rows.push({ + label: intl.formatMessage({ id: "aliases" }), + value: String(remoteAliasesCount), + }); + } + + const remoteUrlsCount = remote.urls?.length ?? 0; + const localUrlsCount = local.urls?.length ?? 0; + if (remoteUrlsCount > localUrlsCount) { + rows.push({ + label: intl.formatMessage({ id: "urls" }), + value: String(remoteUrlsCount), + }); + } + + return rows; +}; + +interface ITaggerPerformerPopoverProps { + performer?: GQL.PerformerDataFragment; + performerID?: string; + scrapedPerformer?: GQL.ScrapedPerformer; + endpoint?: string; + cardExtras?: React.ReactNode; + includeMatchExtras?: boolean; + placement?: Placement; + triggerClassName?: string; + onOpen?: () => void; + onClose?: () => void; +} + +export const TaggerPerformerPopover: React.FC< + React.PropsWithChildren +> = ({ + performer, + performerID, + scrapedPerformer, + endpoint, + cardExtras, + includeMatchExtras = false, + placement = "bottom", + triggerClassName = "d-inline-block", + onOpen, + onClose, + children, +}) => { + const intl = useIntl(); + const { configuration: config } = useConfigurationContext(); + const [isOpened, setIsOpened] = useState(false); + + const { data: selectedPerformerData } = GQL.useFindPerformerQuery({ + variables: { id: performerID ?? "" }, + skip: !performerID || !!performer || !isOpened, + }); + const localPerformer = performer ?? selectedPerformerData?.findPerformer; + + const cardContent = localPerformer ? ( +
+ +
+ ) : scrapedPerformer ? ( +
+ +
+ ) : 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={() => { + setIsOpened(true); + onOpen?.(); + }} + onClose={() => { + setIsOpened(false); + onClose?.(); + }} + > + {children} + + ); +}; diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 0e9db45a6..6b94db775 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -95,6 +95,52 @@ } } +.tagger-matched-performer-popover-extra { + border-top: 1px solid $secondary; + margin-top: 0.4rem; + padding-bottom: 0.4rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + padding-top: 0.5rem; +} + +.tagger-performer-delta-rows { + font-size: 0.875rem; + line-height: 1.3; + text-align: left; +} + +.tagger-performer-stashid-warning { + text-align: left; + + .tagger-performer-stashid-warning-chip { + background-color: $danger; + border-radius: 0.25rem; + color: $white; + min-width: auto; + } +} + +@media (max-height: 1049px) { + .tagger-performer-popover-card { + .card { + max-width: 160px; + width: 160px; + } + + .card-section-title { + font-size: 1rem; + } + + // Target BEM class from shared PerformerCard markup; cannot + // be renamed here without changing reusable performer card components. + // stylelint-disable-next-line selector-class-pattern + .performer-card__age { + font-size: 0.9rem; + } + } +} + .selected-result { background-color: hsl(204, 20%, 30%); border-radius: 3px;