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,75 +44,9 @@ 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 [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;
}
let fittedCardWidth = calculateCardWidth(
containerWidth,
preferredCardWidth!
);
setCardWidth(fittedCardWidth);
}, [containerWidth, zoomIndex]);
function onToggleFavorite(v: boolean) {
if (performer.id) {
updatePerformer({
variables: {
input: {
id: performer.id,
favorite: v,
},
},
});
}
}
const PerformerCardPopovers: React.FC<IPerformerCardProps> = PatchComponent(
"PerformerCard.Popovers",
({ performer, extraCriteria }) => {
function maybeRenderScenesPopoverButton() {
if (!performer.scene_count) return;
@@ -212,7 +147,6 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
);
}
function maybeRenderPopoverButtonGroup() {
if (
performer.scene_count ||
performer.image_count ||
@@ -235,6 +169,27 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
</>
);
}
return null;
}
);
const PerformerCardOverlays: React.FC<IPerformerCardProps> = PatchComponent(
"PerformerCard.Overlays",
({ performer }) => {
const [updatePerformer] = usePerformerUpdate();
function onToggleFavorite(v: boolean) {
if (performer.id) {
updatePerformer({
variables: {
input: {
id: performer.id,
favorite: v,
},
},
});
}
}
function maybeRenderRatingBanner() {
@@ -262,34 +217,6 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
}
return (
<GridCard
className={`performer-card zoom-${zoomIndex}`}
url={`/performers/${performer.id}`}
width={cardWidth}
pretitleIcon={
<GenderIcon className="gender-icon" gender={performer.gender} />
}
title={
<div>
<span className="performer-name">{performer.name}</span>
{performer.disambiguation && (
<span className="performer-disambiguation">
{` (${performer.disambiguation})`}
</span>
)}
</div>
}
image={
<>
<img
loading="lazy"
className="performer-card-image"
alt={performer.name ?? ""}
src={performer.image_path ?? ""}
/>
</>
}
overlays={
<>
<FavoriteIcon
favorite={performer.favorite}
@@ -300,8 +227,31 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
{maybeRenderRatingBanner()}
{maybeRenderFlag()}
</>
);
}
details={
);
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>
@@ -309,11 +259,99 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
""
)}
</>
);
}
popovers={maybeRenderPopoverButtonGroup()}
);
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;
}
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,7 +201,9 @@ const PerformerTabs: React.FC<{
);
};
const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
const PerformerPage: React.FC<IProps> = PatchComponent(
"PerformerPage",
({ performer, tabKey }) => {
const Toast = useToast();
const history = useHistory();
const intl = useIntl();
@@ -283,7 +286,9 @@ const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
{ entity: intl.formatMessage({ id: "performer" }).toLocaleLowerCase() }
{
entity: intl.formatMessage({ id: "performer" }).toLocaleLowerCase(),
}
)
);
}
@@ -465,7 +470,8 @@ const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
</div>
</div>
);
};
}
);
const PerformerLoader: React.FC<RouteComponentProps<IPerformerParams>> = ({
location,

View File

@@ -3,16 +3,15 @@ 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,
}) => {
export const PerformerGalleriesPanel: React.FC<IPerformerDetailsProps> =
PatchComponent("PerformerGalleriesPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
<GalleryList
@@ -21,4 +20,4 @@ export const PerformerGalleriesPanel: React.FC<IPerformerDetailsProps> = ({
view={View.PerformerGalleries}
/>
);
};
});

View File

@@ -3,16 +3,15 @@ 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,
}) => {
export const PerformerGroupsPanel: React.FC<IPerformerDetailsProps> =
PatchComponent("PerformerGroupsPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
<GroupList
@@ -21,4 +20,4 @@ export const PerformerGroupsPanel: React.FC<IPerformerDetailsProps> = ({
view={View.PerformerGroups}
/>
);
};
});

View File

@@ -3,16 +3,15 @@ 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,
}) => {
export const PerformerImagesPanel: React.FC<IPerformerImagesPanel> =
PatchComponent("PerformerImagesPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
<ImageList
@@ -21,4 +20,4 @@ export const PerformerImagesPanel: React.FC<IPerformerImagesPanel> = ({
view={View.PerformerImages}
/>
);
};
});

View File

@@ -3,16 +3,15 @@ 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,
}) => {
export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> =
PatchComponent("PerformerScenesPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
<SceneList
@@ -21,4 +20,4 @@ export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> = ({
view={View.PerformerScenes}
/>
);
};
});

