mirror of
https://github.com/stashapp/stash.git
synced 2026-05-09 05:05:29 +02:00
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.
This commit is contained in:
parent
9d1813e215
commit
455cac489f
8 changed files with 289 additions and 95 deletions
|
|
@ -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<IPeromerPopoverCardProps> = ({
|
||||
id,
|
||||
cardContent,
|
||||
previewData,
|
||||
loading,
|
||||
loadingText = "",
|
||||
cardExtras,
|
||||
}) => {
|
||||
if (cardContent) {
|
||||
return (
|
||||
<>
|
||||
{cardContent}
|
||||
{cardExtras}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (previewData || loading) {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -75,12 +86,15 @@ export const PerformerPopoverCard: React.FC<IPeromerPopoverCardProps> = ({
|
|||
|
||||
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<HTMLElement>;
|
||||
triggerClassName?: string;
|
||||
onOpen?: () => void;
|
||||
|
|
@ -89,6 +103,7 @@ interface IPeroformerPopoverProps {
|
|||
|
||||
export const PerformerPopover: React.FC<IPeroformerPopoverProps> = ({
|
||||
id,
|
||||
cardContent,
|
||||
previewData,
|
||||
loading,
|
||||
loadingText,
|
||||
|
|
@ -96,6 +111,8 @@ export const PerformerPopover: React.FC<IPeroformerPopoverProps> = ({
|
|||
hide,
|
||||
children,
|
||||
placement = "top",
|
||||
enterDelay = 500,
|
||||
leaveDelay = 100,
|
||||
target,
|
||||
triggerClassName,
|
||||
onOpen,
|
||||
|
|
@ -114,13 +131,14 @@ export const PerformerPopover: React.FC<IPeroformerPopoverProps> = ({
|
|||
className={triggerClassName}
|
||||
target={target}
|
||||
placement={placement}
|
||||
enterDelay={500}
|
||||
leaveDelay={100}
|
||||
enterDelay={enterDelay}
|
||||
leaveDelay={leaveDelay}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
content={
|
||||
<PerformerPopoverCard
|
||||
id={id}
|
||||
cardContent={cardContent}
|
||||
previewData={previewData}
|
||||
loading={loading}
|
||||
loadingText={loadingText}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { faGripLines } from "@fortawesome/free-solid-svg-icons";
|
|||
import { DragSide, useDragMoveSelect } from "./dragMoveSelect";
|
||||
import { useDebounce } from "src/hooks/debounce";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import { ExternalLink } from "../ExternalLink";
|
||||
|
||||
interface ICardProps {
|
||||
className?: string;
|
||||
|
|
@ -172,6 +173,27 @@ const MoveTarget: React.FC<{ dragSide: DragSide }> = ({ dragSide }) => {
|
|||
);
|
||||
};
|
||||
|
||||
function CardNavLink(props: {
|
||||
url: string;
|
||||
linkClassName?: string;
|
||||
onClick: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { url, linkClassName, onClick, children } = props;
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
return (
|
||||
<ExternalLink href={url} className={linkClassName} onClick={onClick}>
|
||||
{children}
|
||||
</ExternalLink>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link to={url} className={linkClassName} onClick={onClick}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export const GridCard: React.FC<ICardProps> = PatchComponent(
|
||||
"GridCard",
|
||||
(props: ICardProps) => {
|
||||
|
|
@ -256,24 +278,24 @@ export const GridCard: React.FC<ICardProps> = PatchComponent(
|
|||
<div
|
||||
className={cx(props.thumbnailSectionClassName, "thumbnail-section")}
|
||||
>
|
||||
<Link
|
||||
to={props.url}
|
||||
className={props.linkClassName}
|
||||
<CardNavLink
|
||||
url={props.url}
|
||||
linkClassName={props.linkClassName}
|
||||
onClick={handleImageClick}
|
||||
>
|
||||
{props.image}
|
||||
</Link>
|
||||
</CardNavLink>
|
||||
{props.overlays}
|
||||
{maybeRenderProgressBar()}
|
||||
</div>
|
||||
{maybeRenderInteractiveHeatmap()}
|
||||
<div className="card-section">
|
||||
<Link to={props.url} onClick={handleImageClick}>
|
||||
<CardNavLink url={props.url} onClick={handleImageClick}>
|
||||
<h5 className="card-section-title flex-aligned">
|
||||
{props.pretitleIcon}
|
||||
<TruncatedText text={props.title} lineCount={2} />
|
||||
</h5>
|
||||
</Link>
|
||||
</CardNavLink>
|
||||
{props.details}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<PerformerPopover
|
||||
id={performerID ?? undefined}
|
||||
<TaggerPerformerPopover
|
||||
performer={performer ?? undefined}
|
||||
performerID={performerID ?? undefined}
|
||||
cardExtras={
|
||||
warningStashID || deltaRows.length > 0 ? (
|
||||
<div className="tagger-matched-performer-popover-extra">
|
||||
|
|
@ -250,6 +251,6 @@ export const MatchedPerformerPreview = ({
|
|||
onClose={() => setIsOpened(false)}
|
||||
>
|
||||
{children}
|
||||
</PerformerPopover>
|
||||
</TaggerPerformerPopover>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<IPerformerResultProps> = ({
|
|||
<div className="entity-name">
|
||||
<FormattedMessage id="countables.performers" values={{ count: 1 }} />:
|
||||
<b className="ml-2">
|
||||
<ScrapedPerformerPreview performer={performer}>
|
||||
<TaggerPerformerPopover
|
||||
scrapedPerformer={performer}
|
||||
endpoint={endpoint}
|
||||
>
|
||||
<PerformerLink
|
||||
performer={performer}
|
||||
url={`${stashboxPerformerPrefix}${performer.remote_site_id}`}
|
||||
/>
|
||||
</ScrapedPerformerPreview>
|
||||
</TaggerPerformerPopover>
|
||||
</b>
|
||||
</div>
|
||||
<span className="ml-auto">
|
||||
|
|
@ -162,12 +165,15 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
|
|||
<div className="entity-name">
|
||||
<FormattedMessage id="countables.performers" values={{ count: 1 }} />:
|
||||
<b className="ml-2">
|
||||
<ScrapedPerformerPreview performer={performer}>
|
||||
<TaggerPerformerPopover
|
||||
scrapedPerformer={performer}
|
||||
endpoint={endpoint}
|
||||
>
|
||||
<PerformerLink
|
||||
performer={performer}
|
||||
url={safeBuildPerformerScraperLink(performer.remote_site_id)}
|
||||
/>
|
||||
</ScrapedPerformerPreview>
|
||||
</TaggerPerformerPopover>
|
||||
</b>
|
||||
</div>
|
||||
<ButtonGroup>
|
||||
|
|
|
|||
145
ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerCard.tsx
Normal file
145
ui/v2.5/src/components/Tagger/scenes/ScrapedPerformerCard.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import React from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { stringToGender } from "src/utils/gender";
|
||||
import { getStashboxBase } from "src/utils/stashbox";
|
||||
import { GridCard } from "src/components/Shared/GridCard/GridCard";
|
||||
import { CountryFlag } from "src/components/Shared/CountryFlag";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import GenderIcon from "src/components/Performers/GenderIcon";
|
||||
|
||||
export interface IScrapedPerformerCardProps {
|
||||
scrapedPerformer: GQL.ScrapedPerformer;
|
||||
endpoint: string;
|
||||
cardWidth?: number;
|
||||
ageFromDate?: string | null;
|
||||
selecting?: boolean;
|
||||
selected?: boolean;
|
||||
zoomIndex?: number;
|
||||
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
|
||||
}
|
||||
|
||||
const ScrapedPerformerCardOverlays: React.FC<IScrapedPerformerCardProps> =
|
||||
PatchComponent("ScrapedPerformerCard.Overlays", ({ scrapedPerformer }) => {
|
||||
function maybeRenderFlag() {
|
||||
if (!scrapedPerformer.country) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<CountryFlag
|
||||
className="performer-card__country-flag"
|
||||
country={scrapedPerformer.country}
|
||||
includeOverlay
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{maybeRenderFlag()}</>;
|
||||
});
|
||||
|
||||
const ScrapedPerformerCardDetails: React.FC<IScrapedPerformerCardProps> =
|
||||
PatchComponent("ScrapedPerformerCard.Details", (props) => {
|
||||
const { scrapedPerformer, ageFromDate } = props;
|
||||
const intl = useIntl();
|
||||
const age = TextUtils.age(
|
||||
scrapedPerformer.birthdate,
|
||||
ageFromDate ?? scrapedPerformer.death_date
|
||||
);
|
||||
const ageL10nId = ageFromDate
|
||||
? "media_info.performer_card.age_context"
|
||||
: "media_info.performer_card.age";
|
||||
const ageL10String = intl.formatMessage({
|
||||
id: "years_old",
|
||||
});
|
||||
const ageString = intl.formatMessage(
|
||||
{ id: ageL10nId },
|
||||
{ age, years_old: ageL10String }
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{age !== 0 ? (
|
||||
<div className="performer-card__age">{ageString}</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const ScrapedPerformerCardImage: React.FC<IScrapedPerformerCardProps> =
|
||||
PatchComponent("ScrapedPerformerCard.Image", ({ scrapedPerformer }) => {
|
||||
const intl = useIntl();
|
||||
const unknownName = intl.formatMessage({
|
||||
id: "component_tagger.results.unnamed",
|
||||
});
|
||||
const alt = scrapedPerformer.name ?? unknownName;
|
||||
return (
|
||||
<img
|
||||
loading="lazy"
|
||||
className="performer-card-image"
|
||||
alt={alt}
|
||||
src={scrapedPerformer.images?.[0] ?? ""}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const ScrapedPerformerCardTitle: React.FC<IScrapedPerformerCardProps> =
|
||||
PatchComponent("ScrapedPerformerCard.Title", ({ scrapedPerformer }) => {
|
||||
const intl = useIntl();
|
||||
const unknownPerformerName = intl.formatMessage({
|
||||
id: "component_tagger.results.unnamed",
|
||||
});
|
||||
const name = scrapedPerformer.name ?? unknownPerformerName;
|
||||
return (
|
||||
<div>
|
||||
<span className="performer-name">{name}</span>
|
||||
{scrapedPerformer.disambiguation && (
|
||||
<span className="performer-disambiguation">
|
||||
{` (${scrapedPerformer.disambiguation})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const ScrapedPerformerCard: React.FC<IScrapedPerformerCardProps> =
|
||||
PatchComponent("ScrapedPerformerCard", (props) => {
|
||||
const {
|
||||
scrapedPerformer,
|
||||
endpoint,
|
||||
cardWidth,
|
||||
selecting,
|
||||
selected,
|
||||
onSelectedChanged,
|
||||
zoomIndex,
|
||||
} = props;
|
||||
|
||||
const base = getStashboxBase(endpoint);
|
||||
const stashboxUrl =
|
||||
base && scrapedPerformer.remote_site_id
|
||||
? `${base}performers/${scrapedPerformer.remote_site_id}`
|
||||
: "#";
|
||||
|
||||
return (
|
||||
<GridCard
|
||||
className={`performer-card zoom-${zoomIndex}`}
|
||||
url={stashboxUrl}
|
||||
width={cardWidth}
|
||||
pretitleIcon={
|
||||
<GenderIcon
|
||||
className="gender-icon"
|
||||
gender={stringToGender(scrapedPerformer.gender, true)}
|
||||
/>
|
||||
}
|
||||
title={<ScrapedPerformerCardTitle {...props} />}
|
||||
image={<ScrapedPerformerCardImage {...props} />}
|
||||
overlays={<ScrapedPerformerCardOverlays {...props} />}
|
||||
details={<ScrapedPerformerCardDetails {...props} />}
|
||||
selected={selected}
|
||||
selecting={selecting}
|
||||
onSelectedChanged={onSelectedChanged}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<PerformerPopover
|
||||
previewData={previewData}
|
||||
placement={placement}
|
||||
triggerClassName="d-inline-block"
|
||||
>
|
||||
{children}
|
||||
</PerformerPopover>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<ITaggerPerformerPopoverProps>
|
||||
> = ({
|
||||
performer,
|
||||
performerID,
|
||||
scrapedPerformer,
|
||||
endpoint,
|
||||
cardExtras,
|
||||
placement = "bottom",
|
||||
triggerClassName = "d-inline-block",
|
||||
onOpen,
|
||||
onClose,
|
||||
children,
|
||||
}) => {
|
||||
const cardContent = performer ? (
|
||||
<div className="tag-popover-card">
|
||||
<PerformerCard performer={performer} zoomIndex={0} />
|
||||
</div>
|
||||
) : scrapedPerformer ? (
|
||||
<div className="tag-popover-card">
|
||||
<ScrapedPerformerCard
|
||||
scrapedPerformer={scrapedPerformer}
|
||||
endpoint={endpoint ?? ""}
|
||||
zoomIndex={0}
|
||||
/>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<PerformerPopover
|
||||
id={cardContent ? undefined : performerID}
|
||||
cardContent={cardContent}
|
||||
cardExtras={cardExtras}
|
||||
placement={placement}
|
||||
enterDelay={1000}
|
||||
leaveDelay={500}
|
||||
triggerClassName={triggerClassName}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
{children}
|
||||
</PerformerPopover>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue