Plugin api improvements (#5703)

* Add ReactSelect to PluginApi.libraries
* Make Performer tabs patchable
* Make PerformerCard patchable
* Use registration pattern for HoverPopover, TagLink and LoadingIndicator

Initialising the components map to include these was causing an initialisation error.

* Add showZero property to PopoverCountButton
* Make TagCard patchable
* Make ScenePage and ScenePlayer patchable
* Pass properties to container components
* Add example for scene tabs
* Make FrontPage patchable
* Add FrontPage example
This commit is contained in:
WithoutPants
2025-03-05 14:04:12 +11:00
committed by GitHub
parent df5566771a
commit 9b7e20351a
21 changed files with 1884 additions and 1723 deletions

View File

@@ -14,7 +14,11 @@ interface IPluginApi {
Button: React.FC<any>;
Nav: React.FC<any> & {
Link: React.FC<any>;
Item: React.FC<any>;
};
Tab: React.FC<any> & {
Pane: React.FC<any>;
}
},
FontAwesomeSolid: {
faEthernet: any;
@@ -45,7 +49,7 @@ interface IPluginApi {
const React = PluginApi.React;
const GQL = PluginApi.GQL;
const { Button } = PluginApi.libraries.Bootstrap;
const { Button, Nav, Tab } = PluginApi.libraries.Bootstrap;
const { faEthernet } = PluginApi.libraries.FontAwesomeSolid;
const {
Link,
@@ -144,6 +148,10 @@ interface IPluginApi {
return <><Overlays />{original({...props})}</>;
});
PluginApi.patch.instead("FrontPage", function (props: any, _: any, original: (props: any) => any) {
return <><p>Hello from Test React!</p>{original({...props})}</>;
});
const TestPage: React.FC = () => {
const componentsToLoad = [
PluginApi.loadableComponents.SceneCard,
@@ -237,5 +245,37 @@ interface IPluginApi {
)
}
]
})
});
PluginApi.patch.before("ScenePage.Tabs", function (props: any) {
return [
{
children: (
<>
{props.children}
<Nav.Item>
<Nav.Link eventKey="test-react-tab">
Test React tab
</Nav.Link>
</Nav.Item>
</>
),
},
];
});
PluginApi.patch.before("ScenePage.TabContent", function (props: any) {
return [
{
children: (
<>
{props.children}
<Tab.Pane eventKey="test-react-tab">
Test React tab content {props.scene.id}
</Tab.Pane>
</>
),
},
];
});
})();

View File

@@ -13,8 +13,9 @@ import {
getFrontPageContent,
} from "src/core/config";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
import { PatchComponent } from "src/patch";
const FrontPage: React.FC = () => {
const FrontPage: React.FC = PatchComponent("FrontPage", () => {
const intl = useIntl();
const Toast = useToast();
@@ -81,6 +82,6 @@ const FrontPage: React.FC = () => {
</div>
</div>
);
};
});
export default FrontPage;

View File

