mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
* Full width settings page * Group settings * Make config fields optional * auto save on change * Add settings context * Refactor stash library section * Restructure settings * Refactor tasks page * Add collapse buttons for setting groups * Add collapse buttons in library * Add loading indicator * Simplify task options. Add details to manual * Add manual links to tasks page * Add help tooltips * Refactor about page * Refactor log page * Refactor tools panel * Refactor plugin page * Refactor task queue * Improve disabled styling
468 lines
11 KiB
TypeScript
468 lines
11 KiB
TypeScript
import React, { useState } from "react";
|
|
import { Button, Collapse, Form, Modal, ModalProps } from "react-bootstrap";
|
|
import { FormattedMessage, useIntl } from "react-intl";
|
|
import { PropsWithChildren } from "react-router/node_modules/@types/react";
|
|
import { Icon } from "../Shared";
|
|
import { StringListInput } from "../Shared/StringListInput";
|
|
|
|
interface ISetting {
|
|
id?: string;
|
|
className?: string;
|
|
heading?: React.ReactNode;
|
|
headingID?: string;
|
|
subHeadingID?: string;
|
|
subHeading?: React.ReactNode;
|
|
tooltipID?: string;
|
|
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export const Setting: React.FC<PropsWithChildren<ISetting>> = ({
|
|
id,
|
|
className,
|
|
heading,
|
|
headingID,
|
|
subHeadingID,
|
|
subHeading,
|
|
children,
|
|
tooltipID,
|
|
onClick,
|
|
disabled,
|
|
}) => {
|
|
const intl = useIntl();
|
|
|
|
function renderHeading() {
|
|
if (headingID) {
|
|
return intl.formatMessage({ id: headingID });
|
|
}
|
|
return heading;
|
|
}
|
|
|
|
function renderSubHeading() {
|
|
if (subHeadingID) {
|
|
return (
|
|
<div className="sub-heading">
|
|
{intl.formatMessage({ id: subHeadingID })}
|
|
</div>
|
|
);
|
|
}
|
|
if (subHeading) {
|
|
return <div className="sub-heading">{subHeading}</div>;
|
|
}
|
|
}
|
|
|
|
const tooltip = tooltipID ? intl.formatMessage({ id: tooltipID }) : undefined;
|
|
const disabledClassName = disabled ? "disabled" : "";
|
|
|
|
return (
|
|
<div
|
|
className={`setting ${className ?? ""} ${disabledClassName}`}
|
|
id={id}
|
|
onClick={onClick}
|
|
>
|
|
<div>
|
|
<h3 title={tooltip}>{renderHeading()}</h3>
|
|
{renderSubHeading()}
|
|
</div>
|
|
<div>{children}</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface ISettingGroup {
|
|
settingProps?: ISetting;
|
|
topLevel?: JSX.Element;
|
|
collapsible?: boolean;
|
|
collapsedDefault?: boolean;
|
|
}
|
|
|
|
export const SettingGroup: React.FC<PropsWithChildren<ISettingGroup>> = ({
|
|
settingProps,
|
|
topLevel,
|
|
collapsible,
|
|
collapsedDefault,
|
|
children,
|
|
}) => {
|
|
const [open, setOpen] = useState(!collapsedDefault);
|
|
|
|
function renderCollapseButton() {
|
|
if (!collapsible) return;
|
|
|
|
return (
|
|
<Button
|
|
className="setting-group-collapse-button"
|
|
variant="minimal"
|
|
onClick={() => setOpen(!open)}
|
|
>
|
|
<Icon className="fa-fw" icon={open ? "chevron-up" : "chevron-down"} />
|
|
</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;
|
|
}
|
|
|
|
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 {
|
|
id: string;
|
|
checked?: boolean;
|
|
onChange: (v: boolean) => void;
|
|
}
|
|
|
|
export const BooleanSetting: React.FC<IBooleanSetting> = (props) => {
|
|
const { id, disabled, checked, onChange, ...settingProps } = props;
|
|
|
|
return (
|
|
<Setting {...settingProps} disabled={disabled}>
|
|
<Form.Switch
|
|
id={id}
|
|
disabled={disabled}
|
|
checked={checked ?? false}
|
|
onChange={() => onChange(!checked)}
|
|
/>
|
|
</Setting>
|
|
);
|
|
};
|
|
|
|
interface ISelectSetting extends ISetting {
|
|
value?: string | number | string[] | undefined;
|
|
onChange: (v: string) => void;
|
|
}
|
|
|
|
export const SelectSetting: React.FC<PropsWithChildren<ISelectSetting>> = ({
|
|
id,
|
|
headingID,
|
|
subHeadingID,
|
|
value,
|
|
children,
|
|
onChange,
|
|
}) => {
|
|
return (
|
|
<Setting headingID={headingID} subHeadingID={subHeadingID} id={id}>
|
|
<Form.Control
|
|
className="input-control"
|
|
as="select"
|
|
value={value ?? ""}
|
|
onChange={(e) => onChange(e.currentTarget.value)}
|
|
>
|
|
{children}
|
|
</Form.Control>
|
|
</Setting>
|
|
);
|
|
};
|
|
|
|
interface IDialogSetting<T> extends ISetting {
|
|
buttonText?: string;
|
|
buttonTextID?: string;
|
|
value?: T;
|
|
renderValue?: (v: T | undefined) => JSX.Element;
|
|
onChange: () => void;
|
|
}
|
|
|
|
export const ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
|
|
const {
|
|
id,
|
|
className,
|
|
headingID,
|
|
subHeadingID,
|
|
subHeading,
|
|
value,
|
|
onChange,
|
|
renderValue,
|
|
buttonText,
|
|
buttonTextID,
|
|
disabled,
|
|
} = props;
|
|
const intl = useIntl();
|
|
|
|
const disabledClassName = disabled ? "disabled" : "";
|
|
|
|
return (
|
|
<div className={`setting ${className ?? ""} ${disabledClassName}`} id={id}>
|
|
<div>
|
|
<h3>{headingID ? intl.formatMessage({ id: headingID }) : undefined}</h3>
|
|
|
|
<div className="value">
|
|
{renderValue ? renderValue(value) : undefined}
|
|
</div>
|
|
|
|
{subHeadingID ? (
|
|
<div className="sub-heading">
|
|
{intl.formatMessage({ id: subHeadingID })}
|
|
</div>
|
|
) : subHeading ? (
|
|
<div className="sub-heading">{subHeading}</div>
|
|
) : undefined}
|
|
</div>
|
|
<div>
|
|
<Button onClick={() => onChange()} disabled={disabled}>
|
|
{buttonText ? (
|
|
buttonText
|
|
) : (
|
|
<FormattedMessage id={buttonTextID ?? "actions.edit"} />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export interface ISettingModal<T> {
|
|
heading?: string;
|
|
headingID?: string;
|
|
subHeadingID?: string;
|
|
subHeading?: React.ReactNode;
|
|
value: T | undefined;
|
|
close: (v?: T) => void;
|
|
renderField: (value: T | undefined, setValue: (v?: T) => void) => JSX.Element;
|
|
modalProps?: ModalProps;
|
|
}
|
|
|
|
export const SettingModal = <T extends {}>(props: ISettingModal<T>) => {
|
|
const {
|
|
heading,
|
|
headingID,
|
|
subHeading,
|
|
subHeadingID,
|
|
value,
|
|
close,
|
|
renderField,
|
|
modalProps,
|
|
} = props;
|
|
|
|
const intl = useIntl();
|
|
const [currentValue, setCurrentValue] = useState<T | undefined>(value);
|
|
|
|
return (
|
|
<Modal show onHide={() => close()} id="setting-dialog" {...modalProps}>
|
|
<Form
|
|
onSubmit={(e) => {
|
|
close(currentValue);
|
|
e.preventDefault();
|
|
}}
|
|
>
|
|
<Modal.Header closeButton>
|
|
{headingID ? <FormattedMessage id={headingID} /> : heading}
|
|
</Modal.Header>
|
|
<Modal.Body>
|
|
{renderField(currentValue, setCurrentValue)}
|
|
{subHeadingID ? (
|
|
<div className="sub-heading">
|
|
{intl.formatMessage({ id: subHeadingID })}
|
|
</div>
|
|
) : subHeading ? (
|
|
<div className="sub-heading">{subHeading}</div>
|
|
) : undefined}
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
<Button variant="secondary" onClick={() => close()}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
onClick={() => close(currentValue)}
|
|
>
|
|
Confirm
|
|
</Button>
|
|
</Modal.Footer>
|
|
</Form>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
interface IModalSetting<T> extends ISetting {
|
|
value: T | undefined;
|
|
buttonText?: string;
|
|
buttonTextID?: string;
|
|
onChange: (v: T) => void;
|
|
renderField: (value: T | undefined, setValue: (v?: T) => void) => JSX.Element;
|
|
renderValue?: (v: T | undefined) => JSX.Element;
|
|
modalProps?: ModalProps;
|
|
}
|
|
|
|
export const ModalSetting = <T extends {}>(props: IModalSetting<T>) => {
|
|
const {
|
|
id,
|
|
className,
|
|
value,
|
|
headingID,
|
|
subHeadingID,
|
|
subHeading,
|
|
onChange,
|
|
renderField,
|
|
renderValue,
|
|
buttonText,
|
|
buttonTextID,
|
|
modalProps,
|
|
disabled,
|
|
} = props;
|
|
const [showModal, setShowModal] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
{showModal ? (
|
|
<SettingModal<T>
|
|
headingID={headingID}
|
|
subHeadingID={subHeadingID}
|
|
subHeading={subHeading}
|
|
value={value}
|
|
renderField={renderField}
|
|
close={(v) => {
|
|
if (v !== undefined) onChange(v);
|
|
setShowModal(false);
|
|
}}
|
|
{...modalProps}
|
|
/>
|
|
) : undefined}
|
|
|
|
<ChangeButtonSetting<T>
|
|
id={id}
|
|
className={className}
|
|
disabled={disabled}
|
|
buttonText={buttonText}
|
|
buttonTextID={buttonTextID}
|
|
headingID={headingID}
|
|
subHeadingID={subHeadingID}
|
|
subHeading={subHeading}
|
|
value={value}
|
|
onChange={() => setShowModal(true)}
|
|
renderValue={renderValue}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
interface IStringSetting extends ISetting {
|
|
value: string | undefined;
|
|
onChange: (v: string) => void;
|
|
}
|
|
|
|
export const StringSetting: React.FC<IStringSetting> = (props) => {
|
|
return (
|
|
<ModalSetting<string>
|
|
{...props}
|
|
renderField={(value, setValue) => (
|
|
<Form.Control
|
|
className="text-input"
|
|
value={value ?? ""}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
setValue(e.currentTarget.value)
|
|
}
|
|
/>
|
|
)}
|
|
renderValue={(value) => <span>{value}</span>}
|
|
/>
|
|
);
|
|
};
|
|
|
|
interface INumberSetting extends ISetting {
|
|
value: number | undefined;
|
|
onChange: (v: number) => void;
|
|
}
|
|
|
|
export const NumberSetting: React.FC<INumberSetting> = (props) => {
|
|
return (
|
|
<ModalSetting<number>
|
|
{...props}
|
|
renderField={(value, setValue) => (
|
|
<Form.Control
|
|
className="text-input"
|
|
type="number"
|
|
value={value ?? 0}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
setValue(Number.parseInt(e.currentTarget.value || "0", 10))
|
|
}
|
|
/>
|
|
)}
|
|
renderValue={(value) => <span>{value}</span>}
|
|
/>
|
|
);
|
|
};
|
|
|
|
interface IStringListSetting extends ISetting {
|
|
value: string[] | undefined;
|
|
defaultNewValue?: string;
|
|
onChange: (v: string[]) => void;
|
|
}
|
|
|
|
export const StringListSetting: React.FC<IStringListSetting> = (props) => {
|
|
return (
|
|
<ModalSetting<string[]>
|
|
{...props}
|
|
renderField={(value, setValue) => (
|
|
<StringListInput
|
|
value={value ?? []}
|
|
setValue={setValue}
|
|
defaultNewValue={props.defaultNewValue}
|
|
/>
|
|
)}
|
|
renderValue={(value) => (
|
|
<div>
|
|
{value?.map((v, i) => (
|
|
// eslint-disable-next-line react/no-array-index-key
|
|
<div key={i}>{v}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
interface IConstantSetting<T> extends ISetting {
|
|
value?: T;
|
|
renderValue?: (v: T | undefined) => JSX.Element;
|
|
}
|
|
|
|
export const ConstantSetting = <T extends {}>(props: IConstantSetting<T>) => {
|
|
const { id, headingID, subHeading, subHeadingID, renderValue, value } = props;
|
|
const intl = useIntl();
|
|
|
|
return (
|
|
<div className="setting" id={id}>
|
|
<div>
|
|
<h3>{headingID ? intl.formatMessage({ id: headingID }) : undefined}</h3>
|
|
|
|
<div className="value">{renderValue ? renderValue(value) : value}</div>
|
|
|
|
{subHeadingID ? (
|
|
<div className="sub-heading">
|
|
{intl.formatMessage({ id: subHeadingID })}
|
|
</div>
|
|
) : subHeading ? (
|
|
<div className="sub-heading">{subHeading}</div>
|
|
) : undefined}
|
|
</div>
|
|
<div />
|
|
</div>
|
|
);
|
|
};
|