mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +03:00
Settings UI refactor (#2086)
* 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
This commit is contained in:
467
ui/v2.5/src/components/Settings/Inputs.tsx
Normal file
467
ui/v2.5/src/components/Settings/Inputs.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user