Plugin api improvements (#4935)

* Support hook into App component
* Add hookable PluginSettings component
* Add useSettings to plugin hooks
* Make setting inputs hookable
* Add hooks for performer details panel
* Update docs
This commit is contained in:
WithoutPants
2024-06-11 13:18:45 +10:00
committed by GitHub
parent ed057c971f
commit 845d718c67
7 changed files with 526 additions and 426 deletions

View File

@@ -50,6 +50,7 @@ import { PluginRoutes } from "./plugins";
// import plugin_api to run code // import plugin_api to run code
import "./pluginApi"; import "./pluginApi";
import { ConnectionMonitor } from "./ConnectionMonitor"; import { ConnectionMonitor } from "./ConnectionMonitor";
import { PatchFunction } from "./patch";
const Performers = lazyComponent( const Performers = lazyComponent(
() => import("./components/Performers/Performers") () => import("./components/Performers/Performers")
@@ -144,6 +145,13 @@ function sortPlugins(plugins: PluginList) {
return sorted; return sorted;
} }
const AppContainer: React.FC<React.PropsWithChildren<{}>> = PatchFunction(
"App",
(props: React.PropsWithChildren<{}>) => {
return <>{props.children}</>;
}
) as React.FC;
export const App: React.FC = () => { export const App: React.FC = () => {
const config = useConfiguration(); const config = useConfiguration();
const [saveUI] = useConfigureUI(); const [saveUI] = useConfigureUI();
@@ -357,41 +365,43 @@ export const App: React.FC = () => {
const titleProps = makeTitleProps(); const titleProps = makeTitleProps();
return ( return (
<ErrorBoundary> <AppContainer>
{messages ? ( <ErrorBoundary>
<IntlProvider {messages ? (
locale={language} <IntlProvider
messages={messages} locale={language}
formats={intlFormats} messages={messages}
> formats={intlFormats}
<ConfigurationProvider
configuration={config.data?.configuration}
loading={config.loading}
> >
{maybeRenderReleaseNotes()} <ConfigurationProvider
<ToastProvider> configuration={config.data?.configuration}
<ConnectionMonitor /> loading={config.loading}
<Suspense fallback={<LoadingIndicator />}> >
<LightboxProvider> {maybeRenderReleaseNotes()}
<ManualProvider> <ToastProvider>
<InteractiveProvider> <ConnectionMonitor />
<Helmet {...titleProps} /> <Suspense fallback={<LoadingIndicator />}>
{maybeRenderNavbar()} <LightboxProvider>
<div <ManualProvider>
className={`main container-fluid ${ <InteractiveProvider>
appleRendering ? "apple" : "" <Helmet {...titleProps} />
}`} {maybeRenderNavbar()}
> <div
{renderContent()} className={`main container-fluid ${
</div> appleRendering ? "apple" : ""
</InteractiveProvider> }`}
</ManualProvider> >
</LightboxProvider> {renderContent()}
</Suspense> </div>
</ToastProvider> </InteractiveProvider>
</ConfigurationProvider> </ManualProvider>
</IntlProvider> </LightboxProvider>
) : null} </Suspense>
</ErrorBoundary> </ToastProvider>
</ConfigurationProvider>
</IntlProvider>
) : null}
</ErrorBoundary>
</AppContainer>
); );
}; };

View File

@@ -1,4 +1,4 @@
import React from "react"; import React, { PropsWithChildren } from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { TagLink } from "src/components/Shared/TagLink"; import { TagLink } from "src/components/Shared/TagLink";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -13,6 +13,7 @@ import {
FormatPenisLength, FormatPenisLength,
FormatWeight, FormatWeight,
} from "../PerformerList"; } from "../PerformerList";
import { PatchComponent } from "src/patch";
interface IPerformerDetails { interface IPerformerDetails {
performer: GQL.PerformerDataFragment; performer: GQL.PerformerDataFragment;
@@ -20,231 +21,236 @@ interface IPerformerDetails {
fullWidth?: boolean; fullWidth?: boolean;
} }
export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({ const PerformerDetailGroup: React.FC<PropsWithChildren<IPerformerDetails>> =
performer, PatchComponent("PerformerDetailsPanel.DetailGroup", ({ children }) => {
collapsed, return <div className="detail-group">{children}</div>;
fullWidth, });
}) => {
// Network state
const intl = useIntl();
function renderTagsField() { export const PerformerDetailsPanel: React.FC<IPerformerDetails> =
if (!performer.tags.length) { PatchComponent("PerformerDetailsPanel", (props) => {
return; const { performer, collapsed, fullWidth } = props;
}
return (
<ul className="pl-0">
{(performer.tags ?? []).map((tag) => (
<TagLink key={tag.id} linkType="performer" tag={tag} />
))}
</ul>
);
}
function renderStashIDs() { // Network state
if (!performer.stash_ids.length) { const intl = useIntl();
return;
}
return ( function renderTagsField() {
<ul className="pl-0"> if (!performer.tags.length) {
{performer.stash_ids.map((stashID) => ( return;
<li key={stashID.stash_id} className="row no-gutters"> }
<StashIDPill stashID={stashID} linkType="performers" />
</li>
))}
</ul>
);
}
function maybeRenderExtraDetails() {
if (!collapsed) {
/* Remove extra urls provided in details since they will be present by perfomr name */
/* This code can be removed once multple urls are supported for performers */
let details = performer?.details
?.replace(/\[((?:http|www\.)[^\n\]]+)\]/gm, "")
.trim();
return ( return (
<> <ul className="pl-0">
<DetailItem {(performer.tags ?? []).map((tag) => (
id="tattoos" <TagLink key={tag.id} linkType="performer" tag={tag} />
value={performer?.tattoos} ))}
fullWidth={fullWidth} </ul>
/>
<DetailItem
id="piercings"
value={performer?.piercings}
fullWidth={fullWidth}
/>
<DetailItem
id="career_length"
value={performer?.career_length}
fullWidth={fullWidth}
/>
<DetailItem id="details" value={details} fullWidth={fullWidth} />
<DetailItem
id="tags"
value={renderTagsField()}
fullWidth={fullWidth}
/>
<DetailItem
id="stash_ids"
value={renderStashIDs()}
fullWidth={fullWidth}
/>
</>
); );
} }
}
return ( function renderStashIDs() {
<div className="detail-group"> if (!performer.stash_ids.length) {
{performer.gender ? ( return;
<DetailItem }
id="gender"
value={intl.formatMessage({ id: "gender_types." + performer.gender })} return (
fullWidth={fullWidth} <ul className="pl-0">
/> {performer.stash_ids.map((stashID) => (
) : ( <li key={stashID.stash_id} className="row no-gutters">
"" <StashIDPill stashID={stashID} linkType="performers" />
)} </li>
<DetailItem ))}
id="age" </ul>
value={ );
!fullWidth }
? TextUtils.age(performer.birthdate, performer.death_date)
: FormatAge(performer.birthdate, performer.death_date) function maybeRenderExtraDetails() {
} if (!collapsed) {
title={ /* Remove extra urls provided in details since they will be present by perfomr name */
!fullWidth /* This code can be removed once multple urls are supported for performers */
? TextUtils.formatDate(intl, performer.birthdate ?? undefined) let details = performer?.details
: "" ?.replace(/\[((?:http|www\.)[^\n\]]+)\]/gm, "")
} .trim();
fullWidth={fullWidth} return (
/> <>
<DetailItem id="death_date" value={performer.death_date} /> <DetailItem
{performer.country ? ( id="tattoos"
<DetailItem value={performer?.tattoos}
id="country" fullWidth={fullWidth}
value={
<CountryFlag
country={performer.country}
className="mr-2"
includeName={true}
/> />
<DetailItem
id="piercings"
value={performer?.piercings}
fullWidth={fullWidth}
/>
<DetailItem
id="career_length"
value={performer?.career_length}
fullWidth={fullWidth}
/>
<DetailItem id="details" value={details} fullWidth={fullWidth} />
<DetailItem
id="tags"
value={renderTagsField()}
fullWidth={fullWidth}
/>
<DetailItem
id="stash_ids"
value={renderStashIDs()}
fullWidth={fullWidth}
/>
</>
);
}
}
return (
<PerformerDetailGroup {...props}>
{performer.gender ? (
<DetailItem
id="gender"
value={intl.formatMessage({
id: "gender_types." + performer.gender,
})}
fullWidth={fullWidth}
/>
) : (
""
)}
<DetailItem
id="age"
value={
!fullWidth
? TextUtils.age(performer.birthdate, performer.death_date)
: FormatAge(performer.birthdate, performer.death_date)
}
title={
!fullWidth
? TextUtils.formatDate(intl, performer.birthdate ?? undefined)
: ""
} }
fullWidth={fullWidth} fullWidth={fullWidth}
/> />
) : ( <DetailItem id="death_date" value={performer.death_date} />
""
)}
<DetailItem
id="ethnicity"
value={performer?.ethnicity}
fullWidth={fullWidth}
/>
<DetailItem
id="hair_color"
value={performer?.hair_color}
fullWidth={fullWidth}
/>
<DetailItem
id="eye_color"
value={performer?.eye_color}
fullWidth={fullWidth}
/>
<DetailItem
id="height"
value={FormatHeight(performer.height_cm)}
fullWidth={fullWidth}
/>
<DetailItem
id="weight"
value={FormatWeight(performer.weight)}
fullWidth={fullWidth}
/>
<DetailItem
id="penis_length"
value={FormatPenisLength(performer.penis_length)}
fullWidth={fullWidth}
/>
<DetailItem
id="circumcised"
value={FormatCircumcised(performer.circumcised)}
fullWidth={fullWidth}
/>
<DetailItem
id="measurements"
value={performer?.measurements}
fullWidth={fullWidth}
/>
<DetailItem
id="fake_tits"
value={performer?.fake_tits}
fullWidth={fullWidth}
/>
{maybeRenderExtraDetails()}
</div>
);
};
export const CompressedPerformerDetailsPanel: React.FC<IPerformerDetails> = ({
performer,
}) => {
// Network state
const intl = useIntl();
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
return (
<div className="sticky detail-header">
<div className="sticky detail-header-group">
<a className="performer-name" onClick={() => scrollToTop()}>
{performer.name}
</a>
{performer.gender ? (
<>
<span className="detail-divider">/</span>
<span className="performer-gender">
{intl.formatMessage({ id: "gender_types." + performer.gender })}
</span>
</>
) : (
""
)}
{performer.birthdate ? (
<>
<span className="detail-divider">/</span>
<span
className="performer-age"
title={TextUtils.formatDate(
intl,
performer.birthdate ?? undefined
)}
>
{TextUtils.age(performer.birthdate, performer.death_date)}
</span>
</>
) : (
""
)}
{performer.country ? ( {performer.country ? (
<> <DetailItem
<span className="detail-divider">/</span> id="country"
<span className="performer-country"> value={
<CountryFlag <CountryFlag
country={performer.country} country={performer.country}
className="mr-2" className="mr-2"
includeName={true} includeName={true}
/> />
</span> }
</> fullWidth={fullWidth}
/>
) : ( ) : (
"" ""
)} )}
<DetailItem
id="ethnicity"
value={performer?.ethnicity}
fullWidth={fullWidth}
/>
<DetailItem
id="hair_color"
value={performer?.hair_color}
fullWidth={fullWidth}
/>
<DetailItem
id="eye_color"
value={performer?.eye_color}
fullWidth={fullWidth}
/>
<DetailItem
id="height"
value={FormatHeight(performer.height_cm)}
fullWidth={fullWidth}
/>
<DetailItem
id="weight"
value={FormatWeight(performer.weight)}
fullWidth={fullWidth}
/>
<DetailItem
id="penis_length"
value={FormatPenisLength(performer.penis_length)}
fullWidth={fullWidth}
/>
<DetailItem
id="circumcised"
value={FormatCircumcised(performer.circumcised)}
fullWidth={fullWidth}
/>
<DetailItem
id="measurements"
value={performer?.measurements}
fullWidth={fullWidth}
/>
<DetailItem
id="fake_tits"
value={performer?.fake_tits}
fullWidth={fullWidth}
/>
{maybeRenderExtraDetails()}
</PerformerDetailGroup>
);
});
export const CompressedPerformerDetailsPanel: React.FC<IPerformerDetails> =
PatchComponent("CompressedPerformerDetailsPanel", ({ performer }) => {
// Network state
const intl = useIntl();
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
return (
<div className="sticky detail-header">
<div className="sticky detail-header-group">
<a className="performer-name" onClick={() => scrollToTop()}>
{performer.name}
</a>
{performer.gender ? (
<>
<span className="detail-divider">/</span>
<span className="performer-gender">
{intl.formatMessage({ id: "gender_types." + performer.gender })}
</span>
</>
) : (
""
)}
{performer.birthdate ? (
<>
<span className="detail-divider">/</span>
<span
className="performer-age"
title={TextUtils.formatDate(
intl,
performer.birthdate ?? undefined
)}
>
{TextUtils.age(performer.birthdate, performer.death_date)}
</span>
</>
) : (
""
)}
{performer.country ? (
<>
<span className="detail-divider">/</span>
<span className="performer-country">
<CountryFlag
country={performer.country}
className="mr-2"
includeName={true}
/>
</span>
</>
) : (
""
)}
</div>
</div> </div>
</div> );
); });
};

View File

@@ -92,60 +92,58 @@ interface ISettingGroup {
collapsedDefault?: boolean; collapsedDefault?: boolean;
} }
export const SettingGroup: React.FC<PropsWithChildren<ISettingGroup>> = ({ export const SettingGroup: React.FC<PropsWithChildren<ISettingGroup>> =
settingProps, PatchComponent(
topLevel, "SettingGroup",
collapsible, ({ settingProps, topLevel, collapsible, collapsedDefault, children }) => {
collapsedDefault, const [open, setOpen] = useState(!collapsedDefault);
children,
}) => {
const [open, setOpen] = useState(!collapsedDefault);
function renderCollapseButton() { function renderCollapseButton() {
if (!collapsible) return; if (!collapsible) return;
return ( return (
<Button <Button
className="setting-group-collapse-button" className="setting-group-collapse-button"
variant="minimal" variant="minimal"
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
> >
<Icon className="fa-fw" icon={open ? faChevronUp : faChevronDown} /> <Icon className="fa-fw" icon={open ? faChevronUp : faChevronDown} />
</Button> </Button>
); );
}
function onDivClick(e: React.MouseEvent<HTMLDivElement>) {
if (!collapsible) return;
// ensure button was not clicked
let target: HTMLElement | null = e.target as HTMLElement;
while (target && target !== e.currentTarget) {
if (
target.nodeName.toLowerCase() === "button" ||
target.nodeName.toLowerCase() === "a"
) {
// button clicked, swallow event
return;
} }
target = target.parentElement;
function onDivClick(e: React.MouseEvent<HTMLDivElement>) {
if (!collapsible) return;
// ensure button was not clicked
let target: HTMLElement | null = e.target as HTMLElement;
while (target && target !== e.currentTarget) {
if (
target.nodeName.toLowerCase() === "button" ||
target.nodeName.toLowerCase() === "a"
) {
// button clicked, swallow event
return;
}
target = target.parentElement;
}
setOpen(!open);
}
return (
<div className={`setting-group ${collapsible ? "collapsible" : ""}`}>
<Setting {...settingProps} onClick={onDivClick}>
{topLevel}
{renderCollapseButton()}
</Setting>
<Collapse in={open}>
<div className="collapsible-section">{children}</div>
</Collapse>
</div>
);
} }
setOpen(!open);
}
return (
<div className={`setting-group ${collapsible ? "collapsible" : ""}`}>
<Setting {...settingProps} onClick={onDivClick}>
{topLevel}
{renderCollapseButton()}
</Setting>
<Collapse in={open}>
<div className="collapsible-section">{children}</div>
</Collapse>
</div>
); );
};
interface IBooleanSetting extends ISetting { interface IBooleanSetting extends ISetting {
id: string; id: string;
@@ -153,53 +151,52 @@ interface IBooleanSetting extends ISetting {
onChange: (v: boolean) => void; onChange: (v: boolean) => void;
} }
export const BooleanSetting: React.FC<IBooleanSetting> = (props) => { export const BooleanSetting: React.FC<IBooleanSetting> = PatchComponent(
const { id, disabled, checked, onChange, ...settingProps } = props; "BooleanSetting",
(props) => {
const { id, disabled, checked, onChange, ...settingProps } = props;
return ( return (
<Setting {...settingProps} disabled={disabled}> <Setting {...settingProps} disabled={disabled}>
<Form.Switch <Form.Switch
id={id} id={id}
disabled={disabled} disabled={disabled}
checked={checked ?? false} checked={checked ?? false}
onChange={() => onChange(!checked)} onChange={() => onChange(!checked)}
/> />
</Setting> </Setting>
); );
}; }
);
interface ISelectSetting extends ISetting { interface ISelectSetting extends ISetting {
value?: string | number | string[]; value?: string | number | string[];
onChange: (v: string) => void; onChange: (v: string) => void;
} }
export const SelectSetting: React.FC<PropsWithChildren<ISelectSetting>> = ({ export const SelectSetting: React.FC<PropsWithChildren<ISelectSetting>> =
id, PatchComponent(
headingID, "SelectSetting",
subHeadingID, ({ id, headingID, subHeadingID, value, children, onChange, advanced }) => {
value, return (
children, <Setting
onChange, advanced={advanced}
advanced, headingID={headingID}
}) => { subHeadingID={subHeadingID}
return ( id={id}
<Setting >
advanced={advanced} <Form.Control
headingID={headingID} className="input-control"
subHeadingID={subHeadingID} as="select"
id={id} value={value ?? ""}
> onChange={(e) => onChange(e.currentTarget.value)}
<Form.Control >
className="input-control" {children}
as="select" </Form.Control>
value={value ?? ""} </Setting>
onChange={(e) => onChange(e.currentTarget.value)} );
> }
{children}
</Form.Control>
</Setting>
); );
};
interface IDialogSetting<T> extends ISetting { interface IDialogSetting<T> extends ISetting {
buttonText?: string; buttonText?: string;
@@ -208,8 +205,7 @@ interface IDialogSetting<T> extends ISetting {
renderValue?: (v: T | undefined) => JSX.Element; renderValue?: (v: T | undefined) => JSX.Element;
onChange: () => void; onChange: () => void;
} }
const _ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
export const ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
const { const {
id, id,
className, className,
@@ -266,6 +262,11 @@ export const ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
); );
}; };
export const ChangeButtonSetting = PatchComponent(
"ChangeButtonSetting",
_ChangeButtonSetting
) as typeof _ChangeButtonSetting;
export interface ISettingModal<T> { export interface ISettingModal<T> {
heading?: React.ReactNode; heading?: React.ReactNode;
headingID?: string; headingID?: string;
@@ -283,7 +284,7 @@ export interface ISettingModal<T> {
error?: string | undefined; error?: string | undefined;
} }
export const SettingModal = <T extends {}>(props: ISettingModal<T>) => { const _SettingModal = <T extends {}>(props: ISettingModal<T>) => {
const { const {
heading, heading,
headingID, headingID,
@@ -342,6 +343,11 @@ export const SettingModal = <T extends {}>(props: ISettingModal<T>) => {
); );
}; };
export const SettingModal = PatchComponent(
"SettingModal",
_SettingModal
) as typeof _SettingModal;
interface IModalSetting<T> extends ISetting { interface IModalSetting<T> extends ISetting {
value: T | undefined; value: T | undefined;
buttonText?: string; buttonText?: string;
@@ -357,7 +363,7 @@ interface IModalSetting<T> extends ISetting {
validateChange?: (v: T) => void | undefined; validateChange?: (v: T) => void | undefined;
} }
export const ModalSetting = <T extends {}>(props: IModalSetting<T>) => { export const _ModalSetting = <T extends {}>(props: IModalSetting<T>) => {
const { const {
id, id,
className, className,
@@ -435,52 +441,63 @@ export const ModalSetting = <T extends {}>(props: IModalSetting<T>) => {
); );
}; };
export const ModalSetting = PatchComponent(
"ModalSetting",
_ModalSetting
) as typeof _ModalSetting;
interface IStringSetting extends ISetting { interface IStringSetting extends ISetting {
value: string | undefined; value: string | undefined;
onChange: (v: string) => void; onChange: (v: string) => void;
} }
export const StringSetting: React.FC<IStringSetting> = (props) => { export const StringSetting: React.FC<IStringSetting> = PatchComponent(
return ( "StringSetting",
<ModalSetting<string> (props) => {
{...props} return (
renderField={(value, setValue) => ( <ModalSetting<string>
<Form.Control {...props}
className="text-input" renderField={(value, setValue) => (
value={value ?? ""} <Form.Control
onChange={(e: React.ChangeEvent<HTMLInputElement>) => className="text-input"
setValue(e.currentTarget.value) value={value ?? ""}
} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
/> setValue(e.currentTarget.value)
)} }
renderValue={(value) => <span>{value}</span>} />
/> )}
); renderValue={(value) => <span>{value}</span>}
}; />
);
}
);
interface INumberSetting extends ISetting { interface INumberSetting extends ISetting {
value: number | undefined; value: number | undefined;
onChange: (v: number) => void; onChange: (v: number) => void;
} }
export const NumberSetting: React.FC<INumberSetting> = (props) => { export const NumberSetting: React.FC<INumberSetting> = PatchComponent(
return ( "NumberSetting",
<ModalSetting<number> (props) => {
{...props} return (
renderField={(value, setValue) => ( <ModalSetting<number>
<Form.Control {...props}
className="text-input" renderField={(value, setValue) => (
type="number" <Form.Control
value={value ?? 0} className="text-input"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => type="number"
setValue(Number.parseInt(e.currentTarget.value || "0", 10)) value={value ?? 0}
} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
/> setValue(Number.parseInt(e.currentTarget.value || "0", 10))
)} }
renderValue={(value) => <span>{value}</span>} />
/> )}
); renderValue={(value) => <span>{value}</span>}
}; />
);
}
);
interface IStringListSetting extends ISetting { interface IStringListSetting extends ISetting {
value: string[] | undefined; value: string[] | undefined;
@@ -488,35 +505,38 @@ interface IStringListSetting extends ISetting {
onChange: (v: string[]) => void; onChange: (v: string[]) => void;
} }
export const StringListSetting: React.FC<IStringListSetting> = (props) => { export const StringListSetting: React.FC<IStringListSetting> = PatchComponent(
return ( "StringListSetting",
<ModalSetting<string[]> (props) => {
{...props} return (
renderField={(value, setValue) => ( <ModalSetting<string[]>
<StringListInput {...props}
value={value ?? []} renderField={(value, setValue) => (
setValue={setValue} <StringListInput
placeholder={props.defaultNewValue} value={value ?? []}
/> setValue={setValue}
)} placeholder={props.defaultNewValue}
renderValue={(value) => ( />
<div> )}
{value?.map((v, i) => ( renderValue={(value) => (
// eslint-disable-next-line react/no-array-index-key <div>
<div key={i}>{v}</div> {value?.map((v, i) => (
))} // eslint-disable-next-line react/no-array-index-key
</div> <div key={i}>{v}</div>
)} ))}
/> </div>
); )}
}; />
);
}
);
interface IConstantSetting<T> extends ISetting { interface IConstantSetting<T> extends ISetting {
value?: T; value?: T;
renderValue?: (v: T | undefined) => JSX.Element; renderValue?: (v: T | undefined) => JSX.Element;
} }
export const ConstantSetting = <T extends {}>(props: IConstantSetting<T>) => { export const _ConstantSetting = <T extends {}>(props: IConstantSetting<T>) => {
const { id, headingID, subHeading, subHeadingID, renderValue, value } = props; const { id, headingID, subHeading, subHeadingID, renderValue, value } = props;
const intl = useIntl(); const intl = useIntl();
@@ -539,3 +559,8 @@ export const ConstantSetting = <T extends {}>(props: IConstantSetting<T>) => {
</div> </div>
); );
}; };
export const ConstantSetting = PatchComponent(
"ConstantSetting",
_ConstantSetting
) as typeof _ConstantSetting;

View File

@@ -27,6 +27,7 @@ import {
InstalledPluginPackages, InstalledPluginPackages,
} from "./PluginPackageManager"; } from "./PluginPackageManager";
import { ExternalLink } from "../Shared/ExternalLink"; import { ExternalLink } from "../Shared/ExternalLink";
import { PatchComponent } from "src/patch";
interface IPluginSettingProps { interface IPluginSettingProps {
pluginID: string; pluginID: string;
@@ -75,11 +76,38 @@ const PluginSetting: React.FC<IPluginSettingProps> = ({
} }
}; };
const PluginSettings: React.FC<{
pluginID: string;
settings: GQL.PluginSetting[];
}> = PatchComponent("PluginSettings", ({ pluginID, settings }) => {
const { plugins, savePluginSettings } = useSettings();
const pluginSettings = plugins[pluginID] ?? {};
return (
<div className="plugin-settings">
{settings.map((setting) => (
<PluginSetting
key={setting.name}
pluginID={pluginID}
setting={setting}
value={pluginSettings[setting.name]}
onChange={(v) =>
savePluginSettings(pluginID, {
...pluginSettings,
[setting.name]: v,
})
}
/>
))}
</div>
);
});
export const SettingsPluginsPanel: React.FC = () => { export const SettingsPluginsPanel: React.FC = () => {
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
const { loading: configLoading, plugins, savePluginSettings } = useSettings(); const { loading: configLoading } = useSettings();
const { data, loading } = usePlugins(); const { data, loading } = usePlugins();
const [changedPluginID, setChangedPluginID] = React.useState< const [changedPluginID, setChangedPluginID] = React.useState<
@@ -163,7 +191,10 @@ export const SettingsPluginsPanel: React.FC = () => {
} }
> >
{renderPluginHooks(plugin.hooks ?? undefined)} {renderPluginHooks(plugin.hooks ?? undefined)}
{renderPluginSettings(plugin.id, plugin.settings ?? [])} <PluginSettings
pluginID={plugin.id}
settings={plugin.settings ?? []}
/>
</SettingGroup> </SettingGroup>
)); ));
@@ -208,37 +239,8 @@ export const SettingsPluginsPanel: React.FC = () => {
); );
} }
function renderPluginSettings(
pluginID: string,
settings: GQL.PluginSetting[]
) {
const pluginSettings = plugins[pluginID] ?? {};
return settings.map((setting) => (
<PluginSetting
key={setting.name}
pluginID={pluginID}
setting={setting}
value={pluginSettings[setting.name]}
onChange={(v) =>
savePluginSettings(pluginID, {
...pluginSettings,
[setting.name]: v,
})
}
/>
));
}
return renderPlugins(); return renderPlugins();
}, [ }, [data?.plugins, intl, Toast, changedPluginID]);
data?.plugins,
intl,
Toast,
changedPluginID,
plugins,
savePluginSettings,
]);
if (loading || configLoading) return <LoadingIndicator />; if (loading || configLoading) return <LoadingIndicator />;

View File

@@ -139,6 +139,11 @@ Returns `void`.
#### Patchable components and functions #### Patchable components and functions
- `App`
- `BooleanSetting`
- `ChangeButtonSetting`
- `CompressedPerformerDetailsPanel`
- `ConstantSetting`
- `CountrySelect` - `CountrySelect`
- `DateInput` - `DateInput`
- `FolderSelect` - `FolderSelect`
@@ -146,9 +151,13 @@ Returns `void`.
- `GallerySelect` - `GallerySelect`
- `GallerySelect.sort` - `GallerySelect.sort`
- `Icon` - `Icon`
- `ModalSetting`
- `MovieIDSelect` - `MovieIDSelect`
- `MovieSelect` - `MovieSelect`
- `MovieSelect.sort` - `MovieSelect.sort`
- `NumberSetting`
- `PerformerDetailsPanel`
- `PerformerDetailsPanel.DetailGroup`
- `PerformerIDSelect` - `PerformerIDSelect`
- `PerformerSelect` - `PerformerSelect`
- `PerformerSelect.sort` - `PerformerSelect.sort`
@@ -161,13 +170,20 @@ Returns `void`.
- `SceneIDSelect` - `SceneIDSelect`
- `SceneSelect` - `SceneSelect`
- `SceneSelect.sort` - `SceneSelect.sort`
- `SelectSetting`
- `Setting` - `Setting`
- `SettingModal`
- `StringSetting`
- `StringListSetting`
- `StudioIDSelect` - `StudioIDSelect`
- `StudioSelect` - `StudioSelect`
- `StudioSelect.sort` - `StudioSelect.sort`
- `TagIDSelect` - `TagIDSelect`
- `TagSelect` - `TagSelect`
- `TagSelect.sort` - `TagSelect.sort`
- `PluginSettings`
- `Setting`
- `SettingGroup`
### `PluginApi.Event` ### `PluginApi.Event`

View File

@@ -681,7 +681,18 @@ declare namespace PluginApi {
"SceneCard.Details": React.FC<any>; "SceneCard.Details": React.FC<any>;
"SceneCard.Overlays": React.FC<any>; "SceneCard.Overlays": React.FC<any>;
"SceneCard.Image": React.FC<any>; "SceneCard.Image": React.FC<any>;
SceneCard: React.FC<any>; PluginSettings: React.FC<any>;
Setting: React.FC<any>;
SettingGroup: React.FC<any>;
BooleanSetting: React.FC<any>;
SelectSetting: React.FC<any>;
ChangeButtonSetting: React.FC<any>;
SettingModal: React.FC<any>;
ModalSetting: React.FC<any>;
StringSetting: React.FC<any>;
NumberSetting: React.FC<any>;
StringListSetting: React.FC<any>;
ConstantSetting: React.FC<any>;
}; };
namespace utils { namespace utils {
namespace NavUtils { namespace NavUtils {
@@ -922,6 +933,34 @@ declare namespace PluginApi {
success(message: JSX.Element | string): void; success(message: JSX.Element | string): void;
error(error: unknown): void; error(error: unknown): void;
}; };
function useSettings(): {
loading: boolean;
error: any | undefined;
general: any;
interface: any;
defaults: any;
scraping: any;
dlna: any;
ui: any;
plugins: any;
advancedMode: boolean;
// apikey isn't directly settable, so expose it here
apiKey: string;
saveGeneral: (input: any) => void;
saveInterface: (input: any) => void;
saveDefaults: (input: any) => void;
saveScraping: (input: any) => void;
saveDLNA: (input: any) => void;
saveUI: (input: any) => void;
savePluginSettings: (pluginID: string, input: {}) => void;
setAdvancedMode: (value: boolean) => void;
refetch: () => void;
};
} }
namespace patch { namespace patch {
function before(target: string, fn: Function): void; function before(target: string, fn: Function): void;

View File

@@ -15,6 +15,7 @@ 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";
import { before, instead, after, components, RegisterComponent } from "./patch"; import { before, instead, after, components, RegisterComponent } from "./patch";
import { useSettings } from "./components/Settings/context";
// due to code splitting, some components may not have been loaded when a plugin // due to code splitting, some components may not have been loaded when a plugin
// page is loaded. This function will load all components passed to it. // page is loaded. This function will load all components passed to it.
@@ -92,6 +93,7 @@ export const PluginApi = {
useLoadComponents, useLoadComponents,
useSpriteInfo, useSpriteInfo,
useToast, useToast,
useSettings,
}, },
patch: { patch: {
// intercept the arguments of supported functions // intercept the arguments of supported functions