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>; Button: React.FC<any>;
Nav: React.FC<any> & { Nav: React.FC<any> & {
Link: React.FC<any>; Link: React.FC<any>;
Item: React.FC<any>;
}; };
Tab: React.FC<any> & {
Pane: React.FC<any>;
}
}, },
FontAwesomeSolid: { FontAwesomeSolid: {
faEthernet: any; faEthernet: any;
@@ -45,7 +49,7 @@ interface IPluginApi {
const React = PluginApi.React; const React = PluginApi.React;
const GQL = PluginApi.GQL; const GQL = PluginApi.GQL;
const { Button } = PluginApi.libraries.Bootstrap; const { Button, Nav, Tab } = PluginApi.libraries.Bootstrap;
const { faEthernet } = PluginApi.libraries.FontAwesomeSolid; const { faEthernet } = PluginApi.libraries.FontAwesomeSolid;
const { const {
Link, Link,
@@ -144,6 +148,10 @@ interface IPluginApi {
return <><Overlays />{original({...props})}</>; 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 TestPage: React.FC = () => {
const componentsToLoad = [ const componentsToLoad = [
PluginApi.loadableComponents.SceneCard, 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, getFrontPageContent,
} from "src/core/config"; } from "src/core/config";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; 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 intl = useIntl();
const Toast = useToast(); const Toast = useToast();
@@ -81,6 +82,6 @@ const FrontPage: React.FC = () => {
</div> </div>
</div> </div>
); );
}; });
export default FrontPage; export default FrontPage;

View File

@@ -23,6 +23,7 @@ import { usePerformerUpdate } from "src/core/StashService";
import { ILabeledId } from "src/models/list-filter/types"; import { ILabeledId } from "src/models/list-filter/types";
import ScreenUtils from "src/utils/screen"; import ScreenUtils from "src/utils/screen";
import { FavoriteIcon } from "../Shared/FavoriteIcon"; import { FavoriteIcon } from "../Shared/FavoriteIcon";
import { PatchComponent } from "src/patch";
export interface IPerformerCardExtraCriteria { export interface IPerformerCardExtraCriteria {
scenes?: ModifierCriterion<CriterionValue>[]; scenes?: ModifierCriterion<CriterionValue>[];
@@ -43,75 +44,9 @@ interface IPerformerCardProps {
extraCriteria?: IPerformerCardExtraCriteria; extraCriteria?: IPerformerCardExtraCriteria;
} }
export const PerformerCard: React.FC<IPerformerCardProps> = ({ const PerformerCardPopovers: React.FC<IPerformerCardProps> = PatchComponent(
performer, "PerformerCard.Popovers",
containerWidth, ({ performer, extraCriteria }) => {
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,
},
},
});
}
}
function maybeRenderScenesPopoverButton() { function maybeRenderScenesPopoverButton() {
if (!performer.scene_count) return; if (!performer.scene_count) return;
@@ -212,7 +147,6 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
); );
} }
function maybeRenderPopoverButtonGroup() {
if ( if (
performer.scene_count || performer.scene_count ||
performer.image_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() { function maybeRenderRatingBanner() {
@@ -262,34 +217,6 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
} }
return ( 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 <FavoriteIcon
favorite={performer.favorite} favorite={performer.favorite}
@@ -300,8 +227,31 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
{maybeRenderRatingBanner()} {maybeRenderRatingBanner()}
{maybeRenderFlag()} {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 ? ( {age !== 0 ? (
<div className="performer-card__age">{ageString}</div> <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} selected={selected}
selecting={selecting} selecting={selecting}
onSelectedChanged={onSelectedChanged} onSelectedChanged={onSelectedChanged}
/> />
); );
}; }
);

View File

@@ -45,6 +45,7 @@ import { FavoriteIcon } from "src/components/Shared/FavoriteIcon";
import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList";
import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
import { LightboxLink } from "src/hooks/Lightbox/LightboxLink"; import { LightboxLink } from "src/hooks/Lightbox/LightboxLink";
import { PatchComponent } from "src/patch";
interface IProps { interface IProps {
performer: GQL.PerformerDataFragment; 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 Toast = useToast();
const history = useHistory(); const history = useHistory();
const intl = useIntl(); const intl = useIntl();
@@ -283,7 +286,9 @@ const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => {
Toast.success( Toast.success(
intl.formatMessage( intl.formatMessage(
{ id: "toast.updated_entity" }, { 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>
</div> </div>
); );
}; }
);
const PerformerLoader: React.FC<RouteComponentProps<IPerformerParams>> = ({ const PerformerLoader: React.FC<RouteComponentProps<IPerformerParams>> = ({
location, location,

View File

@@ -3,16 +3,15 @@ import * as GQL from "src/core/generated-graphql";
import { GalleryList } from "src/components/Galleries/GalleryList"; import { GalleryList } from "src/components/Galleries/GalleryList";
import { usePerformerFilterHook } from "src/core/performers"; import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views"; import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
interface IPerformerDetailsProps { interface IPerformerDetailsProps {
active: boolean; active: boolean;
performer: GQL.PerformerDataFragment; performer: GQL.PerformerDataFragment;
} }
export const PerformerGalleriesPanel: React.FC<IPerformerDetailsProps> = ({ export const PerformerGalleriesPanel: React.FC<IPerformerDetailsProps> =
active, PatchComponent("PerformerGalleriesPanel", ({ active, performer }) => {
performer,
}) => {
const filterHook = usePerformerFilterHook(performer); const filterHook = usePerformerFilterHook(performer);
return ( return (
<GalleryList <GalleryList
@@ -21,4 +20,4 @@ export const PerformerGalleriesPanel: React.FC<IPerformerDetailsProps> = ({
view={View.PerformerGalleries} 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 { GroupList } from "src/components/Groups/GroupList";
import { usePerformerFilterHook } from "src/core/performers"; import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views"; import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
interface IPerformerDetailsProps { interface IPerformerDetailsProps {
active: boolean; active: boolean;
performer: GQL.PerformerDataFragment; performer: GQL.PerformerDataFragment;
} }
export const PerformerGroupsPanel: React.FC<IPerformerDetailsProps> = ({ export const PerformerGroupsPanel: React.FC<IPerformerDetailsProps> =
active, PatchComponent("PerformerGroupsPanel", ({ active, performer }) => {
performer,
}) => {
const filterHook = usePerformerFilterHook(performer); const filterHook = usePerformerFilterHook(performer);
return ( return (
<GroupList <GroupList
@@ -21,4 +20,4 @@ export const PerformerGroupsPanel: React.FC<IPerformerDetailsProps> = ({
view={View.PerformerGroups} 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 { ImageList } from "src/components/Images/ImageList";
import { usePerformerFilterHook } from "src/core/performers"; import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views"; import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
interface IPerformerImagesPanel { interface IPerformerImagesPanel {
active: boolean; active: boolean;
performer: GQL.PerformerDataFragment; performer: GQL.PerformerDataFragment;
} }
export const PerformerImagesPanel: React.FC<IPerformerImagesPanel> = ({ export const PerformerImagesPanel: React.FC<IPerformerImagesPanel> =
active, PatchComponent("PerformerImagesPanel", ({ active, performer }) => {
performer,
}) => {
const filterHook = usePerformerFilterHook(performer); const filterHook = usePerformerFilterHook(performer);
return ( return (
<ImageList <ImageList
@@ -21,4 +20,4 @@ export const PerformerImagesPanel: React.FC<IPerformerImagesPanel> = ({
view={View.PerformerImages} 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 { SceneList } from "src/components/Scenes/SceneList";
import { usePerformerFilterHook } from "src/core/performers"; import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views"; import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
interface IPerformerDetailsProps { interface IPerformerDetailsProps {
active: boolean; active: boolean;
performer: GQL.PerformerDataFragment; performer: GQL.PerformerDataFragment;
} }
export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> = ({ export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> =
active, PatchComponent("PerformerScenesPanel", ({ active, performer }) => {
performer,
}) => {
const filterHook = usePerformerFilterHook(performer); const filterHook = usePerformerFilterHook(performer);
return ( return (
<SceneList <SceneList
@@ -21,4 +20,4 @@ export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> = ({
view={View.PerformerScenes} 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 { PerformerList } from "src/components/Performers/PerformerList";
import { usePerformerFilterHook } from "src/core/performers"; import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views"; import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
interface IPerformerDetailsProps { interface IPerformerDetailsProps {
active: boolean; active: boolean;
performer: GQL.PerformerDataFragment; performer: GQL.PerformerDataFragment;
} }
export const PerformerAppearsWithPanel: React.FC<IPerformerDetailsProps> = ({ export const PerformerAppearsWithPanel: React.FC<IPerformerDetailsProps> =
active, PatchComponent("PerformerAppearsWithPanel", ({ active, performer }) => {
performer,
}) => {
const performerValue = { const performerValue = {
id: performer.id, id: performer.id,
label: performer.name ?? `Performer ${performer.id}`, label: performer.name ?? `Performer ${performer.id}`,
@@ -32,4 +31,4 @@ export const PerformerAppearsWithPanel: React.FC<IPerformerDetailsProps> = ({
view={View.PerformerAppearsWith} view={View.PerformerAppearsWith}
/> />
); );
}; });

View File

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

View File

@@ -50,6 +50,7 @@ import { useRatingKeybinds } from "src/hooks/keybinds";
import { lazyComponent } from "src/utils/lazyComponent"; import { lazyComponent } from "src/utils/lazyComponent";
import cx from "classnames"; import cx from "classnames";
import { TruncatedText } from "src/components/Shared/TruncatedText"; import { TruncatedText } from "src/components/Shared/TruncatedText";
import { PatchComponent, PatchContainerComponent } from "src/patch";
const SubmitStashBoxDraft = lazyComponent( const SubmitStashBoxDraft = lazyComponent(
() => import("src/components/Dialogs/SubmitDraft") () => import("src/components/Dialogs/SubmitDraft")
@@ -153,7 +154,13 @@ interface ISceneParams {
id: string; 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, scene,
setTimestamp, setTimestamp,
queueScenes, queueScenes,
@@ -170,7 +177,8 @@ const ScenePage: React.FC<IProps> = ({
collapsed, collapsed,
setCollapsed, setCollapsed,
setContinuePlaylist, setContinuePlaylist,
}) => { } = props;
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
const [updateScene] = useSceneUpdate(); const [updateScene] = useSceneUpdate();
@@ -423,6 +431,7 @@ const ScenePage: React.FC<IProps> = ({
> >
<div> <div>
<Nav variant="tabs" className="mr-auto"> <Nav variant="tabs" className="mr-auto">
<ScenePageTabs {...props}>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="scene-details-panel"> <Nav.Link eventKey="scene-details-panel">
<FormattedMessage id="details" /> <FormattedMessage id="details" />
@@ -485,10 +494,12 @@ const ScenePage: React.FC<IProps> = ({
<FormattedMessage id="actions.edit" /> <FormattedMessage id="actions.edit" />
</Nav.Link> </Nav.Link>
</Nav.Item> </Nav.Item>
</ScenePageTabs>
</Nav> </Nav>
</div> </div>
<Tab.Content> <Tab.Content>
<ScenePageTabContent {...props}>
<Tab.Pane eventKey="scene-details-panel"> <Tab.Pane eventKey="scene-details-panel">
<SceneDetailPanel scene={scene} /> <SceneDetailPanel scene={scene} />
</Tab.Pane> </Tab.Pane>
@@ -529,7 +540,10 @@ const ScenePage: React.FC<IProps> = ({
<Tab.Pane eventKey="scene-video-filter-panel"> <Tab.Pane eventKey="scene-video-filter-panel">
<SceneVideoFilterPanel scene={scene} /> <SceneVideoFilterPanel scene={scene} />
</Tab.Pane> </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} /> <SceneFileInfoPanel scene={scene} />
</Tab.Pane> </Tab.Pane>
<Tab.Pane eventKey="scene-edit-panel" mountOnEnter> <Tab.Pane eventKey="scene-edit-panel" mountOnEnter>
@@ -543,6 +557,7 @@ const ScenePage: React.FC<IProps> = ({
<Tab.Pane eventKey="scene-history-panel"> <Tab.Pane eventKey="scene-history-panel">
<SceneHistoryPanel scene={scene} /> <SceneHistoryPanel scene={scene} />
</Tab.Pane> </Tab.Pane>
</ScenePageTabContent>
</Tab.Content> </Tab.Content>
</Tab.Container> </Tab.Container>
); );
@@ -657,7 +672,7 @@ const ScenePage: React.FC<IProps> = ({
/> />
</> </>
); );
}; });
const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({ const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
location, location,

View File

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

View File

@@ -1,5 +1,6 @@
import React, { useState, useCallback, useEffect, useRef } from "react"; import React, { useState, useCallback, useEffect, useRef } from "react";
import { Overlay, Popover, OverlayProps } from "react-bootstrap"; import { Overlay, Popover, OverlayProps } from "react-bootstrap";
import { PatchComponent } from "src/patch";
interface IHoverPopover { interface IHoverPopover {
enterDelay?: number; enterDelay?: number;
@@ -12,7 +13,9 @@ interface IHoverPopover {
target?: React.RefObject<HTMLElement>; target?: React.RefObject<HTMLElement>;
} }
export const HoverPopover: React.FC<IHoverPopover> = ({ export const HoverPopover: React.FC<IHoverPopover> = PatchComponent(
"HoverPopover",
({
enterDelay = 200, enterDelay = 200,
leaveDelay = 200, leaveDelay = 200,
content, content,
@@ -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 { Spinner } from "react-bootstrap";
import cx from "classnames"; import cx from "classnames";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { PatchComponent } from "src/patch";
interface ILoadingProps { interface ILoadingProps {
message?: JSX.Element | string; message?: JSX.Element | string;
@@ -13,19 +14,20 @@ interface ILoadingProps {
const CLASSNAME = "LoadingIndicator"; const CLASSNAME = "LoadingIndicator";
const CLASSNAME_MESSAGE = `${CLASSNAME}-message`; const CLASSNAME_MESSAGE = `${CLASSNAME}-message`;
export const LoadingIndicator: React.FC<ILoadingProps> = ({ export const LoadingIndicator: React.FC<ILoadingProps> = PatchComponent(
message, "LoadingIndicator",
inline = false, ({ message, inline = false, small = false, card = false }) => {
small = false,
card = false,
}) => {
const intl = useIntl(); const intl = useIntl();
const text = intl.formatMessage({ id: "loading.generic" }); const text = intl.formatMessage({ id: "loading.generic" });
return ( return (
<div className={cx(CLASSNAME, { inline, small, "card-based": card })}> <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> <span className="sr-only">{text}</span>
</Spinner> </Spinner>
{message !== "" && ( {message !== "" && (
@@ -33,4 +35,5 @@ export const LoadingIndicator: React.FC<ILoadingProps> = ({
)} )}
</div> </div>
); );
}; }
);

View File

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

View File

@@ -13,6 +13,7 @@ import { Placement } from "react-bootstrap/esm/Overlay";
import { faFolderTree } from "@fortawesome/free-solid-svg-icons"; import { faFolderTree } from "@fortawesome/free-solid-svg-icons";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
type SceneMarkerFragment = Pick<GQL.SceneMarker, "id" | "title" | "seconds"> & { type SceneMarkerFragment = Pick<GQL.SceneMarker, "id" | "title" | "seconds"> & {
scene: Pick<GQL.Scene, "id">; scene: Pick<GQL.Scene, "id">;
@@ -243,7 +244,9 @@ interface ITagLinkProps {
hierarchyTooltipID?: string; hierarchyTooltipID?: string;
} }
export const TagLink: React.FC<ITagLinkProps> = ({ export const TagLink: React.FC<ITagLinkProps> = PatchComponent(
"TagLink",
({
tag, tag,
linkType = "scene", linkType = "scene",
className, className,
@@ -305,4 +308,5 @@ export const TagLink: React.FC<ITagLinkProps> = ({
</TagPopover> </TagPopover>
</SortNameLinkComponent> </SortNameLinkComponent>
); );
}; }
);

View File

@@ -1,3 +1,4 @@
import { PatchComponent } from "src/patch";
import { Button, ButtonGroup } from "react-bootstrap"; import { Button, ButtonGroup } from "react-bootstrap";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -12,6 +13,7 @@ import { Icon } from "../Shared/Icon";
import { faHeart } from "@fortawesome/free-solid-svg-icons"; import { faHeart } from "@fortawesome/free-solid-svg-icons";
import cx from "classnames"; import cx from "classnames";
import { useTagUpdate } from "src/core/StashService"; import { useTagUpdate } from "src/core/StashService";
interface IProps { interface IProps {
tag: GQL.TagDataFragment; tag: GQL.TagDataFragment;
containerWidth?: number; containerWidth?: number;
@@ -21,53 +23,73 @@ interface IProps {
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
} }
export const TagCard: React.FC<IProps> = ({ const TagCardPopovers: React.FC<IProps> = PatchComponent(
tag, "TagCard.Popovers",
containerWidth, ({ tag }) => {
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) {
return ( return (
<TruncatedText <>
className="tag-description" <hr />
text={tag.description} <ButtonGroup className="card-popovers">
lineCount={3} <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() { function renderFavoriteIcon() {
return ( return (
<Link to="" onClick={(e) => e.preventDefault()}> <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() { function maybeRenderParents() {
if (tag.parents.length === 1) { if (tag.parents.length === 1) {
const parent = tag.parents[0]; 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 ( return (
<> <>
<hr /> {maybeRenderDescription()}
<ButtonGroup className="card-popovers"> {maybeRenderParents()}
{maybeRenderScenesPopoverButton()} {maybeRenderChildren()}
{maybeRenderImagesPopoverButton()}
{maybeRenderGalleriesPopoverButton()}
{maybeRenderGroupsPopoverButton()}
{maybeRenderSceneMarkersPopoverButton()}
{maybeRenderPerformersPopoverButton()}
{maybeRenderStudiosPopoverButton()}
</ButtonGroup>
</> </>
); );
} }
} );
const TagCardImage: React.FC<IProps> = PatchComponent(
"TagCard.Image",
({ tag }) => {
return ( return (
<GridCard <>
className={`tag-card zoom-${zoomIndex}`}
url={`/tags/${tag.id}`}
width={cardWidth}
title={tag.name ?? ""}
linkClassName="tag-card-header"
image={
<img <img
loading="lazy" loading="lazy"
className="tag-card-image" className="tag-card-image"
alt={tag.name} alt={tag.name}
src={tag.image_path ?? ""} 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} selected={selected}
selecting={selecting} selecting={selecting}
onSelectedChanged={onSelectedChanged} onSelectedChanged={onSelectedChanged}
/> />
); );
}; });

View File

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

View File

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

View File

@@ -616,6 +616,7 @@ declare namespace PluginApi {
const FontAwesomeSolid: typeof import("@fortawesome/free-solid-svg-icons"); const FontAwesomeSolid: typeof import("@fortawesome/free-solid-svg-icons");
const Intl: typeof import("react-intl"); const Intl: typeof import("react-intl");
const Mousetrap: typeof import("mousetrap"); const Mousetrap: typeof import("mousetrap");
const ReactSelect: typeof import("react-select");
// @ts-expect-error // @ts-expect-error
import { MousetrapStatic } from "mousetrap"; import { MousetrapStatic } from "mousetrap";
@@ -688,6 +689,29 @@ declare namespace PluginApi {
StringListSetting: React.FC<any>; StringListSetting: React.FC<any>;
ConstantSetting: React.FC<any>; ConstantSetting: React.FC<any>;
SceneFileInfoPanel: 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; type PatchableComponentNames = keyof typeof components | string;
namespace utils { namespace utils {

View File

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