View File

@@ -3,16 +3,15 @@ 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,
}) => {
export const PerformerAppearsWithPanel: React.FC<IPerformerDetailsProps> =
PatchComponent("PerformerAppearsWithPanel", ({ active, performer }) => {
const performerValue = {
id: performer.id,
label: performer.name ?? `Performer ${performer.id}`,
@@ -32,4 +31,4 @@ export const PerformerAppearsWithPanel: React.FC<IPerformerDetailsProps> = ({
view={View.PerformerAppearsWith}
/>
);
};
});

View File

@@ -46,6 +46,7 @@ import airplay from "@silvermine/videojs-airplay";
import chromecast from "@silvermine/videojs-chromecast";
import abLoopPlugin from "videojs-abloop";
import ScreenUtils from "src/utils/screen";
import { PatchComponent } from "src/patch";
// register videojs plugins
airplay(videojs);
@@ -210,7 +211,9 @@ interface IScenePlayerProps {
onPrevious: () => void;
}
export const ScenePlayer: React.FC<IScenePlayerProps> = ({
export const ScenePlayer: React.FC<IScenePlayerProps> = PatchComponent(
"ScenePlayer",
({
scene,
hideScrubberOverride,
autoplay,
@@ -220,7 +223,7 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
onComplete,
onNext,
onPrevious,
}) => {
}) => {
const { configuration } = useContext(ConfigurationContext);
const interfaceConfig = configuration?.interface;
const uiConfig = configuration?.ui;
@@ -892,7 +895,10 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
return (
<div
className={cx("VideoPlayer", { portrait: isPortrait, "no-file": !file })}
className={cx("VideoPlayer", {
portrait: isPortrait,
"no-file": !file,
})}
onKeyDownCapture={onKeyDown}
>
<div className="video-wrapper" ref={videoRef} />
@@ -910,6 +916,7 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
)}
</div>
);
};
}
);
export default ScenePlayer;

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,7 +154,13 @@ interface ISceneParams {
id: string;
}
const ScenePage: React.FC<IProps> = ({
const ScenePageTabs = PatchContainerComponent<IProps>("ScenePage.Tabs");
const ScenePageTabContent = PatchContainerComponent<IProps>(
"ScenePage.TabContent"
);
const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
const {
scene,
setTimestamp,
queueScenes,
@@ -170,7 +177,8 @@ const ScenePage: React.FC<IProps> = ({
collapsed,
setCollapsed,
setContinuePlaylist,
}) => {
} = props;
const Toast = useToast();
const intl = useIntl();
const [updateScene] = useSceneUpdate();
@@ -423,6 +431,7 @@ const ScenePage: React.FC<IProps> = ({
>
<div>
<Nav variant="tabs" className="mr-auto">
<ScenePageTabs {...props}>
<Nav.Item>
<Nav.Link eventKey="scene-details-panel">
<FormattedMessage id="details" />
@@ -485,10 +494,12 @@ const ScenePage: React.FC<IProps> = ({
<FormattedMessage id="actions.edit" />
</Nav.Link>
</Nav.Item>
</ScenePageTabs>
</Nav>
</div>
<Tab.Content>
<ScenePageTabContent {...props}>
<Tab.Pane eventKey="scene-details-panel">
<SceneDetailPanel scene={scene} />
</Tab.Pane>
@@ -529,7 +540,10 @@ const ScenePage: React.FC<IProps> = ({
<Tab.Pane eventKey="scene-video-filter-panel">
<SceneVideoFilterPanel scene={scene} />
</Tab.Pane>
<Tab.Pane className="file-info-panel" eventKey="scene-file-info-panel">
<Tab.Pane
className="file-info-panel"
eventKey="scene-file-info-panel"
>
<SceneFileInfoPanel scene={scene} />
</Tab.Pane>
<Tab.Pane eventKey="scene-edit-panel" mountOnEnter>
@@ -543,6 +557,7 @@ const ScenePage: React.FC<IProps> = ({
<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 }) => {
}> = 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,7 +13,9 @@ interface IHoverPopover {
target?: React.RefObject<HTMLElement>;
}
export const HoverPopover: React.FC<IHoverPopover> = ({
export const HoverPopover: React.FC<IHoverPopover> = PatchComponent(
"HoverPopover",
({
enterDelay = 200,
leaveDelay = 200,
content,
@@ -22,7 +25,7 @@ export const HoverPopover: React.FC<IHoverPopover> = ({
onOpen,
onClose,
target,
}) => {
}) => {
const [show, setShow] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const enterTimer = useRef<number>();
@@ -80,4 +83,5 @@ export const HoverPopover: React.FC<IHoverPopover> = ({
)}
</>
);
};
}
);

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,19 +14,20 @@ interface ILoadingProps {
const CLASSNAME = "LoadingIndicator";
const CLASSNAME_MESSAGE = `${CLASSNAME}-message`;
export const LoadingIndicator: React.FC<ILoadingProps> = ({
message,
inline = false,
small = false,
card = false,
}) => {
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" });
return (
<div className={cx(CLASSNAME, { inline, small, "card-based": card })}>
<Spinner animation="border" role="status" size={small ? "sm" : undefined}>
<Spinner
animation="border"
role="status"
size={small ? "sm" : undefined}
>
<span className="sr-only">{text}</span>
</Spinner>
{message !== "" && (
@@ -33,4 +35,5 @@ export const LoadingIndicator: React.FC<ILoadingProps> = ({
)}
</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,14 +244,16 @@ interface ITagLinkProps {
hierarchyTooltipID?: string;
}
export const TagLink: React.FC<ITagLinkProps> = ({
export const TagLink: React.FC<ITagLinkProps> = PatchComponent(
"TagLink",
({
tag,
linkType = "scene",
className,
hoverPlacement,
showHierarchyIcon = false,
hierarchyTooltipID,
}) => {
}) => {
const link = useMemo(() => {
switch (linkType) {
case "scene":
@@ -305,4 +308,5 @@ export const TagLink: React.FC<ITagLinkProps> = ({
</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,53 +23,73 @@ interface IProps {
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
}
export const TagCard: React.FC<IProps> = ({
tag,
containerWidth,
zoomIndex,
selecting,
selected,
onSelectedChanged,
}) => {
const [cardWidth, setCardWidth] = useState<number>();
const [updateTag] = useTagUpdate();
useEffect(() => {
if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile())
return;
let zoomValue = zoomIndex;
let preferredCardWidth: number;
switch (zoomValue) {
case 0:
preferredCardWidth = 280;
break;
case 1:
preferredCardWidth = 340;
break;
case 2:
preferredCardWidth = 480;
break;
case 3:
preferredCardWidth = 640;
}
let fittedCardWidth = calculateCardWidth(
containerWidth,
preferredCardWidth!
);
setCardWidth(fittedCardWidth);
}, [containerWidth, zoomIndex]);
function maybeRenderDescription() {
if (tag.description) {
const TagCardPopovers: React.FC<IProps> = PatchComponent(
"TagCard.Popovers",
({ tag }) => {
return (
<TruncatedText
className="tag-description"
text={tag.description}
lineCount={3}
<>
<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()}>
@@ -98,6 +120,26 @@ export const TagCard: React.FC<IProps> = ({
});
}
}
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];
@@ -158,143 +200,89 @@ export const TagCard: React.FC<IProps> = ({
}
}
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>
{maybeRenderDescription()}
{maybeRenderParents()}
{maybeRenderChildren()}
</>
);
}
}
);
const TagCardImage: React.FC<IProps> = PatchComponent(
"TagCard.Image",
({ tag }) => {
return (
<GridCard
className={`tag-card zoom-${zoomIndex}`}
url={`/tags/${tag.id}`}
width={cardWidth}
title={tag.name ?? ""}
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()}
);
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>();
useEffect(() => {
if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile())
return;
let zoomValue = zoomIndex;
let preferredCardWidth: number;
switch (zoomValue) {
case 0:
preferredCardWidth = 280;
break;
case 1:
preferredCardWidth = 340;
break;
case 2:
preferredCardWidth = 480;
break;
case 3:
preferredCardWidth = 640;
}
let fittedCardWidth = calculateCardWidth(
containerWidth,
preferredCardWidth!
);
setCardWidth(fittedCardWidth);
}, [containerWidth, zoomIndex]);
return (
<GridCard
className={`tag-card zoom-${zoomIndex}`}
url={`/tags/${tag.id}`}
width={cardWidth}
title={<TagCardTitle {...props} />}
linkClassName="tag-card-header"
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