@@ -23,6 +23,7 @@ import { usePerformerUpdate } from "src/core/StashService";
import { ILabeledId } from "src/models/list-filter/types";
import ScreenUtils from "src/utils/screen";
import { FavoriteIcon } from "../Shared/FavoriteIcon";
import { PatchComponent } from "src/patch";
export interface IPerformerCardExtraCriteria {
scenes?: ModifierCriterion<CriterionValue>[];
@@ -43,176 +44,109 @@ interface IPerformerCardProps {
extraCriteria?: IPerformerCardExtraCriteria;
}
export const PerformerCard: React.FC<IPerformerCardProps> = ({
performer,
containerWidth,
ageFromDate,
selecting,
selected,
zoomIndex,
onSelectedChanged,
extraCriteria,
}) => {
const intl = useIntl();
const age = TextUtils.age(
performer.birthdate,
ageFromDate ?? performer.death_date
);
const ageL10nId = ageFromDate
? "media_info.performer_card.age_context"
: "media_info.performer_card.age";
const ageL10String = intl.formatMessage({
id: "years_old",
defaultMessage: "years old",
});
const ageString = intl.formatMessage(
{ id: ageL10nId },
{ age, years_old: ageL10String }
);
const PerformerCardPopovers: React.FC<IPerformerCardProps> = PatchComponent(
"PerformerCard.Popovers",
({ performer, extraCriteria }) => {
function maybeRenderScenesPopoverButton() {
if (!performer.scene_count) return;
const [updatePerformer] = usePerformerUpdate();
const [cardWidth, setCardWidth] = useState<number>();
useEffect(() => {
if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile())
return;
let zoomValue = zoomIndex;
let preferredCardWidth: number;
switch (zoomValue) {
case 0:
preferredCardWidth = 240;
break;
case 1:
preferredCardWidth = 300;
break;
case 2:
preferredCardWidth = 375;
break;
case 3:
preferredCardWidth = 470;
return (
<PopoverCountButton
className="scene-count"
type="scene"
count={performer.scene_count}
url={NavUtils.makePerformerScenesUrl(
performer,
extraCriteria?.performer,
extraCriteria?.scenes
)}
/>
);
}
let fittedCardWidth = calculateCardWidth(
containerWidth,
preferredCardWidth!
);
setCardWidth(fittedCardWidth);
}, [containerWidth, zoomIndex]);
function onToggleFavorite(v: boolean) {
if (performer.id) {
updatePerformer({
variables: {
input: {
id: performer.id,
favorite: v,
},
},
});
function maybeRenderImagesPopoverButton() {
if (!performer.image_count) return;
return (
<PopoverCountButton
className="image-count"
type="image"
count={performer.image_count}
url={NavUtils.makePerformerImagesUrl(
performer,
extraCriteria?.performer,
extraCriteria?.images
)}
/>
);
}
}
function maybeRenderScenesPopoverButton() {
if (!performer.scene_count) return;
function maybeRenderGalleriesPopoverButton() {
if (!performer.gallery_count) return;
return (
<PopoverCountButton
className="scene-count"
type="scene"
count={performer.scene_count}
url={NavUtils.makePerformerScenesUrl(
performer,
extraCriteria?.performer,
extraCriteria?.scenes
)}
/>
);
}
return (
<PopoverCountButton
className="gallery-count"
type="gallery"
count={performer.gallery_count}
url={NavUtils.makePerformerGalleriesUrl(
performer,
extraCriteria?.performer,
extraCriteria?.galleries
)}
/>
);
}
function maybeRenderImagesPopoverButton() {
if (!performer.image_count) return;
function maybeRenderOCounter() {
if (!performer.o_counter) return;
return (
<PopoverCountButton
className="image-count"
type="image"
count={performer.image_count}
url={NavUtils.makePerformerImagesUrl(
performer,
extraCriteria?.performer,
extraCriteria?.images
)}
/>
);
}
return (
<div className="o-counter">
<Button className="minimal">
<span className="fa-icon">
<SweatDrops />
</span>
<span>{performer.o_counter}</span>
</Button>
</div>
);
}
function maybeRenderGalleriesPopoverButton() {
if (!performer.gallery_count) return;
function maybeRenderTagPopoverButton() {
if (performer.tags.length <= 0) return;
return (
<PopoverCountButton
className="gallery-count"
type="gallery"
count={performer.gallery_count}
url={NavUtils.makePerformerGalleriesUrl(
performer,
extraCriteria?.performer,
extraCriteria?.galleries
)}
/>
);
}
const popoverContent = performer.tags.map((tag) => (
<TagLink key={tag.id} linkType="performer" tag={tag} />
));
function maybeRenderOCounter() {
if (!performer.o_counter) return;
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal tag-count">
<Icon icon={faTag} />
<span>{performer.tags.length}</span>
</Button>
</HoverPopover>
);
}
return (
<div className="o-counter">
<Button className="minimal">
<span className="fa-icon">
<SweatDrops />
</span>
<span>{performer.o_counter}</span>
</Button>
</div>
);
}
function maybeRenderGroupsPopoverButton() {
if (!performer.group_count) return;
function maybeRenderTagPopoverButton() {
if (performer.tags.length <= 0) return;
return (
<PopoverCountButton
className="group-count"
type="group"
count={performer.group_count}
url={NavUtils.makePerformerGroupsUrl(
performer,
extraCriteria?.performer,
extraCriteria?.groups
)}
/>
);
}
const popoverContent = performer.tags.map((tag) => (
<TagLink key={tag.id} linkType="performer" tag={tag} />
));
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal tag-count">
<Icon icon={faTag} />
<span>{performer.tags.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderGroupsPopoverButton() {
if (!performer.group_count) return;
return (
<PopoverCountButton
className="group-count"
type="group"
count={performer.group_count}
url={NavUtils.makePerformerGroupsUrl(
performer,
extraCriteria?.performer,
extraCriteria?.groups
)}
/>
);
}
function maybeRenderPopoverButtonGroup() {
if (
performer.scene_count ||
performer.image_count ||
@@ -235,85 +169,189 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
</>
);
}
}
function maybeRenderRatingBanner() {
if (!performer.rating100) {
return;
}
return <RatingBanner rating={performer.rating100} />;
return null;
}
);
function maybeRenderFlag() {
if (performer.country) {
return (
<Link to={NavUtils.makePerformersCountryUrl(performer)}>
<CountryFlag
className="performer-card__country-flag"
country={performer.country}
includeOverlay
/>
<span className="performer-card__country-string">
{performer.country}
</span>
</Link>
);
}
}
const PerformerCardOverlays: React.FC<IPerformerCardProps> = PatchComponent(
"PerformerCard.Overlays",
({ performer }) => {
const [updatePerformer] = usePerformerUpdate();
return (
<GridCard
className={`performer-card zoom-${zoomIndex}`}
url={`/performers/${performer.id}`}
width={cardWidth}
pretitleIcon={
<GenderIcon className="gender-icon" gender={performer.gender} />
function onToggleFavorite(v: boolean) {
if (performer.id) {
updatePerformer({
variables: {
input: {
id: performer.id,
favorite: v,
},
},
});
}
title={
<div>
<span className="performer-name">{performer.name}</span>
{performer.disambiguation && (
<span className="performer-disambiguation">
{` (${performer.disambiguation})`}
}
function maybeRenderRatingBanner() {
if (!performer.rating100) {
return;
}
return <RatingBanner rating={performer.rating100} />;
}
function maybeRenderFlag() {
if (performer.country) {
return (
<Link to={NavUtils.makePerformersCountryUrl(performer)}>
<CountryFlag
className="performer-card__country-flag"
country={performer.country}
includeOverlay
/>
<span className="performer-card__country-string">
{performer.country}
</span>
)}
</div>
</Link>
);
}
image={
<>
<img
loading="lazy"
className="performer-card-image"
alt={performer.name ?? ""}
src={performer.image_path ?? ""}
/>
</>
}
return (
<>
<FavoriteIcon
favorite={performer.favorite}
onToggleFavorite={onToggleFavorite}
size="2x"
className="hide-not-favorite"
/>
{maybeRenderRatingBanner()}
{maybeRenderFlag()}
</>
);
}
);
const PerformerCardDetails: React.FC<IPerformerCardProps> = PatchComponent(
"PerformerCard.Details",
({ performer, ageFromDate }) => {
const intl = useIntl();
const age = TextUtils.age(
performer.birthdate,
ageFromDate ?? performer.death_date
);
const ageL10nId = ageFromDate
? "media_info.performer_card.age_context"
: "media_info.performer_card.age";
const ageL10String = intl.formatMessage({
id: "years_old",
defaultMessage: "years old",
});
const ageString = intl.formatMessage(
{ id: ageL10nId },
{ age, years_old: ageL10String }
);
return (
<>
{age !== 0 ? (
<div className="performer-card__age">{ageString}</div>
) : (
""
)}
</>
);
}
);
const PerformerCardImage: React.FC<IPerformerCardProps> = PatchComponent(
"PerformerCard.Image",
({ performer }) => {
return (
<>
<img
loading="lazy"
className="performer-card-image"
alt={performer.name ?? ""}
src={performer.image_path ?? ""}
/>
</>
);
}
);
const PerformerCardTitle: React.FC<IPerformerCardProps> = PatchComponent(
"PerformerCard.Title",
({ performer }) => {
return (
<div>
<span className="performer-name">{performer.name}</span>
{performer.disambiguation && (
<span className="performer-disambiguation">
{` (${performer.disambiguation})`}
</span>
)}
</div>
);
}
);
export const PerformerCard: React.FC<IPerformerCardProps> = PatchComponent(
"PerformerCard",
(props) => {
const {
performer,
containerWidth,
selecting,
selected,
onSelectedChanged,
zoomIndex,
} = props;
const [cardWidth, setCardWidth] = useState<number>();
useEffect(() => {
if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile())
return;
let zoomValue = zoomIndex;
let preferredCardWidth: number;
switch (zoomValue) {
case 0:
preferredCardWidth = 240;
break;
case 1:
preferredCardWidth = 300;
break;
case 2:
preferredCardWidth = 375;
break;
case 3:
preferredCardWidth = 470;
}
overlays={
<>
<FavoriteIcon
favorite={performer.favorite}
onToggleFavorite={onToggleFavorite}
size="2x"
className="hide-not-favorite"
/>
{maybeRenderRatingBanner()}
{maybeRenderFlag()}
</>
}
details={
<>
{age !== 0 ? (
<div className="performer-card__age">{ageString}</div>
) : (
""
)}
</>
}
popovers={maybeRenderPopoverButtonGroup()}
selected={selected}
selecting={selecting}
onSelectedChanged={onSelectedChanged}
/>
);
};
let fittedCardWidth = calculateCardWidth(
containerWidth,
preferredCardWidth!
);
setCardWidth(fittedCardWidth);
}, [containerWidth, zoomIndex]);
return (
<GridCard
className={`performer-card zoom-${zoomIndex}`}
url={`/performers/${performer.id}`}
width={cardWidth}
pretitleIcon={
<GenderIcon className="gender-icon" gender={performer.gender} />
}
title={<PerformerCardTitle {...props} />}
image={<PerformerCardImage {...props} />}
overlays={<PerformerCardOverlays {...props} />}
details={<PerformerCardDetails {...props} />}
popovers={<PerformerCardPopovers {...props} />}
selected={selected}
selecting={selecting}
onSelectedChanged={onSelectedChanged}
/>
);
}
);

View File

@@ -45,6 +45,7 @@ import { FavoriteIcon } from "src/components/Shared/FavoriteIcon";
import { AliasList } from "src/components/Shared/DetailsPage/AliasList";
import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
import { LightboxLink } from "src/hooks/Lightbox/LightboxLink";
import { PatchComponent } from "src/patch";
interface IProps {
performer: GQL.PerformerDataFragment;
@@ -200,272 +201,277 @@ const PerformerTabs: React.FC<{
);
};
const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
const Toast = useToast();
const history = useHistory();
const intl = useIntl();
const PerformerPage: React.FC<IProps> = PatchComponent(
"PerformerPage",
({ performer, tabKey }) => {
const Toast = useToast();
const history = useHistory();
const intl = useIntl();
// Configuration settings
const { configuration } = React.useContext(ConfigurationContext);
const uiConfig = configuration?.ui;
const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;
const enableBackgroundImage =
uiConfig?.enablePerformerBackgroundImage ?? false;
const showAllDetails = uiConfig?.showAllDetails ?? true;
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
// Configuration settings
const { configuration } = React.useContext(ConfigurationContext);
const uiConfig = configuration?.ui;
const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;
const enableBackgroundImage =
uiConfig?.enablePerformerBackgroundImage ?? false;
const showAllDetails = uiConfig?.showAllDetails ?? true;
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const loadStickyHeader = useLoadStickyHeader();
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const loadStickyHeader = useLoadStickyHeader();
const activeImage = useMemo(() => {
const performerImage = performer.image_path;
if (isEditing) {
if (image === null && performerImage) {
const performerImageURL = new URL(performerImage);
performerImageURL.searchParams.set("default", "true");
return performerImageURL.toString();
} else if (image) {
return image;
const activeImage = useMemo(() => {
const performerImage = performer.image_path;
if (isEditing) {
if (image === null && performerImage) {
const performerImageURL = new URL(performerImage);
performerImageURL.searchParams.set("default", "true");
return performerImageURL.toString();
} else if (image) {
return image;
}
}
return performerImage;
}, [image, isEditing, performer.image_path]);
const lightboxImages = useMemo(
() => [{ paths: { thumbnail: activeImage, image: activeImage } }],
[activeImage]
);
const [updatePerformer] = usePerformerUpdate();
const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy();
async function onAutoTag() {
try {
await mutateMetadataAutoTag({ performers: [performer.id] });
Toast.success(intl.formatMessage({ id: "toast.started_auto_tagging" }));
} catch (e) {
Toast.error(e);
}
}
return performerImage;
}, [image, isEditing, performer.image_path]);
const lightboxImages = useMemo(
() => [{ paths: { thumbnail: activeImage, image: activeImage } }],
[activeImage]
);
useRatingKeybinds(
true,
configuration?.ui.ratingSystemOptions?.type,
setRating
);
const [updatePerformer] = usePerformerUpdate();
const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy();
// set up hotkeys
useEffect(() => {
Mousetrap.bind("e", () => toggleEditing());
Mousetrap.bind("f", () => setFavorite(!performer.favorite));
Mousetrap.bind(",", () => setCollapsed(!collapsed));
async function onAutoTag() {
try {
await mutateMetadataAutoTag({ performers: [performer.id] });
Toast.success(intl.formatMessage({ id: "toast.started_auto_tagging" }));
} catch (e) {
Toast.error(e);
}
}
useRatingKeybinds(
true,
configuration?.ui.ratingSystemOptions?.type,
setRating
);
// set up hotkeys
useEffect(() => {
Mousetrap.bind("e", () => toggleEditing());
Mousetrap.bind("f", () => setFavorite(!performer.favorite));
Mousetrap.bind(",", () => setCollapsed(!collapsed));
return () => {
Mousetrap.unbind("e");
Mousetrap.unbind("f");
Mousetrap.unbind(",");
};
});
async function onSave(input: GQL.PerformerCreateInput) {
await updatePerformer({
variables: {
input: {
id: performer.id,
...input,
},
},
return () => {
Mousetrap.unbind("e");
Mousetrap.unbind("f");
Mousetrap.unbind(",");
};
});
toggleEditing(false);
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
{ entity: intl.formatMessage({ id: "performer" }).toLocaleLowerCase() }
)
);
}
async function onDelete() {
try {
await deletePerformer({ variables: { id: performer.id } });
} catch (e) {
Toast.error(e);
}
// redirect to performers page
history.push("/performers");
}
function toggleEditing(value?: boolean) {
if (value !== undefined) {
setIsEditing(value);
} else {
setIsEditing((e) => !e);
}
setImage(undefined);
}
function setFavorite(v: boolean) {
if (performer.id) {
updatePerformer({
async function onSave(input: GQL.PerformerCreateInput) {
await updatePerformer({
variables: {
input: {
id: performer.id,
favorite: v,
...input,
},
},
});
toggleEditing(false);
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl.formatMessage({ id: "performer" }).toLocaleLowerCase(),
}
)
);
}
}
function setRating(v: number | null) {
if (performer.id) {
updatePerformer({
variables: {
input: {
id: performer.id,
rating100: v,
async function onDelete() {
try {
await deletePerformer({ variables: { id: performer.id } });
} catch (e) {
Toast.error(e);
}
// redirect to performers page
history.push("/performers");
}
function toggleEditing(value?: boolean) {
if (value !== undefined) {
setIsEditing(value);
} else {
setIsEditing((e) => !e);
}
setImage(undefined);
}
function setFavorite(v: boolean) {
if (performer.id) {
updatePerformer({
variables: {
input: {
id: performer.id,
favorite: v,
},
},
},
});
});
}
}
}
if (isDestroying)
return (
<LoadingIndicator
message={`Deleting performer ${performer.id}: ${performer.name}`}
/>
);
function setRating(v: number | null) {
if (performer.id) {
updatePerformer({
variables: {
input: {
id: performer.id,
rating100: v,
},
},
});
}
}
const headerClassName = cx("detail-header", {
edit: isEditing,
collapsed,
"full-width": !collapsed && !compactExpandedDetails,
});
return (
<div id="performer-page" className="row">
<Helmet>
<title>{performer.name}</title>
</Helmet>
<div className={headerClassName}>
<BackgroundImage
imagePath={activeImage ?? undefined}
show={enableBackgroundImage && !isEditing}
if (isDestroying)
return (
<LoadingIndicator
message={`Deleting performer ${performer.id}: ${performer.name}`}
/>
<div className="detail-container">
<HeaderImage encodingImage={encodingImage}>
{!!activeImage && (
<LightboxLink images={lightboxImages}>
<DetailImage
className="performer"
src={activeImage}
alt={performer.name}
/>
</LightboxLink>
)}
</HeaderImage>
);
<div className="row">
<div className="performer-head col">
<DetailTitle
name={performer.name}
disambiguation={performer.disambiguation ?? undefined}
classNamePrefix="performer"
>
const headerClassName = cx("detail-header", {
edit: isEditing,
collapsed,
"full-width": !collapsed && !compactExpandedDetails,
});
return (
<div id="performer-page" className="row">
<Helmet>
<title>{performer.name}</title>
</Helmet>
<div className={headerClassName}>
<BackgroundImage
imagePath={activeImage ?? undefined}
show={enableBackgroundImage && !isEditing}
/>
<div className="detail-container">
<HeaderImage encodingImage={encodingImage}>
{!!activeImage && (
<LightboxLink images={lightboxImages}>
<DetailImage
className="performer"
src={activeImage}
alt={performer.name}
/>
</LightboxLink>
)}
</HeaderImage>
<div className="row">
<div className="performer-head col">
<DetailTitle
name={performer.name}
disambiguation={performer.disambiguation ?? undefined}
classNamePrefix="performer"
>
{!isEditing && (
<ExpandCollapseButton
collapsed={collapsed}
setCollapsed={(v) => setCollapsed(v)}
/>
)}
<span className="name-icons">
<FavoriteIcon
favorite={performer.favorite}
onToggleFavorite={(v) => setFavorite(v)}
/>
<ExternalLinkButtons urls={performer.urls ?? undefined} />
</span>
</DetailTitle>
<AliasList aliases={performer.alias_list} />
<RatingSystem
value={performer.rating100}
onSetRating={(value) => setRating(value)}
clickToRate
withoutContext
/>
{!isEditing && (
<ExpandCollapseButton
<PerformerDetailsPanel
performer={performer}
collapsed={collapsed}
setCollapsed={(v) => setCollapsed(v)}
fullWidth={!collapsed && !compactExpandedDetails}
/>
)}
<span className="name-icons">
<FavoriteIcon
favorite={performer.favorite}
onToggleFavorite={(v) => setFavorite(v)}
{isEditing ? (
<PerformerEditPanel
performer={performer}
isVisible={isEditing}
onSubmit={onSave}
onCancel={() => toggleEditing()}
setImage={setImage}
setEncodingImage={setEncodingImage}
/>
<ExternalLinkButtons urls={performer.urls ?? undefined} />
</span>
</DetailTitle>
<AliasList aliases={performer.alias_list} />
<RatingSystem
value={performer.rating100}
onSetRating={(value) => setRating(value)}
clickToRate
withoutContext
/>
) : (
<Col>
<Row xs={8}>
<DetailsEditNavbar
objectName={
performer?.name ??
intl.formatMessage({ id: "performer" })
}
onToggleEdit={() => toggleEditing()}
onDelete={onDelete}
onAutoTag={onAutoTag}
autoTagDisabled={performer.ignore_auto_tag}
isNew={false}
isEditing={false}
onSave={() => {}}
onImageChange={() => {}}
classNames="mb-2"
customButtons={
<div>
<PerformerSubmitButton performer={performer} />
</div>
}
></DetailsEditNavbar>
</Row>
</Col>
)}
</div>
</div>
</div>
</div>
{!isEditing && loadStickyHeader && (
<CompressedPerformerDetailsPanel performer={performer} />
)}
<div className="detail-body">
<div className="performer-body">
<div className="performer-tabs">
{!isEditing && (
<PerformerDetailsPanel
<PerformerTabs
tabKey={tabKey}
performer={performer}
collapsed={collapsed}
fullWidth={!collapsed && !compactExpandedDetails}
abbreviateCounter={abbreviateCounter}
/>
)}
{isEditing ? (
<PerformerEditPanel
performer={performer}
isVisible={isEditing}
onSubmit={onSave}
onCancel={() => toggleEditing()}
setImage={setImage}
setEncodingImage={setEncodingImage}
/>
) : (
<Col>
<Row xs={8}>
<DetailsEditNavbar
objectName={
performer?.name ??
intl.formatMessage({ id: "performer" })
}
onToggleEdit={() => toggleEditing()}
onDelete={onDelete}
onAutoTag={onAutoTag}
autoTagDisabled={performer.ignore_auto_tag}
isNew={false}
isEditing={false}
onSave={() => {}}
onImageChange={() => {}}
classNames="mb-2"
customButtons={
<div>
<PerformerSubmitButton performer={performer} />
</div>
}
></DetailsEditNavbar>
</Row>
</Col>
)}
</div>
</div>
</div>
</div>
{!isEditing && loadStickyHeader && (
<CompressedPerformerDetailsPanel performer={performer} />
)}
<div className="detail-body">
<div className="performer-body">
<div className="performer-tabs">
{!isEditing && (
<PerformerTabs
tabKey={tabKey}
performer={performer}
abbreviateCounter={abbreviateCounter}
/>
)}
</div>
</div>
</div>
</div>
);
};
);
}
);
const PerformerLoader: React.FC<RouteComponentProps<IPerformerParams>> = ({
location,

View File

@@ -3,22 +3,21 @@ import * as GQL from "src/core/generated-graphql";
import { GalleryList } from "src/components/Galleries/GalleryList";
import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
interface IPerformerDetailsProps {
active: boolean;
performer: GQL.PerformerDataFragment;
}
export const PerformerGalleriesPanel: React.FC<IPerformerDetailsProps> = ({
active,
performer,
}) => {
const filterHook = usePerformerFilterHook(performer);
return (
<GalleryList
filterHook={filterHook}
alterQuery={active}
view={View.PerformerGalleries}
/>
);
};
export const PerformerGalleriesPanel: React.FC<IPerformerDetailsProps> =
PatchComponent("PerformerGalleriesPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
<GalleryList
filterHook={filterHook}
alterQuery={active}
view={View.PerformerGalleries}
/>
);
});

View File

@@ -3,22 +3,21 @@ import * as GQL from "src/core/generated-graphql";
import { GroupList } from "src/components/Groups/GroupList";
import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
interface IPerformerDetailsProps {
active: boolean;
performer: GQL.PerformerDataFragment;
}
export const PerformerGroupsPanel: React.FC<IPerformerDetailsProps> = ({
active,
performer,
}) => {
const filterHook = usePerformerFilterHook(performer);
return (
<GroupList
filterHook={filterHook}
alterQuery={active}
view={View.PerformerGroups}
/>
);
};
export const PerformerGroupsPanel: React.FC<IPerformerDetailsProps> =
PatchComponent("PerformerGroupsPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
<GroupList
filterHook={filterHook}
alterQuery={active}
view={View.PerformerGroups}
/>
);
});

View File

@@ -3,22 +3,21 @@ import * as GQL from "src/core/generated-graphql";
import { ImageList } from "src/components/Images/ImageList";
import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
interface IPerformerImagesPanel {
active: boolean;
performer: GQL.PerformerDataFragment;
}
export const PerformerImagesPanel: React.FC<IPerformerImagesPanel> = ({
active,
performer,
}) => {
const filterHook = usePerformerFilterHook(performer);
return (
<ImageList
filterHook={filterHook}
alterQuery={active}
view={View.PerformerImages}
/>
);
};
export const PerformerImagesPanel: React.FC<IPerformerImagesPanel> =
PatchComponent("PerformerImagesPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
<ImageList
filterHook={filterHook}
alterQuery={active}
view={View.PerformerImages}
/>
);
});

View File

@@ -3,22 +3,21 @@ import * as GQL from "src/core/generated-graphql";
import { SceneList } from "src/components/Scenes/SceneList";
import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
interface IPerformerDetailsProps {
active: boolean;
performer: GQL.PerformerDataFragment;
}
export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> = ({
active,
performer,
}) => {
const filterHook = usePerformerFilterHook(performer);
return (
<SceneList
filterHook={filterHook}
alterQuery={active}
view={View.PerformerScenes}
/>
);
};
export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> =
PatchComponent("PerformerScenesPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
<SceneList
filterHook={filterHook}
alterQuery={active}
view={View.PerformerScenes}
/>
);
});

View File

@@ -3,33 +3,32 @@ import * as GQL from "src/core/generated-graphql";
import { PerformerList } from "src/components/Performers/PerformerList";
import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
interface IPerformerDetailsProps {
active: boolean;
performer: GQL.PerformerDataFragment;
}
export const PerformerAppearsWithPanel: React.FC<IPerformerDetailsProps> = ({
active,
performer,
}) => {
const performerValue = {
id: performer.id,
label: performer.name ?? `Performer ${performer.id}`,
};
export const PerformerAppearsWithPanel: React.FC<IPerformerDetailsProps> =
PatchComponent("PerformerAppearsWithPanel", ({ active, performer }) => {
const performerValue = {
id: performer.id,
label: performer.name ?? `Performer ${performer.id}`,
};
const extraCriteria = {
performer: performerValue,
};
const extraCriteria = {
performer: performerValue,
};
const filterHook = usePerformerFilterHook(performer);
const filterHook = usePerformerFilterHook(performer);
return (
<PerformerList
filterHook={filterHook}
extraCriteria={extraCriteria}
alterQuery={active}
view={View.PerformerAppearsWith}
/>
);
};
return (
<PerformerList
filterHook={filterHook}
extraCriteria={extraCriteria}
alterQuery={active}
view={View.PerformerAppearsWith}
/>
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,7 @@ import { useRatingKeybinds } from "src/hooks/keybinds";
import { lazyComponent } from "src/utils/lazyComponent";
import cx from "classnames";
import { TruncatedText } from "src/components/Shared/TruncatedText";
import { PatchComponent, PatchContainerComponent } from "src/patch";
const SubmitStashBoxDraft = lazyComponent(
() => import("src/components/Dialogs/SubmitDraft")
@@ -153,24 +154,31 @@ interface ISceneParams {
id: string;
}
const ScenePage: React.FC<IProps> = ({
scene,
setTimestamp,
queueScenes,
onQueueNext,
onQueuePrevious,
onQueueRandom,
onQueueSceneClicked,
onDelete,
continuePlaylist,
queueHasMoreScenes,
onQueueMoreScenes,
onQueueLessScenes,
queueStart,
collapsed,
setCollapsed,
setContinuePlaylist,
}) => {
const ScenePageTabs = PatchContainerComponent<IProps>("ScenePage.Tabs");
const ScenePageTabContent = PatchContainerComponent<IProps>(
"ScenePage.TabContent"
);
const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
const {
scene,
setTimestamp,
queueScenes,
onQueueNext,
onQueuePrevious,
onQueueRandom,
onQueueSceneClicked,
onDelete,
continuePlaylist,
queueHasMoreScenes,
onQueueMoreScenes,
onQueueLessScenes,
queueStart,
collapsed,
setCollapsed,
setContinuePlaylist,
} = props;
const Toast = useToast();
const intl = useIntl();
const [updateScene] = useSceneUpdate();
@@ -423,126 +431,133 @@ const ScenePage: React.FC<IProps> = ({
>
<div>
<Nav variant="tabs" className="mr-auto">
<Nav.Item>
<Nav.Link eventKey="scene-details-panel">
<FormattedMessage id="details" />
</Nav.Link>
</Nav.Item>
{queueScenes.length > 0 ? (
<ScenePageTabs {...props}>
<Nav.Item>
<Nav.Link eventKey="scene-queue-panel">
<FormattedMessage id="queue" />
<Nav.Link eventKey="scene-details-panel">
<FormattedMessage id="details" />
</Nav.Link>
</Nav.Item>
) : (
""
)}
<Nav.Item>
<Nav.Link eventKey="scene-markers-panel">
<FormattedMessage id="markers" />
</Nav.Link>
</Nav.Item>
{scene.groups.length > 0 ? (
{queueScenes.length > 0 ? (
<Nav.Item>
<Nav.Link eventKey="scene-queue-panel">
<FormattedMessage id="queue" />
</Nav.Link>
</Nav.Item>
) : (
""
)}
<Nav.Item>
<Nav.Link eventKey="scene-group-panel">
<FormattedMessage
id="countables.groups"
values={{ count: scene.groups.length }}
/>
<Nav.Link eventKey="scene-markers-panel">
<FormattedMessage id="markers" />
</Nav.Link>
</Nav.Item>
) : (
""
)}
{scene.galleries.length >= 1 ? (
{scene.groups.length > 0 ? (
<Nav.Item>
<Nav.Link eventKey="scene-group-panel">
<FormattedMessage
id="countables.groups"
values={{ count: scene.groups.length }}
/>
</Nav.Link>
</Nav.Item>
) : (
""
)}
{scene.galleries.length >= 1 ? (
<Nav.Item>
<Nav.Link eventKey="scene-galleries-panel">
<FormattedMessage
id="countables.galleries"
values={{ count: scene.galleries.length }}
/>
</Nav.Link>
</Nav.Item>
) : undefined}
<Nav.Item>
<Nav.Link eventKey="scene-galleries-panel">
<FormattedMessage
id="countables.galleries"
values={{ count: scene.galleries.length }}
/>
<Nav.Link eventKey="scene-video-filter-panel">
<FormattedMessage id="effect_filters.name" />
</Nav.Link>
</Nav.Item>
) : undefined}
<Nav.Item>
<Nav.Link eventKey="scene-video-filter-panel">
<FormattedMessage id="effect_filters.name" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="scene-file-info-panel">
<FormattedMessage id="file_info" />
<Counter count={scene.files.length} hideZero hideOne />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="scene-history-panel">
<FormattedMessage id="history" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="scene-edit-panel">
<FormattedMessage id="actions.edit" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="scene-file-info-panel">
<FormattedMessage id="file_info" />
<Counter count={scene.files.length} hideZero hideOne />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="scene-history-panel">
<FormattedMessage id="history" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="scene-edit-panel">
<FormattedMessage id="actions.edit" />
</Nav.Link>
</Nav.Item>
</ScenePageTabs>
</Nav>
</div>
<Tab.Content>
<Tab.Pane eventKey="scene-details-panel">
<SceneDetailPanel scene={scene} />
</Tab.Pane>
<Tab.Pane eventKey="scene-queue-panel">
<QueueViewer
scenes={queueScenes}
currentID={scene.id}
continue={continuePlaylist}
setContinue={setContinuePlaylist}
onSceneClicked={onQueueSceneClicked}
onNext={onQueueNext}
onPrevious={onQueuePrevious}
onRandom={onQueueRandom}
start={queueStart}
hasMoreScenes={queueHasMoreScenes}
onLessScenes={onQueueLessScenes}
onMoreScenes={onQueueMoreScenes}
/>
</Tab.Pane>
<Tab.Pane eventKey="scene-markers-panel">
<SceneMarkersPanel
sceneId={scene.id}
onClickMarker={onClickMarker}
isVisible={activeTabKey === "scene-markers-panel"}
/>
</Tab.Pane>
<Tab.Pane eventKey="scene-group-panel">
<SceneGroupPanel scene={scene} />
</Tab.Pane>
{scene.galleries.length >= 1 && (
<Tab.Pane eventKey="scene-galleries-panel">
<SceneGalleriesPanel galleries={scene.galleries} />
{scene.galleries.length === 1 && (
<GalleryViewer galleryId={scene.galleries[0].id} />
)}
<ScenePageTabContent {...props}>
<Tab.Pane eventKey="scene-details-panel">
<SceneDetailPanel scene={scene} />
</Tab.Pane>
)}
<Tab.Pane eventKey="scene-video-filter-panel">
<SceneVideoFilterPanel scene={scene} />
</Tab.Pane>
<Tab.Pane className="file-info-panel" eventKey="scene-file-info-panel">
<SceneFileInfoPanel scene={scene} />
</Tab.Pane>
<Tab.Pane eventKey="scene-edit-panel" mountOnEnter>
<SceneEditPanel
isVisible={activeTabKey === "scene-edit-panel"}
scene={scene}
onSubmit={onSave}
onDelete={() => setIsDeleteAlertOpen(true)}
/>
</Tab.Pane>
<Tab.Pane eventKey="scene-history-panel">
<SceneHistoryPanel scene={scene} />
</Tab.Pane>
<Tab.Pane eventKey="scene-queue-panel">
<QueueViewer
scenes={queueScenes}
currentID={scene.id}
continue={continuePlaylist}
setContinue={setContinuePlaylist}
onSceneClicked={onQueueSceneClicked}
onNext={onQueueNext}
onPrevious={onQueuePrevious}
onRandom={onQueueRandom}
start={queueStart}
hasMoreScenes={queueHasMoreScenes}
onLessScenes={onQueueLessScenes}
onMoreScenes={onQueueMoreScenes}
/>
</Tab.Pane>
<Tab.Pane eventKey="scene-markers-panel">
<SceneMarkersPanel
sceneId={scene.id}
onClickMarker={onClickMarker}
isVisible={activeTabKey === "scene-markers-panel"}
/>
</Tab.Pane>
<Tab.Pane eventKey="scene-group-panel">
<SceneGroupPanel scene={scene} />
</Tab.Pane>
{scene.galleries.length >= 1 && (
<Tab.Pane eventKey="scene-galleries-panel">
<SceneGalleriesPanel galleries={scene.galleries} />
{scene.galleries.length === 1 && (
<GalleryViewer galleryId={scene.galleries[0].id} />
)}
</Tab.Pane>
)}
<Tab.Pane eventKey="scene-video-filter-panel">
<SceneVideoFilterPanel scene={scene} />
</Tab.Pane>
<Tab.Pane
className="file-info-panel"
eventKey="scene-file-info-panel"
>
<SceneFileInfoPanel scene={scene} />
</Tab.Pane>
<Tab.Pane eventKey="scene-edit-panel" mountOnEnter>
<SceneEditPanel
isVisible={activeTabKey === "scene-edit-panel"}
scene={scene}
onSubmit={onSave}
onDelete={() => setIsDeleteAlertOpen(true)}
/>
</Tab.Pane>
<Tab.Pane eventKey="scene-history-panel">
<SceneHistoryPanel scene={scene} />
</Tab.Pane>
</ScenePageTabContent>
</Tab.Content>
</Tab.Container>
);
@@ -657,7 +672,7 @@ const ScenePage: React.FC<IProps> = ({
/>
</>
);
};
});
const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
location,

View File

@@ -2,19 +2,23 @@ import { FormattedMessage } from "react-intl";
import { Counter } from "../Counter";
import { useCallback, useEffect } from "react";
import { useHistory } from "react-router-dom";
import { PatchComponent } from "src/patch";
export const TabTitleCounter: React.FC<{
messageID: string;
count: number;
abbreviateCounter: boolean;
}> = ({ messageID, count, abbreviateCounter }) => {
return (
<>
<FormattedMessage id={messageID} />
<Counter count={count} abbreviateCounter={abbreviateCounter} hideZero />
</>
);
};
}> = PatchComponent(
"TabTitleCounter",
({ messageID, count, abbreviateCounter }) => {
return (
<>
<FormattedMessage id={messageID} />
<Counter count={count} abbreviateCounter={abbreviateCounter} hideZero />
</>
);
}
);
export function useTabKey(props: {
tabKey: string | undefined;

View File

@@ -1,5 +1,6 @@
import React, { useState, useCallback, useEffect, useRef } from "react";
import { Overlay, Popover, OverlayProps } from "react-bootstrap";
import { PatchComponent } from "src/patch";
interface IHoverPopover {
enterDelay?: number;
@@ -12,72 +13,75 @@ interface IHoverPopover {
target?: React.RefObject<HTMLElement>;
}
export const HoverPopover: React.FC<IHoverPopover> = ({
enterDelay = 200,
leaveDelay = 200,
content,
children,
className,
placement = "top",
onOpen,
onClose,
target,
}) => {
const [show, setShow] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const enterTimer = useRef<number>();
const leaveTimer = useRef<number>();
export const HoverPopover: React.FC<IHoverPopover> = PatchComponent(
"HoverPopover",
({
enterDelay = 200,
leaveDelay = 200,
content,
children,
className,
placement = "top",
onOpen,
onClose,
target,
}) => {
const [show, setShow] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const enterTimer = useRef<number>();
const leaveTimer = useRef<number>();
const handleMouseEnter = useCallback(() => {
window.clearTimeout(leaveTimer.current);
enterTimer.current = window.setTimeout(() => {
setShow(true);
onOpen?.();
}, enterDelay);
}, [enterDelay, onOpen]);
const handleMouseLeave = useCallback(() => {
window.clearTimeout(enterTimer.current);
leaveTimer.current = window.setTimeout(() => {
setShow(false);
onClose?.();
}, leaveDelay);
}, [leaveDelay, onClose]);
useEffect(
() => () => {
window.clearTimeout(enterTimer.current);
const handleMouseEnter = useCallback(() => {
window.clearTimeout(leaveTimer.current);
},
[]
);
enterTimer.current = window.setTimeout(() => {
setShow(true);
onOpen?.();
}, enterDelay);
}, [enterDelay, onOpen]);
return (
<>
<div
className={className}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={triggerRef}
>
{children}
</div>
{triggerRef.current && (
<Overlay
show={show}
placement={placement}
target={target?.current ?? triggerRef.current}
const handleMouseLeave = useCallback(() => {
window.clearTimeout(enterTimer.current);
leaveTimer.current = window.setTimeout(() => {
setShow(false);
onClose?.();
}, leaveDelay);
}, [leaveDelay, onClose]);
useEffect(
() => () => {
window.clearTimeout(enterTimer.current);
window.clearTimeout(leaveTimer.current);
},
[]
);
return (
<>
<div
className={className}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={triggerRef}
>
<Popover
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
id="popover"
className="hover-popover-content"
{children}
</div>
{triggerRef.current && (
<Overlay
show={show}
placement={placement}
target={target?.current ?? triggerRef.current}
>
{content}
</Popover>
</Overlay>
)}
</>
);
};
<Popover
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
id="popover"
className="hover-popover-content"
>
{content}
</Popover>
</Overlay>
)}
</>
);
}
);

View File

@@ -2,6 +2,7 @@ import React from "react";
import { Spinner } from "react-bootstrap";
import cx from "classnames";
import { useIntl } from "react-intl";
import { PatchComponent } from "src/patch";
interface ILoadingProps {
message?: JSX.Element | string;
@@ -13,24 +14,26 @@ interface ILoadingProps {
const CLASSNAME = "LoadingIndicator";
const CLASSNAME_MESSAGE = `${CLASSNAME}-message`;
export const LoadingIndicator: React.FC<ILoadingProps> = ({
message,
inline = false,
small = false,
card = false,
}) => {
const intl = useIntl();
export const LoadingIndicator: React.FC<ILoadingProps> = PatchComponent(
"LoadingIndicator",
({ message, inline = false, small = false, card = false }) => {
const intl = useIntl();
const text = intl.formatMessage({ id: "loading.generic" });
const text = intl.formatMessage({ id: "loading.generic" });
return (
<div className={cx(CLASSNAME, { inline, small, "card-based": card })}>
<Spinner animation="border" role="status" size={small ? "sm" : undefined}>
<span className="sr-only">{text}</span>
</Spinner>
{message !== "" && (
<h4 className={CLASSNAME_MESSAGE}>{message ?? text}</h4>
)}
</div>
);
};
return (
<div className={cx(CLASSNAME, { inline, small, "card-based": card })}>
<Spinner
animation="border"
role="status"
size={small ? "sm" : undefined}
>
<span className="sr-only">{text}</span>
</Spinner>
{message !== "" && (
<h4 className={CLASSNAME_MESSAGE}>{message ?? text}</h4>
)}
</div>
);
}
);

View File

@@ -53,6 +53,7 @@ interface IProps {
url: string;
type: PopoverLinkType;
count: number;
showZero?: boolean;
}
export const PopoverCountButton: React.FC<IProps> = ({
@@ -60,9 +61,14 @@ export const PopoverCountButton: React.FC<IProps> = ({
url,
type,
count,
showZero = true,
}) => {
const intl = useIntl();
if (!showZero && count === 0) {
return null;
}
// TODO - refactor - create SceneIcon, ImageIcon etc components
function getIcon() {
switch (type) {

View File

@@ -13,6 +13,7 @@ import { Placement } from "react-bootstrap/esm/Overlay";
import { faFolderTree } from "@fortawesome/free-solid-svg-icons";
import { Icon } from "../Shared/Icon";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
type SceneMarkerFragment = Pick<GQL.SceneMarker, "id" | "title" | "seconds"> & {
scene: Pick<GQL.Scene, "id">;
@@ -243,66 +244,69 @@ interface ITagLinkProps {
hierarchyTooltipID?: string;
}
export const TagLink: React.FC<ITagLinkProps> = ({
tag,
linkType = "scene",
className,
hoverPlacement,
showHierarchyIcon = false,
hierarchyTooltipID,
}) => {
const link = useMemo(() => {
switch (linkType) {
case "scene":
return NavUtils.makeTagScenesUrl(tag);
case "performer":
return NavUtils.makeTagPerformersUrl(tag);
case "studio":
return NavUtils.makeTagStudiosUrl(tag);
case "gallery":
return NavUtils.makeTagGalleriesUrl(tag);
case "image":
return NavUtils.makeTagImagesUrl(tag);
case "group":
return NavUtils.makeTagGroupsUrl(tag);
case "scene_marker":
return NavUtils.makeTagSceneMarkersUrl(tag);
case "details":
return NavUtils.makeTagUrl(tag.id ?? "");
}
}, [tag, linkType]);
export const TagLink: React.FC<ITagLinkProps> = PatchComponent(
"TagLink",
({
tag,
linkType = "scene",
className,
hoverPlacement,
showHierarchyIcon = false,
hierarchyTooltipID,
}) => {
const link = useMemo(() => {
switch (linkType) {
case "scene":
return NavUtils.makeTagScenesUrl(tag);
case "performer":
return NavUtils.makeTagPerformersUrl(tag);
case "studio":
return NavUtils.makeTagStudiosUrl(tag);
case "gallery":
return NavUtils.makeTagGalleriesUrl(tag);
case "image":
return NavUtils.makeTagImagesUrl(tag);
case "group":
return NavUtils.makeTagGroupsUrl(tag);
case "scene_marker":
return NavUtils.makeTagSceneMarkersUrl(tag);
case "details":
return NavUtils.makeTagUrl(tag.id ?? "");
}
}, [tag, linkType]);
const title = tag.name || "";
const title = tag.name || "";
const tooltip = useMemo(() => {
if (!hierarchyTooltipID) {
return <></>;
}
const tooltip = useMemo(() => {
if (!hierarchyTooltipID) {
return <></>;
}
return (
<Tooltip id="tag-hierarchy-tooltip">
<FormattedMessage id={hierarchyTooltipID} />
</Tooltip>
);
}, [hierarchyTooltipID]);
return (
<Tooltip id="tag-hierarchy-tooltip">
<FormattedMessage id={hierarchyTooltipID} />
</Tooltip>
<SortNameLinkComponent
sortName={tag.sort_name || title}
link={link}
className={className}
>
<TagPopover id={tag.id ?? ""} placement={hoverPlacement}>
{title}
{showHierarchyIcon && (
<OverlayTrigger placement="top" overlay={tooltip}>
<span className="icon-wrapper">
<span className="vertical-line">|</span>
<Icon icon={faFolderTree} className="tag-icon" />
</span>
</OverlayTrigger>
)}
</TagPopover>
</SortNameLinkComponent>
);
}, [hierarchyTooltipID]);
return (
<SortNameLinkComponent
sortName={tag.sort_name || title}
link={link}
className={className}
>
<TagPopover id={tag.id ?? ""} placement={hoverPlacement}>
{title}
{showHierarchyIcon && (
<OverlayTrigger placement="top" overlay={tooltip}>
<span className="icon-wrapper">
<span className="vertical-line">|</span>
<Icon icon={faFolderTree} className="tag-icon" />
</span>
</OverlayTrigger>
)}
</TagPopover>
</SortNameLinkComponent>
);
};
}
);

View File

@@ -1,3 +1,4 @@
import { PatchComponent } from "src/patch";
import { Button, ButtonGroup } from "react-bootstrap";
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
@@ -12,6 +13,7 @@ import { Icon } from "../Shared/Icon";
import { faHeart } from "@fortawesome/free-solid-svg-icons";
import cx from "classnames";
import { useTagUpdate } from "src/core/StashService";
interface IProps {
tag: GQL.TagDataFragment;
containerWidth?: number;
@@ -21,16 +23,226 @@ interface IProps {
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
}
export const TagCard: React.FC<IProps> = ({
tag,
containerWidth,
zoomIndex,
selecting,
selected,
onSelectedChanged,
}) => {
const TagCardPopovers: React.FC<IProps> = PatchComponent(
"TagCard.Popovers",
({ tag }) => {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
<PopoverCountButton
className="scene-count"
type="scene"
count={tag.scene_count}
url={NavUtils.makeTagScenesUrl(tag)}
showZero={false}
/>
<PopoverCountButton
className="image-count"
type="image"
count={tag.image_count}
url={NavUtils.makeTagImagesUrl(tag)}
showZero={false}
/>
<PopoverCountButton
className="gallery-count"
type="gallery"
count={tag.gallery_count}
url={NavUtils.makeTagGalleriesUrl(tag)}
showZero={false}
/>
<PopoverCountButton
className="group-count"
type="group"
count={tag.group_count}
url={NavUtils.makeTagGroupsUrl(tag)}
showZero={false}
/>
<PopoverCountButton
className="marker-count"
type="marker"
count={tag.scene_marker_count}
url={NavUtils.makeTagSceneMarkersUrl(tag)}
showZero={false}
/>
<PopoverCountButton
className="performer-count"
type="performer"
count={tag.performer_count}
url={NavUtils.makeTagPerformersUrl(tag)}
showZero={false}
/>
<PopoverCountButton
className="studio-count"
type="studio"
count={tag.studio_count}
url={NavUtils.makeTagStudiosUrl(tag)}
showZero={false}
/>
</ButtonGroup>
</>
);
}
);
const TagCardOverlays: React.FC<IProps> = PatchComponent(
"TagCard.Overlays",
({ tag }) => {
const [updateTag] = useTagUpdate();
function renderFavoriteIcon() {
return (
<Link to="" onClick={(e) => e.preventDefault()}>
<Button
className={cx(
"minimal",
"mousetrap",
"favorite-button",
tag.favorite ? "favorite" : "not-favorite"
)}
onClick={() => onToggleFavorite!(!tag.favorite)}
>
<Icon icon={faHeart} size="2x" />
</Button>
</Link>
);
}
function onToggleFavorite(v: boolean) {
if (tag.id) {
updateTag({
variables: {
input: {
id: tag.id,
favorite: v,
},
},
});
}
}
return <>{renderFavoriteIcon()}</>;
}
);
const TagCardDetails: React.FC<IProps> = PatchComponent(
"TagCard.Details",
({ tag }) => {
function maybeRenderDescription() {
if (tag.description) {
return (
<TruncatedText
className="tag-description"
text={tag.description}
lineCount={3}
/>
);
}
}
function maybeRenderParents() {
if (tag.parents.length === 1) {
const parent = tag.parents[0];
return (
<div className="tag-parent-tags">
<FormattedMessage
id="sub_tag_of"
values={{
parent: <Link to={`/tags/${parent.id}`}>{parent.name}</Link>,
}}
/>
</div>
);
}
if (tag.parents.length > 1) {
return (
<div className="tag-parent-tags">
<FormattedMessage
id="sub_tag_of"
values={{
parent: (
<Link to={NavUtils.makeParentTagsUrl(tag)}>
{tag.parents.length}&nbsp;
<FormattedMessage
id="countables.tags"
values={{ count: tag.parents.length }}
/>
</Link>
),
}}
/>
</div>
);
}
}
function maybeRenderChildren() {
if (tag.children.length > 0) {
return (
<div className="tag-sub-tags">
<FormattedMessage
id="parent_of"
values={{
children: (
<Link to={NavUtils.makeChildTagsUrl(tag)}>
{tag.children.length}&nbsp;
<FormattedMessage
id="countables.tags"
values={{ count: tag.children.length }}
/>
</Link>
),
}}
/>
</div>
);
}
}
return (
<>
{maybeRenderDescription()}
{maybeRenderParents()}
{maybeRenderChildren()}
</>
);
}
);
const TagCardImage: React.FC<IProps> = PatchComponent(
"TagCard.Image",
({ tag }) => {
return (
<>
<img
loading="lazy"
className="tag-card-image"
alt={tag.name}
src={tag.image_path ?? ""}
/>
</>
);
}
);
const TagCardTitle: React.FC<IProps> = PatchComponent(
"TagCard.Title",
({ tag }) => {
return <>{tag.name ?? ""}</>;
}
);
export const TagCard: React.FC<IProps> = PatchComponent("TagCard", (props) => {
const {
tag,
containerWidth,
zoomIndex,
selecting,
selected,
onSelectedChanged,
} = props;
const [cardWidth, setCardWidth] = useState<number>();
const [updateTag] = useTagUpdate();
useEffect(() => {
if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile())
return;
@@ -57,244 +269,20 @@ export const TagCard: React.FC<IProps> = ({
setCardWidth(fittedCardWidth);
}, [containerWidth, zoomIndex]);
function maybeRenderDescription() {
if (tag.description) {
return (
<TruncatedText
className="tag-description"
text={tag.description}
lineCount={3}
/>
);
}
}
function renderFavoriteIcon() {
return (
<Link to="" onClick={(e) => e.preventDefault()}>
<Button
className={cx(
"minimal",
"mousetrap",
"favorite-button",
tag.favorite ? "favorite" : "not-favorite"
)}
onClick={() => onToggleFavorite!(!tag.favorite)}
>
<Icon icon={faHeart} size="2x" />
</Button>
</Link>
);
}
function onToggleFavorite(v: boolean) {
if (tag.id) {
updateTag({
variables: {
input: {
id: tag.id,
favorite: v,
},
},
});
}
}
function maybeRenderParents() {
if (tag.parents.length === 1) {
const parent = tag.parents[0];
return (
<div className="tag-parent-tags">
<FormattedMessage
id="sub_tag_of"
values={{
parent: <Link to={`/tags/${parent.id}`}>{parent.name}</Link>,
}}
/>
</div>
);
}
if (tag.parents.length > 1) {
return (
<div className="tag-parent-tags">
<FormattedMessage
id="sub_tag_of"
values={{
parent: (
<Link to={NavUtils.makeParentTagsUrl(tag)}>
{tag.parents.length}&nbsp;
<FormattedMessage
id="countables.tags"
values={{ count: tag.parents.length }}
/>
</Link>
),
}}
/>
</div>
);
}
}
function maybeRenderChildren() {
if (tag.children.length > 0) {
return (
<div className="tag-sub-tags">
<FormattedMessage
id="parent_of"
values={{
children: (
<Link to={NavUtils.makeChildTagsUrl(tag)}>
{tag.children.length}&nbsp;
<FormattedMessage
id="countables.tags"
values={{ count: tag.children.length }}
/>
</Link>
),
}}
/>
</div>
);
}
}
function maybeRenderScenesPopoverButton() {
if (!tag.scene_count) return;
return (
<PopoverCountButton
className="scene-count"
type="scene"
count={tag.scene_count}
url={NavUtils.makeTagScenesUrl(tag)}
/>
);
}
function maybeRenderSceneMarkersPopoverButton() {
if (!tag.scene_marker_count) return;
return (
<PopoverCountButton
className="marker-count"
type="marker"
count={tag.scene_marker_count}
url={NavUtils.makeTagSceneMarkersUrl(tag)}
/>
);
}
function maybeRenderImagesPopoverButton() {
if (!tag.image_count) return;
return (
<PopoverCountButton
className="image-count"
type="image"
count={tag.image_count}
url={NavUtils.makeTagImagesUrl(tag)}
/>
);
}
function maybeRenderGalleriesPopoverButton() {
if (!tag.gallery_count) return;
return (
<PopoverCountButton
className="gallery-count"
type="gallery"
count={tag.gallery_count}
url={NavUtils.makeTagGalleriesUrl(tag)}
/>
);
}
function maybeRenderPerformersPopoverButton() {
if (!tag.performer_count) return;
return (
<PopoverCountButton
className="performer-count"
type="performer"
count={tag.performer_count}
url={NavUtils.makeTagPerformersUrl(tag)}
/>
);
}
function maybeRenderStudiosPopoverButton() {
if (!tag.studio_count) return;
return (
<PopoverCountButton
className="studio-count"
type="studio"
count={tag.studio_count}
url={NavUtils.makeTagStudiosUrl(tag)}
/>
);
}
function maybeRenderGroupsPopoverButton() {
if (!tag.group_count) return;
return (
<PopoverCountButton
className="group-count"
type="group"
count={tag.group_count}
url={NavUtils.makeTagGroupsUrl(tag)}
/>
);
}
function maybeRenderPopoverButtonGroup() {
if (tag) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()}
{maybeRenderImagesPopoverButton()}
{maybeRenderGalleriesPopoverButton()}
{maybeRenderGroupsPopoverButton()}
{maybeRenderSceneMarkersPopoverButton()}
{maybeRenderPerformersPopoverButton()}
{maybeRenderStudiosPopoverButton()}
</ButtonGroup>
</>
);
}
}
return (
<GridCard
className={`tag-card zoom-${zoomIndex}`}
url={`/tags/${tag.id}`}
width={cardWidth}
title={tag.name ?? ""}
title={<TagCardTitle {...props} />}
linkClassName="tag-card-header"
image={
<img
loading="lazy"
className="tag-card-image"
alt={tag.name}
src={tag.image_path ?? ""}
/>
}
details={
<>
{maybeRenderDescription()}
{maybeRenderParents()}
{maybeRenderChildren()}
</>
}
overlays={<>{renderFavoriteIcon()}</>}
popovers={maybeRenderPopoverButtonGroup()}
image={<TagCardImage {...props} />}
details={<TagCardDetails {...props} />}
overlays={<TagCardOverlays {...props} />}
popovers={<TagCardPopovers {...props} />}
selected={selected}
selecting={selecting}
onSelectedChanged={onSelectedChanged}
/>
);
};
});

View File

@@ -31,6 +31,7 @@ This namespace contains the generated graphql client interface. This is a low-le
- `FontAwesomeSolid`
- `Mousetrap`
- `MousetrapPause`
- `ReactSelect`
### `register`
@@ -147,21 +148,36 @@ Returns `void`.
- `CountrySelect`
- `DateInput`
- `FolderSelect`
- `FrontPage`
- `GalleryIDSelect`
- `GallerySelect`
- `GallerySelect.sort`
- `HoverPopover`
- `Icon`
- `ImageDetailPanel`
- `LoadingIndicator`
- `ModalSetting`
- `GroupIDSelect`
- `GroupSelect`
- `GroupSelect.sort`
- `NumberSetting`
- `PerformerAppearsWithPanel`
- `PerformerCard`
- `PerformerCard.Details`
- `PerformerCard.Image`
- `PerformerCard.Overlays`
- `PerformerCard.Popovers`
- `PerformerCard.Title`
- `PerformerDetailsPanel`
- `PerformerDetailsPanel.DetailGroup`
- `PerformerIDSelect`
- `PerformerPage`
- `PerformerSelect`
- `PerformerSelect.sort`
- `PerformerGalleriesPanel`
- `PerformerGroupsPanel`
- `PerformerImagesPanel`
- `PerformerScenesPanel`
- `PluginRoutes`
- `SceneCard`
- `SceneCard.Details`
@@ -169,6 +185,10 @@ Returns `void`.
- `SceneCard.Overlays`
- `SceneCard.Popovers`
- `SceneIDSelect`
- `ScenePage`
- `ScenePage.Tabs`
- `ScenePage.TabContent`
- `ScenePlayer`
- `SceneSelect`
- `SceneSelect.sort`
- `SelectSetting`
@@ -179,6 +199,15 @@ Returns `void`.
- `StudioIDSelect`
- `StudioSelect`
- `StudioSelect.sort`
- `TabTitleCounter`
- `TagCard`
- `TagCard.Details`
- `TagCard.Image`
- `TagCard.Overlays`
- `TagCard.Popovers`
- `TagCard.Title`
- `TagLink`
- `TabTitleCounter`
- `TagIDSelect`
- `TagSelect`
- `TagSelect.sort`

View File

@@ -1,13 +1,6 @@
import React from "react";
import { HoverPopover } from "./components/Shared/HoverPopover";
import { TagLink } from "./components/Shared/TagLink";
import { LoadingIndicator } from "./components/Shared/LoadingIndicator";
export const components: Record<string, Function> = {
HoverPopover,
TagLink,
LoadingIndicator,
};
export let components: Record<string, Function> = {};
const beforeFns: Record<string, Function[]> = {};
const insteadFns: Record<string, Function[]> = {};
@@ -118,10 +111,12 @@ export function PatchComponent<T>(
}
// patches a component and registers it in the pluginapi components object
export function PatchContainerComponent(
export function PatchContainerComponent<T = {}>(
component: string
): React.FC<React.PropsWithChildren<{}>> {
const fn = (props: React.PropsWithChildren<{}>) => {
): React.FC<React.PropsWithChildren<T>> {
const fn: React.FC<React.PropsWithChildren<T>> = (
props: React.PropsWithChildren<T>
) => {
return <>{props.children}</>;
};

View File

@@ -616,6 +616,7 @@ declare namespace PluginApi {
const FontAwesomeSolid: typeof import("@fortawesome/free-solid-svg-icons");
const Intl: typeof import("react-intl");
const Mousetrap: typeof import("mousetrap");
const ReactSelect: typeof import("react-select");
// @ts-expect-error
import { MousetrapStatic } from "mousetrap";
@@ -688,6 +689,29 @@ declare namespace PluginApi {
StringListSetting: React.FC<any>;
ConstantSetting: React.FC<any>;
SceneFileInfoPanel: React.FC<any>;
PerformerPage: React.FC<any>;
PerformerAppearsWithPanel: React.FC<any>;
PerformerGalleriesPanel: React.FC<any>;
PerformerGroupsPanel: React.FC<any>;
PerformerScenesPanel: React.FC<any>;
PerformerImagesPanel: React.FC<any>;
TabTitleCounter: React.FC<any>;
PerformerCard: React.FC<any>;
"PerformerCard.Popovers": React.FC<any>;
"PerformerCard.Details": React.FC<any>;
"PerformerCard.Overlays": React.FC<any>;
"PerformerCard.Image": React.FC<any>;
"PerformerCard.Title": React.FC<any>;
"TagCard.Popovers": React.FC<any>;
"TagCard.Details": React.FC<any>;
"TagCard.Overlays": React.FC<any>;
"TagCard.Image": React.FC<any>;
"TagCard.Title": React.FC<any>;
ScenePage: React.FC<any>;
"ScenePage.Tabs": React.FC<any>;
"ScenePage.TabContent": React.FC<any>;
ScenePlayer: React.FC<any>;
FrontPage: React.FC<any>;
};
type PatchableComponentNames = keyof typeof components | string;
namespace utils {

View File

@@ -11,6 +11,7 @@ import * as Bootstrap from "react-bootstrap";
import * as Intl from "react-intl";
import * as FontAwesomeSolid from "@fortawesome/free-solid-svg-icons";
import * as FontAwesomeRegular from "@fortawesome/free-regular-svg-icons";
import * as ReactSelect from "react-select";
import { useSpriteInfo } from "./hooks/sprite";
import { useToast } from "./hooks/Toast";
import Event from "./hooks/event";
@@ -73,6 +74,7 @@ export const PluginApi = {
FontAwesomeSolid,
Mousetrap,
MousetrapPause,
ReactSelect,
},
register: {
// register a route to be added to the main router