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,176 +44,109 @@ 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, function maybeRenderScenesPopoverButton() {
selecting, if (!performer.scene_count) return;
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(); return (
const [cardWidth, setCardWidth] = useState<number>(); <PopoverCountButton
className="scene-count"
useEffect(() => { type="scene"
if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile()) count={performer.scene_count}
return; url={NavUtils.makePerformerScenesUrl(
performer,
let zoomValue = zoomIndex; extraCriteria?.performer,
let preferredCardWidth: number; extraCriteria?.scenes
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) { function maybeRenderImagesPopoverButton() {
if (performer.id) { if (!performer.image_count) return;
updatePerformer({
variables: { return (
input: { <PopoverCountButton
id: performer.id, className="image-count"
favorite: v, type="image"
}, count={performer.image_count}
}, url={NavUtils.makePerformerImagesUrl(
}); performer,
extraCriteria?.performer,
extraCriteria?.images
)}
/>
);
} }
}
function maybeRenderScenesPopoverButton() { function maybeRenderGalleriesPopoverButton() {
if (!performer.scene_count) return; if (!performer.gallery_count) return;
return ( return (
<PopoverCountButton <PopoverCountButton
className="scene-count" className="gallery-count"
type="scene" type="gallery"
count={performer.scene_count} count={performer.gallery_count}
url={NavUtils.makePerformerScenesUrl( url={NavUtils.makePerformerGalleriesUrl(
performer, performer,
extraCriteria?.performer, extraCriteria?.performer,
extraCriteria?.scenes extraCriteria?.galleries
)} )}
/> />
); );
} }
function maybeRenderImagesPopoverButton() { function maybeRenderOCounter() {
if (!performer.image_count) return; if (!performer.o_counter) return;
return ( return (
<PopoverCountButton <div className="o-counter">
className="image-count" <Button className="minimal">
type="image" <span className="fa-icon">
count={performer.image_count} <SweatDrops />
url={NavUtils.makePerformerImagesUrl( </span>
performer, <span>{performer.o_counter}</span>
extraCriteria?.performer, </Button>
extraCriteria?.images </div>
)} );
/> }
);
}
function maybeRenderGalleriesPopoverButton() { function maybeRenderTagPopoverButton() {
if (!performer.gallery_count) return; if (performer.tags.length <= 0) return;
return ( const popoverContent = performer.tags.map((tag) => (
<PopoverCountButton <TagLink key={tag.id} linkType="performer" tag={tag} />
className="gallery-count" ));
type="gallery"
count={performer.gallery_count}
url={NavUtils.makePerformerGalleriesUrl(
performer,
extraCriteria?.performer,
extraCriteria?.galleries
)}
/>
);
}
function maybeRenderOCounter() { return (
if (!performer.o_counter) return; <HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal tag-count">
<Icon icon={faTag} />
<span>{performer.tags.length}</span>
</Button>
</HoverPopover>
);
}
return ( function maybeRenderGroupsPopoverButton() {
<div className="o-counter"> if (!performer.group_count) return;
<Button className="minimal">
<span className="fa-icon">
<SweatDrops />
</span>
<span>{performer.o_counter}</span>
</Button>
</div>
);
}
function maybeRenderTagPopoverButton() { return (
if (performer.tags.length <= 0) 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 ( if (
performer.scene_count || performer.scene_count ||
performer.image_count || performer.image_count ||
@@ -235,85 +169,189 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
</> </>
); );
} }
}
function maybeRenderRatingBanner() { return null;
if (!performer.rating100) {
return;
}
return <RatingBanner rating={performer.rating100} />;
} }
);
function maybeRenderFlag() { const PerformerCardOverlays: React.FC<IPerformerCardProps> = PatchComponent(
if (performer.country) { "PerformerCard.Overlays",
return ( ({ performer }) => {
<Link to={NavUtils.makePerformersCountryUrl(performer)}> const [updatePerformer] = usePerformerUpdate();
<CountryFlag
className="performer-card__country-flag"
country={performer.country}
includeOverlay
/>
<span className="performer-card__country-string">
{performer.country}
</span>
</Link>
);
}
}
return ( function onToggleFavorite(v: boolean) {
<GridCard if (performer.id) {
className={`performer-card zoom-${zoomIndex}`} updatePerformer({
url={`/performers/${performer.id}`} variables: {
width={cardWidth} input: {
pretitleIcon={ id: performer.id,
<GenderIcon className="gender-icon" gender={performer.gender} /> favorite: v,
},
},
});
} }
title={ }
<div>
<span className="performer-name">{performer.name}</span> function maybeRenderRatingBanner() {
{performer.disambiguation && ( if (!performer.rating100) {
<span className="performer-disambiguation"> return;
{` (${performer.disambiguation})`} }
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> </span>
)} </Link>
</div> );
} }
image={ }
<>
<img return (
loading="lazy" <>
className="performer-card-image" <FavoriteIcon
alt={performer.name ?? ""} favorite={performer.favorite}
src={performer.image_path ?? ""} 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={ let fittedCardWidth = calculateCardWidth(
<> containerWidth,
<FavoriteIcon preferredCardWidth!
favorite={performer.favorite} );
onToggleFavorite={onToggleFavorite} setCardWidth(fittedCardWidth);
size="2x" }, [containerWidth, zoomIndex]);
className="hide-not-favorite"
/> return (
{maybeRenderRatingBanner()} <GridCard
{maybeRenderFlag()} className={`performer-card zoom-${zoomIndex}`}
</> url={`/performers/${performer.id}`}
} width={cardWidth}
details={ pretitleIcon={
<> <GenderIcon className="gender-icon" gender={performer.gender} />
{age !== 0 ? ( }
<div className="performer-card__age">{ageString}</div> title={<PerformerCardTitle {...props} />}
) : ( image={<PerformerCardImage {...props} />}
"" overlays={<PerformerCardOverlays {...props} />}
)} details={<PerformerCardDetails {...props} />}
</> popovers={<PerformerCardPopovers {...props} />}
} selected={selected}
popovers={maybeRenderPopoverButtonGroup()} selecting={selecting}
selected={selected} onSelectedChanged={onSelectedChanged}
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 { 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,272 +201,277 @@ const PerformerTabs: React.FC<{
); );
}; };
const PerformerPage: React.FC<IProps> = ({ performer, tabKey }) => { const PerformerPage: React.FC<IProps> = PatchComponent(
const Toast = useToast(); "PerformerPage",
const history = useHistory(); ({ performer, tabKey }) => {
const intl = useIntl(); const Toast = useToast();
const history = useHistory();
const intl = useIntl();
// Configuration settings // Configuration settings
const { configuration } = React.useContext(ConfigurationContext); const { configuration } = React.useContext(ConfigurationContext);
const uiConfig = configuration?.ui; const uiConfig = configuration?.ui;
const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; const abbreviateCounter = uiConfig?.abbreviateCounters ?? false;
const enableBackgroundImage = const enableBackgroundImage =
uiConfig?.enablePerformerBackgroundImage ?? false; uiConfig?.enablePerformerBackgroundImage ?? false;
const showAllDetails = uiConfig?.showAllDetails ?? true; const showAllDetails = uiConfig?.showAllDetails ?? true;
const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false;
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails); const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
const [isEditing, setIsEditing] = useState<boolean>(false); const [isEditing, setIsEditing] = useState<boolean>(false);
const [image, setImage] = useState<string | null>(); const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false); const [encodingImage, setEncodingImage] = useState<boolean>(false);
const loadStickyHeader = useLoadStickyHeader(); const loadStickyHeader = useLoadStickyHeader();
const activeImage = useMemo(() => { const activeImage = useMemo(() => {
const performerImage = performer.image_path; const performerImage = performer.image_path;
if (isEditing) { if (isEditing) {
if (image === null && performerImage) { if (image === null && performerImage) {
const performerImageURL = new URL(performerImage); const performerImageURL = new URL(performerImage);
performerImageURL.searchParams.set("default", "true"); performerImageURL.searchParams.set("default", "true");
return performerImageURL.toString(); return performerImageURL.toString();
} else if (image) { } else if (image) {
return 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( useRatingKeybinds(
() => [{ paths: { thumbnail: activeImage, image: activeImage } }], true,
[activeImage] configuration?.ui.ratingSystemOptions?.type,
); setRating
);
const [updatePerformer] = usePerformerUpdate(); // set up hotkeys
const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy(); useEffect(() => {
Mousetrap.bind("e", () => toggleEditing());
Mousetrap.bind("f", () => setFavorite(!performer.favorite));
Mousetrap.bind(",", () => setCollapsed(!collapsed));
async function onAutoTag() { return () => {
try { Mousetrap.unbind("e");
await mutateMetadataAutoTag({ performers: [performer.id] }); Mousetrap.unbind("f");
Toast.success(intl.formatMessage({ id: "toast.started_auto_tagging" })); Mousetrap.unbind(",");
} 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,
},
},
}); });
toggleEditing(false);
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
{ entity: intl.formatMessage({ id: "performer" }).toLocaleLowerCase() }
)
);
}
async function onDelete() { async function onSave(input: GQL.PerformerCreateInput) {
try { await updatePerformer({
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: { variables: {
input: { input: {
id: performer.id, 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) { async function onDelete() {
if (performer.id) { try {
updatePerformer({ await deletePerformer({ variables: { id: performer.id } });
variables: { } catch (e) {
input: { Toast.error(e);
id: performer.id, }
rating100: v,
// 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) function setRating(v: number | null) {
return ( if (performer.id) {
<LoadingIndicator updatePerformer({
message={`Deleting performer ${performer.id}: ${performer.name}`} variables: {
/> input: {
); id: performer.id,
rating100: v,
},
},
});
}
}
const headerClassName = cx("detail-header", { if (isDestroying)
edit: isEditing, return (
collapsed, <LoadingIndicator
"full-width": !collapsed && !compactExpandedDetails, message={`Deleting performer ${performer.id}: ${performer.name}`}
});
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"> const headerClassName = cx("detail-header", {
<div className="performer-head col"> edit: isEditing,
<DetailTitle collapsed,
name={performer.name} "full-width": !collapsed && !compactExpandedDetails,
disambiguation={performer.disambiguation ?? undefined} });
classNamePrefix="performer"
> 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 && ( {!isEditing && (
<ExpandCollapseButton <PerformerDetailsPanel
performer={performer}
collapsed={collapsed} collapsed={collapsed}
setCollapsed={(v) => setCollapsed(v)} fullWidth={!collapsed && !compactExpandedDetails}
/> />
)} )}
<span className="name-icons"> {isEditing ? (
<FavoriteIcon <PerformerEditPanel
favorite={performer.favorite} performer={performer}
onToggleFavorite={(v) => setFavorite(v)} isVisible={isEditing}
onSubmit={onSave}
onCancel={() => toggleEditing()}
setImage={setImage}
setEncodingImage={setEncodingImage}
/> />
<ExternalLinkButtons urls={performer.urls ?? undefined} /> ) : (
</span> <Col>
</DetailTitle> <Row xs={8}>
<AliasList aliases={performer.alias_list} /> <DetailsEditNavbar
<RatingSystem objectName={
value={performer.rating100} performer?.name ??
onSetRating={(value) => setRating(value)} intl.formatMessage({ id: "performer" })
clickToRate }
withoutContext 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 && ( {!isEditing && (
<PerformerDetailsPanel <PerformerTabs
tabKey={tabKey}
performer={performer} performer={performer}
collapsed={collapsed} abbreviateCounter={abbreviateCounter}
fullWidth={!collapsed && !compactExpandedDetails}
/> />
)} )}
{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>
</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>> = ({ const PerformerLoader: React.FC<RouteComponentProps<IPerformerParams>> = ({
location, location,

View File

@@ -3,22 +3,21 @@ 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);
}) => { return (
const filterHook = usePerformerFilterHook(performer); <GalleryList
return ( filterHook={filterHook}
<GalleryList alterQuery={active}
filterHook={filterHook} view={View.PerformerGalleries}
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 { 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);
}) => { return (
const filterHook = usePerformerFilterHook(performer); <GroupList
return ( filterHook={filterHook}
<GroupList alterQuery={active}
filterHook={filterHook} view={View.PerformerGroups}
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 { 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);
}) => { return (
const filterHook = usePerformerFilterHook(performer); <ImageList
return ( filterHook={filterHook}
<ImageList alterQuery={active}
filterHook={filterHook} view={View.PerformerImages}
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 { 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);
}) => { return (
const filterHook = usePerformerFilterHook(performer); <SceneList
return ( filterHook={filterHook}
<SceneList alterQuery={active}
filterHook={filterHook} view={View.PerformerScenes}
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 { 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 = {
}) => { id: performer.id,
const performerValue = { label: performer.name ?? `Performer ${performer.id}`,
id: performer.id, };
label: performer.name ?? `Performer ${performer.id}`,
};
const extraCriteria = { const extraCriteria = {
performer: performerValue, performer: performerValue,
}; };
const filterHook = usePerformerFilterHook(performer); const filterHook = usePerformerFilterHook(performer);
return ( return (
<PerformerList <PerformerList
filterHook={filterHook} filterHook={filterHook}
extraCriteria={extraCriteria} extraCriteria={extraCriteria}
alterQuery={active} alterQuery={active}
view={View.PerformerAppearsWith} 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 { 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,24 +154,31 @@ interface ISceneParams {
id: string; id: string;
} }
const ScenePage: React.FC<IProps> = ({ const ScenePageTabs = PatchContainerComponent<IProps>("ScenePage.Tabs");
scene, const ScenePageTabContent = PatchContainerComponent<IProps>(
setTimestamp, "ScenePage.TabContent"
queueScenes, );
onQueueNext,
onQueuePrevious, const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
onQueueRandom, const {
onQueueSceneClicked, scene,
onDelete, setTimestamp,
continuePlaylist, queueScenes,
queueHasMoreScenes, onQueueNext,
onQueueMoreScenes, onQueuePrevious,
onQueueLessScenes, onQueueRandom,
queueStart, onQueueSceneClicked,
collapsed, onDelete,
setCollapsed, continuePlaylist,
setContinuePlaylist, queueHasMoreScenes,
}) => { onQueueMoreScenes,
onQueueLessScenes,
queueStart,
collapsed,
setCollapsed,
setContinuePlaylist,
} = props;
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
const [updateScene] = useSceneUpdate(); const [updateScene] = useSceneUpdate();
@@ -423,126 +431,133 @@ const ScenePage: React.FC<IProps> = ({
> >
<div> <div>
<Nav variant="tabs" className="mr-auto"> <Nav variant="tabs" className="mr-auto">
<Nav.Item> <ScenePageTabs {...props}>
<Nav.Link eventKey="scene-details-panel">
<FormattedMessage id="details" />
</Nav.Link>
</Nav.Item>
{queueScenes.length > 0 ? (
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="scene-queue-panel"> <Nav.Link eventKey="scene-details-panel">
<FormattedMessage id="queue" /> <FormattedMessage id="details" />
</Nav.Link> </Nav.Link>
</Nav.Item> </Nav.Item>
) : ( {queueScenes.length > 0 ? (
"" <Nav.Item>
)} <Nav.Link eventKey="scene-queue-panel">
<Nav.Item> <FormattedMessage id="queue" />
<Nav.Link eventKey="scene-markers-panel"> </Nav.Link>
<FormattedMessage id="markers" /> </Nav.Item>
</Nav.Link> ) : (
</Nav.Item> ""
{scene.groups.length > 0 ? ( )}
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="scene-group-panel"> <Nav.Link eventKey="scene-markers-panel">
<FormattedMessage <FormattedMessage id="markers" />
id="countables.groups"
values={{ count: scene.groups.length }}
/>
</Nav.Link> </Nav.Link>
</Nav.Item> </Nav.Item>
) : ( {scene.groups.length > 0 ? (
"" <Nav.Item>
)} <Nav.Link eventKey="scene-group-panel">
{scene.galleries.length >= 1 ? ( <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.Item>
<Nav.Link eventKey="scene-galleries-panel"> <Nav.Link eventKey="scene-video-filter-panel">
<FormattedMessage <FormattedMessage id="effect_filters.name" />
id="countables.galleries"
values={{ count: scene.galleries.length }}
/>
</Nav.Link> </Nav.Link>
</Nav.Item> </Nav.Item>
) : undefined} <Nav.Item>
<Nav.Item> <Nav.Link eventKey="scene-file-info-panel">
<Nav.Link eventKey="scene-video-filter-panel"> <FormattedMessage id="file_info" />
<FormattedMessage id="effect_filters.name" /> <Counter count={scene.files.length} hideZero hideOne />
</Nav.Link> </Nav.Link>
</Nav.Item> </Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="scene-file-info-panel"> <Nav.Link eventKey="scene-history-panel">
<FormattedMessage id="file_info" /> <FormattedMessage id="history" />
<Counter count={scene.files.length} hideZero hideOne /> </Nav.Link>
</Nav.Link> </Nav.Item>
</Nav.Item> <Nav.Item>
<Nav.Item> <Nav.Link eventKey="scene-edit-panel">
<Nav.Link eventKey="scene-history-panel"> <FormattedMessage id="actions.edit" />
<FormattedMessage id="history" /> </Nav.Link>
</Nav.Link> </Nav.Item>
</Nav.Item> </ScenePageTabs>
<Nav.Item>
<Nav.Link eventKey="scene-edit-panel">
<FormattedMessage id="actions.edit" />
</Nav.Link>
</Nav.Item>
</Nav> </Nav>
</div> </div>
<Tab.Content> <Tab.Content>
<Tab.Pane eventKey="scene-details-panel"> <ScenePageTabContent {...props}>
<SceneDetailPanel scene={scene} /> <Tab.Pane eventKey="scene-details-panel">
</Tab.Pane> <SceneDetailPanel scene={scene} />
<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>
)} <Tab.Pane eventKey="scene-queue-panel">
<Tab.Pane eventKey="scene-video-filter-panel"> <QueueViewer
<SceneVideoFilterPanel scene={scene} /> scenes={queueScenes}
</Tab.Pane> currentID={scene.id}
<Tab.Pane className="file-info-panel" eventKey="scene-file-info-panel"> continue={continuePlaylist}
<SceneFileInfoPanel scene={scene} /> setContinue={setContinuePlaylist}
</Tab.Pane> onSceneClicked={onQueueSceneClicked}
<Tab.Pane eventKey="scene-edit-panel" mountOnEnter> onNext={onQueueNext}
<SceneEditPanel onPrevious={onQueuePrevious}
isVisible={activeTabKey === "scene-edit-panel"} onRandom={onQueueRandom}
scene={scene} start={queueStart}
onSubmit={onSave} hasMoreScenes={queueHasMoreScenes}
onDelete={() => setIsDeleteAlertOpen(true)} onLessScenes={onQueueLessScenes}
/> onMoreScenes={onQueueMoreScenes}
</Tab.Pane> />
<Tab.Pane eventKey="scene-history-panel"> </Tab.Pane>
<SceneHistoryPanel scene={scene} /> <Tab.Pane eventKey="scene-markers-panel">
</Tab.Pane> <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.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(
return ( "TabTitleCounter",
<> ({ messageID, count, abbreviateCounter }) => {
<FormattedMessage id={messageID} /> return (
<Counter count={count} abbreviateCounter={abbreviateCounter} hideZero /> <>
</> <FormattedMessage id={messageID} />
); <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,72 +13,75 @@ interface IHoverPopover {
target?: React.RefObject<HTMLElement>; target?: React.RefObject<HTMLElement>;
} }
export const HoverPopover: React.FC<IHoverPopover> = ({ export const HoverPopover: React.FC<IHoverPopover> = PatchComponent(
enterDelay = 200, "HoverPopover",
leaveDelay = 200, ({
content, enterDelay = 200,
children, leaveDelay = 200,
className, content,
placement = "top", children,
onOpen, className,
onClose, placement = "top",
target, onOpen,
}) => { onClose,
const [show, setShow] = useState(false); target,
const triggerRef = useRef<HTMLDivElement>(null); }) => {
const enterTimer = useRef<number>(); const [show, setShow] = useState(false);
const leaveTimer = useRef<number>(); const triggerRef = useRef<HTMLDivElement>(null);
const enterTimer = useRef<number>();
const leaveTimer = useRef<number>();
const handleMouseEnter = useCallback(() => { 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);
window.clearTimeout(leaveTimer.current); window.clearTimeout(leaveTimer.current);
}, enterTimer.current = window.setTimeout(() => {
[] setShow(true);
); onOpen?.();
}, enterDelay);
}, [enterDelay, onOpen]);
return ( const handleMouseLeave = useCallback(() => {
<> window.clearTimeout(enterTimer.current);
<div leaveTimer.current = window.setTimeout(() => {
className={className} setShow(false);
onMouseEnter={handleMouseEnter} onClose?.();
onMouseLeave={handleMouseLeave} }, leaveDelay);
ref={triggerRef} }, [leaveDelay, onClose]);
>
{children} useEffect(
</div> () => () => {
{triggerRef.current && ( window.clearTimeout(enterTimer.current);
<Overlay window.clearTimeout(leaveTimer.current);
show={show} },
placement={placement} []
target={target?.current ?? triggerRef.current} );
return (
<>
<div
className={className}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={triggerRef}
> >
<Popover {children}
onMouseEnter={handleMouseEnter} </div>
onMouseLeave={handleMouseLeave} {triggerRef.current && (
id="popover" <Overlay
className="hover-popover-content" show={show}
placement={placement}
target={target?.current ?? triggerRef.current}
> >
{content} <Popover
</Popover> onMouseEnter={handleMouseEnter}
</Overlay> 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 { 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,24 +14,26 @@ 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, const intl = useIntl();
card = false,
}) => {
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
<span className="sr-only">{text}</span> animation="border"
</Spinner> role="status"
{message !== "" && ( size={small ? "sm" : undefined}
<h4 className={CLASSNAME_MESSAGE}>{message ?? text}</h4> >
)} <span className="sr-only">{text}</span>
</div> </Spinner>
); {message !== "" && (
}; <h4 className={CLASSNAME_MESSAGE}>{message ?? text}</h4>
)}
</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,66 +244,69 @@ interface ITagLinkProps {
hierarchyTooltipID?: string; hierarchyTooltipID?: string;
} }
export const TagLink: React.FC<ITagLinkProps> = ({ export const TagLink: React.FC<ITagLinkProps> = PatchComponent(
tag, "TagLink",
linkType = "scene", ({
className, tag,
hoverPlacement, linkType = "scene",
showHierarchyIcon = false, className,
hierarchyTooltipID, hoverPlacement,
}) => { showHierarchyIcon = false,
const link = useMemo(() => { hierarchyTooltipID,
switch (linkType) { }) => {
case "scene": const link = useMemo(() => {
return NavUtils.makeTagScenesUrl(tag); switch (linkType) {
case "performer": case "scene":
return NavUtils.makeTagPerformersUrl(tag); return NavUtils.makeTagScenesUrl(tag);
case "studio": case "performer":
return NavUtils.makeTagStudiosUrl(tag); return NavUtils.makeTagPerformersUrl(tag);
case "gallery": case "studio":
return NavUtils.makeTagGalleriesUrl(tag); return NavUtils.makeTagStudiosUrl(tag);
case "image": case "gallery":
return NavUtils.makeTagImagesUrl(tag); return NavUtils.makeTagGalleriesUrl(tag);
case "group": case "image":
return NavUtils.makeTagGroupsUrl(tag); return NavUtils.makeTagImagesUrl(tag);
case "scene_marker": case "group":
return NavUtils.makeTagSceneMarkersUrl(tag); return NavUtils.makeTagGroupsUrl(tag);
case "details": case "scene_marker":
return NavUtils.makeTagUrl(tag.id ?? ""); return NavUtils.makeTagSceneMarkersUrl(tag);
} case "details":
}, [tag, linkType]); return NavUtils.makeTagUrl(tag.id ?? "");
}
}, [tag, linkType]);
const title = tag.name || ""; const title = tag.name || "";
const tooltip = useMemo(() => { const tooltip = useMemo(() => {
if (!hierarchyTooltipID) { if (!hierarchyTooltipID) {
return <></>; return <></>;
} }
return (
<Tooltip id="tag-hierarchy-tooltip">
<FormattedMessage id={hierarchyTooltipID} />
</Tooltip>
);
}, [hierarchyTooltipID]);
return ( return (
<Tooltip id="tag-hierarchy-tooltip"> <SortNameLinkComponent
<FormattedMessage id={hierarchyTooltipID} /> sortName={tag.sort_name || title}
</Tooltip> 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 { 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,16 +23,226 @@ 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, return (
selecting, <>
selected, <hr />
onSelectedChanged, <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 [cardWidth, setCardWidth] = useState<number>();
const [updateTag] = useTagUpdate();
useEffect(() => { useEffect(() => {
if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile()) if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile())
return; return;
@@ -57,244 +269,20 @@ export const TagCard: React.FC<IProps> = ({
setCardWidth(fittedCardWidth); setCardWidth(fittedCardWidth);
}, [containerWidth, zoomIndex]); }, [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 ( return (
<GridCard <GridCard
className={`tag-card zoom-${zoomIndex}`} className={`tag-card zoom-${zoomIndex}`}
url={`/tags/${tag.id}`} url={`/tags/${tag.id}`}
width={cardWidth} width={cardWidth}
title={tag.name ?? ""} title={<TagCardTitle {...props} />}
linkClassName="tag-card-header" linkClassName="tag-card-header"
image={ image={<TagCardImage {...props} />}
<img details={<TagCardDetails {...props} />}
loading="lazy" overlays={<TagCardOverlays {...props} />}
className="tag-card-image" popovers={<TagCardPopovers {...props} />}
alt={tag.name}
src={tag.image_path ?? ""}
/>
}
details={
<>
{maybeRenderDescription()}
{maybeRenderParents()}
{maybeRenderChildren()}
</>
}
overlays={<>{renderFavoriteIcon()}</>}
popovers={maybeRenderPopoverButtonGroup()}
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