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:
WithoutPants
2021-12-14 15:06:05 +11:00
committed by GitHub
parent b4b955efc8
commit d176e9f192
44 changed files with 3540 additions and 3022 deletions

View File

@@ -61,7 +61,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
cssEnabled
language
slideshowDelay
disabledDropdownCreate {
disableDropdownCreate {
performer
tag
studio

View File

@@ -44,13 +44,13 @@ input ConfigGeneralInput {
"""Path to cache"""
cachePath: String
"""Whether to calculate MD5 checksums for scene video files"""
calculateMD5: Boolean!
calculateMD5: Boolean
"""Hash algorithm to use for generated file naming"""
videoFileNamingAlgorithm: HashAlgorithm!
videoFileNamingAlgorithm: HashAlgorithm
"""Number of parallel tasks to start during scan/generate"""
parallelTasks: Int
"""Include audio stream in previews"""
previewAudio: Boolean!
previewAudio: Boolean
"""Number of segments in a preview file"""
previewSegments: Int
"""Preview segment duration, in seconds"""
@@ -78,13 +78,13 @@ input ConfigGeneralInput {
"""Name of the log file"""
logFile: String
"""Whether to also output to stderr"""
logOut: Boolean!
logOut: Boolean
"""Minimum log level"""
logLevel: String!
logLevel: String
"""Whether to log http access"""
logAccess: Boolean!
logAccess: Boolean
"""True if galleries should be created from folders with images"""
createGalleriesFromFolders: Boolean!
createGalleriesFromFolders: Boolean
"""Array of video file extensions"""
videoExtensions: [String!]
"""Array of image file extensions"""
@@ -104,7 +104,7 @@ input ConfigGeneralInput {
"""Whether the scraper should check for invalid certificates"""
scraperCertCheck: Boolean @deprecated(reason: "use mutation ConfigureScraping(input: ConfigScrapingInput) instead")
"""Stash-box instances used for tagging"""
stashBoxes: [StashBoxInput!]!
stashBoxes: [StashBoxInput!]
}
type ConfigGeneralResult {
@@ -282,7 +282,8 @@ type ConfigInterfaceResult {
slideshowDelay: Int
"""Fields are true if creating via dropdown menus are disabled"""
disabledDropdownCreate: ConfigDisableDropdownCreate!
disableDropdownCreate: ConfigDisableDropdownCreate!
disabledDropdownCreate: ConfigDisableDropdownCreate! @deprecated(reason: "Use disableDropdownCreate")
"""Handy Connection Key"""
handyKey: String
@@ -316,7 +317,7 @@ input ConfigScrapingInput {
"""Scraper CDP path. Path to chrome executable or remote address"""
scraperCDPPath: String
"""Whether the scraper should check for invalid certificates"""
scraperCertCheck: Boolean!
scraperCertCheck: Boolean
"""Tags blacklist during scraping"""
excludeTagPatterns: [String!]
}

View File

@@ -110,26 +110,34 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
c.Set(config.Cache, input.CachePath)
}
if !input.CalculateMd5 && input.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {
if input.VideoFileNamingAlgorithm != nil && *input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() {
calculateMD5 := c.IsCalculateMD5()
if input.CalculateMd5 != nil {
calculateMD5 = *input.CalculateMd5
}
if !calculateMD5 && *input.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {
return makeConfigGeneralResult(), errors.New("calculateMD5 must be true if using MD5")
}
if input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() {
// validate changing VideoFileNamingAlgorithm
if err := manager.ValidateVideoFileNamingAlgorithm(r.txnManager, input.VideoFileNamingAlgorithm); err != nil {
if err := manager.ValidateVideoFileNamingAlgorithm(r.txnManager, *input.VideoFileNamingAlgorithm); err != nil {
return makeConfigGeneralResult(), err
}
c.Set(config.VideoFileNamingAlgorithm, input.VideoFileNamingAlgorithm)
c.Set(config.VideoFileNamingAlgorithm, *input.VideoFileNamingAlgorithm)
}
c.Set(config.CalculateMD5, input.CalculateMd5)
if input.CalculateMd5 != nil {
c.Set(config.CalculateMD5, *input.CalculateMd5)
}
if input.ParallelTasks != nil {
c.Set(config.ParallelTasks, *input.ParallelTasks)
}
c.Set(config.PreviewAudio, input.PreviewAudio)
if input.PreviewAudio != nil {
c.Set(config.PreviewAudio, *input.PreviewAudio)
}
if input.PreviewSegments != nil {
c.Set(config.PreviewSegments, *input.PreviewSegments)
@@ -185,12 +193,17 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
c.Set(config.LogFile, input.LogFile)
}
c.Set(config.LogOut, input.LogOut)
c.Set(config.LogAccess, input.LogAccess)
if input.LogOut != nil {
c.Set(config.LogOut, *input.LogOut)
}
if input.LogLevel != c.GetLogLevel() {
if input.LogAccess != nil {
c.Set(config.LogAccess, *input.LogAccess)
}
if input.LogLevel != nil && *input.LogLevel != c.GetLogLevel() {
c.Set(config.LogLevel, input.LogLevel)
logger.SetLogLevel(input.LogLevel)
logger.SetLogLevel(*input.LogLevel)
}
if input.Excludes != nil {
@@ -213,7 +226,9 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
c.Set(config.GalleryExtensions, input.GalleryExtensions)
}
if input.CreateGalleriesFromFolders != nil {
c.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders)
}
if input.CustomPerformerImageLocation != nil {
c.Set(config.CustomPerformerImageLocation, *input.CustomPerformerImageLocation)
@@ -293,14 +308,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
c.Set(config.SlideshowDelay, *input.SlideshowDelay)
}
css := ""
if input.CSS != nil {
css = *input.CSS
c.SetCSS(*input.CSS)
}
c.SetCSS(css)
setBool(config.CSSEnabled, input.CSSEnabled)
if input.DisableDropdownCreate != nil {
@@ -332,7 +343,9 @@ func (r *mutationResolver) ConfigureDlna(ctx context.Context, input models.Confi
c.Set(config.DLNAServerName, *input.ServerName)
}
if input.WhitelistedIPs != nil {
c.Set(config.DLNADefaultIPWhitelist, input.WhitelistedIPs)
}
currentDLNAEnabled := c.GetDLNADefaultEnabled()
if input.Enabled != nil && *input.Enabled != currentDLNAEnabled {
@@ -349,7 +362,9 @@ func (r *mutationResolver) ConfigureDlna(ctx context.Context, input models.Confi
}
}
if input.Interfaces != nil {
c.Set(config.DLNAInterfaces, input.Interfaces)
}
if err := c.Write(); err != nil {
return makeConfigDLNAResult(), err
@@ -376,7 +391,10 @@ func (r *mutationResolver) ConfigureScraping(ctx context.Context, input models.C
c.Set(config.ScraperExcludeTagPatterns, input.ExcludeTagPatterns)
}
if input.ScraperCertCheck != nil {
c.Set(config.ScraperCertCheck, input.ScraperCertCheck)
}
if refreshScraperCache {
manager.GetInstance().RefreshScraperCache()
}

View File

@@ -121,6 +121,9 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
handyKey := config.GetHandyKey()
scriptOffset := config.GetFunscriptOffset()
// FIXME - misnamed output field means we have redundant fields
disableDropdownCreate := config.GetDisableDropdownCreate()
return &models.ConfigInterfaceResult{
MenuItems: menuItems,
SoundOnPreview: &soundOnPreview,
@@ -136,7 +139,11 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
CSSEnabled: &cssEnabled,
Language: &language,
SlideshowDelay: &slideshowDelay,
DisabledDropdownCreate: config.GetDisableDropdownCreate(),
// FIXME - see above
DisabledDropdownCreate: disableDropdownCreate,
DisableDropdownCreate: disableDropdownCreate,
HandyKey: &handyKey,
FunscriptOffset: &scriptOffset,
}

View File

@@ -33,6 +33,7 @@ import { Migrate } from "./components/Setup/Migrate";
import * as GQL from "./core/generated-graphql";
import { LoadingIndicator, TITLE_SUFFIX } from "./components/Shared";
import { ConfigurationProvider } from "./hooks/Config";
import { ManualProvider } from "./components/Help/Manual";
initPolyfills();
@@ -146,12 +147,14 @@ export const App: React.FC = () => {
>
<ToastProvider>
<LightboxProvider>
<ManualProvider>
<Helmet
titleTemplate={`%s ${TITLE_SUFFIX}`}
defaultTitle="Stash"
/>
{maybeRenderNavbar()}
<div className="main container-fluid">{renderContent()}</div>
</ManualProvider>
</LightboxProvider>
</ToastProvider>
</ConfigurationProvider>

View File

@@ -5,6 +5,7 @@
* Add forward jump 10 second button to video player. ([#1973](https://github.com/stashapp/stash/pull/1973))
### 🎨 Improvements
* Overhauled, restructured and added auto-save to the settings pages. ([#2086](https://github.com/stashapp/stash/pull/2086))
* Include path and hashes in destroy scene/image/gallery post hook input. ([#2102](https://github.com/stashapp/stash/pull/2102/files))
* Rollback operation if files fail to be deleted. ([#1954](https://github.com/stashapp/stash/pull/1954))
* Prefer right-most Studio match in the file path when autotagging. ([#2057](https://github.com/stashapp/stash/pull/2057))

View File

@@ -13,6 +13,7 @@ import { ConfigurationContext } from "src/hooks/Config";
import { Manual } from "../Help/Manual";
import { withoutTypename } from "src/utils";
import { GenerateOptions } from "../Settings/Tasks/GenerateOptions";
import { SettingSection } from "../Settings/SettingSection";
interface ISceneGenerateDialog {
selectedIds?: string[];
@@ -276,7 +277,9 @@ export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
>
<Form>
{selectionStatus}
<SettingSection>
<GenerateOptions options={options} setOptions={setOptions} />
</SettingSection>
</Form>
</Modal>
);

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, PropsWithChildren, useEffect } from "react";
import { Modal, Container, Row, Col, Nav, Tab } from "react-bootstrap";
import Introduction from "src/docs/en/Introduction.md";
import Tasks from "src/docs/en/Tasks.md";
@@ -155,6 +155,12 @@ export const Manual: React.FC<IManualProps> = ({
defaultActiveTab ?? content[0].key
);
useEffect(() => {
if (defaultActiveTab) {
setActiveTab(defaultActiveTab);
}
}, [defaultActiveTab]);
// links to other manual pages are specified as "/help/page.md"
// intercept clicks to these pages and set the tab accordingly
function interceptLinkClick(
@@ -226,3 +232,63 @@ export const Manual: React.FC<IManualProps> = ({
</Modal>
);
};
interface IManualContextState {
openManual: (tab?: string) => void;
}
export const ManualStateContext = React.createContext<IManualContextState>({
openManual: () => {},
});
export const ManualProvider: React.FC = ({ children }) => {
const [showManual, setShowManual] = useState(false);
const [manualLink, setManualLink] = useState<string | undefined>();
function openManual(tab?: string) {
setManualLink(tab);
setShowManual(true);
}
useEffect(() => {
if (manualLink) setManualLink(undefined);
}, [manualLink]);
return (
<ManualStateContext.Provider
value={{
openManual,
}}
>
<Manual
show={showManual}
onClose={() => setShowManual(false)}
defaultActiveTab={manualLink}
/>
{children}
</ManualStateContext.Provider>
);
};
interface IManualLink {
tab: string;
}
export const ManualLink: React.FC<PropsWithChildren<IManualLink>> = ({
tab,
children,
}) => {
const { openManual } = React.useContext(ManualStateContext);
return (
<a
href={`/help/${tab}.md`}
onClick={(e) => {
openManual(`${tab}.md`);
e.preventDefault();
}}
>
{children}
</a>
);
};

View File

@@ -14,7 +14,7 @@ import Mousetrap from "mousetrap";
import { SessionUtils } from "src/utils";
import { Icon } from "src/components/Shared";
import { ConfigurationContext } from "src/hooks/Config";
import { Manual } from "./Help/Manual";
import { ManualStateContext } from "./Help/Manual";
import { SettingsButton } from "./SettingsButton";
interface IMenuItem {
@@ -141,12 +141,12 @@ export const MainNavbar: React.FC = () => {
const history = useHistory();
const location = useLocation();
const { configuration, loading } = React.useContext(ConfigurationContext);
const { openManual } = React.useContext(ManualStateContext);
// Show all menu items by default, unless config says otherwise
const [menuItems, setMenuItems] = useState<IMenuItem[]>(allMenuItems);
const [expanded, setExpanded] = useState(false);
const [showManual, setShowManual] = useState(false);
useEffect(() => {
const iCfg = configuration?.interface;
@@ -203,7 +203,7 @@ export const MainNavbar: React.FC = () => {
// set up hotkeys
useEffect(() => {
Mousetrap.bind("?", () => setShowManual(!showManual));
Mousetrap.bind("?", () => openManual());
Mousetrap.bind("g z", () => goto("/settings"));
menuItems.forEach((item) =>
@@ -267,7 +267,7 @@ export const MainNavbar: React.FC = () => {
</NavLink>
<Button
className="nav-utility minimal"
onClick={() => setShowManual(true)}
onClick={() => openManual()}
title="Help"
>
<Icon icon="question-circle" />
@@ -279,7 +279,6 @@ export const MainNavbar: React.FC = () => {
return (
<>
<Manual show={showManual} onClose={() => setShowManual(false)} />
<Navbar
collapseOnSelect
fixed="top"

View File

@@ -0,0 +1,133 @@
import React from "react";
import { useIntl } from "react-intl";
import { Form } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
export type VideoPreviewSettingsInput = Pick<
GQL.ConfigGeneralInput,
| "previewSegments"
| "previewSegmentDuration"
| "previewExcludeStart"
| "previewExcludeEnd"
>;
interface IVideoPreviewInput {
value: VideoPreviewSettingsInput;
setValue: (v: VideoPreviewSettingsInput) => void;
}
export const VideoPreviewInput: React.FC<IVideoPreviewInput> = ({
value,
setValue,
}) => {
const intl = useIntl();
function set(v: Partial<VideoPreviewSettingsInput>) {
setValue({
...value,
...v,
});
}
const {
previewSegments,
previewSegmentDuration,
previewExcludeStart,
previewExcludeEnd,
} = value;
return (
<div>
<Form.Group id="preview-segments">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_head",
})}
</h6>
<Form.Control
className="text-input"
type="number"
value={previewSegments?.toString() ?? 0}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
set({
previewSegments: Number.parseInt(
e.currentTarget.value || "0",
10
),
})
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-segment-duration">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_duration_head",
})}
</h6>
<Form.Control
className="text-input"
type="number"
value={previewSegmentDuration?.toString() ?? 0}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
set({
previewSegmentDuration: Number.parseFloat(
e.currentTarget.value || "0"
),
})
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_duration_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-exclude-start">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_start_time_head",
})}
</h6>
<Form.Control
className="text-input"
value={previewExcludeStart ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
set({ previewExcludeStart: e.currentTarget.value })
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_start_time_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-exclude-start">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_end_time_head",
})}
</h6>
<Form.Control
className="text-input"
value={previewExcludeEnd ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
set({ previewExcludeEnd: e.currentTarget.value })
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_end_time_desc",
})}
</Form.Text>
</Form.Group>
</div>
);
};

View 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>
);
};

View File

@@ -0,0 +1,31 @@
import React from "react";
import { Card } from "react-bootstrap";
import { useIntl } from "react-intl";
import { PropsWithChildren } from "react-router/node_modules/@types/react";
interface ISettingGroup {
id?: string;
headingID?: string;
subHeadingID?: string;
}
export const SettingSection: React.FC<PropsWithChildren<ISettingGroup>> = ({
id,
children,
headingID,
subHeadingID,
}) => {
const intl = useIntl();
return (
<div className="setting-section" id={id}>
<h1>{headingID ? intl.formatMessage({ id: headingID }) : undefined}</h1>
{subHeadingID ? (
<div className="sub-heading">
{intl.formatMessage({ id: subHeadingID })}
</div>
) : undefined}
<Card>{children}</Card>
</div>
);
};

View File

@@ -1,19 +1,22 @@
import React from "react";
import queryString from "query-string";
import { Card, Tab, Nav, Row, Col } from "react-bootstrap";
import { Tab, Nav, Row, Col } from "react-bootstrap";
import { useHistory, useLocation } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "src/components/Shared";
import { SettingsAboutPanel } from "./SettingsAboutPanel";
import { SettingsConfigurationPanel } from "./SettingsConfigurationPanel";
import { SettingsConfigurationPanel } from "./SettingsSystemPanel";
import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel";
import { SettingsLogsPanel } from "./SettingsLogsPanel";
import { SettingsTasksPanel } from "./Tasks/SettingsTasksPanel";
import { SettingsPluginsPanel } from "./SettingsPluginsPanel";
import { SettingsScrapingPanel } from "./SettingsScrapingPanel";
import { SettingsToolsPanel } from "./SettingsToolsPanel";
import { SettingsDLNAPanel } from "./SettingsDLNAPanel";
import { SettingsServicesPanel } from "./SettingsServicesPanel";
import { SettingsContext } from "./context";
import { SettingsLibraryPanel } from "./SettingsLibraryPanel";
import { SettingsSecurityPanel } from "./SettingsSecurityPanel";
export const Settings: React.FC = () => {
const intl = useIntl();
@@ -27,22 +30,26 @@ export const Settings: React.FC = () => {
id: "settings",
})} ${TITLE_SUFFIX}`;
return (
<Card className="col col-lg-9 mx-auto">
<Helmet
defaultTitle={title_template}
titleTemplate={`%s | ${title_template}`}
/>
<Tab.Container
activeKey={defaultTab}
id="configuration-tabs"
onSelect={(tab) => tab && onSelect(tab)}
>
<Helmet
defaultTitle={title_template}
titleTemplate={`%s | ${title_template}`}
/>
<Row>
<Col sm={3} md={2}>
<Col id="settings-menu-container" sm={3} md={3} xl={2}>
<Nav variant="pills" className="flex-column">
<Nav.Item>
<Nav.Link eventKey="configuration">
<FormattedMessage id="configuration" />
<Nav.Link eventKey="tasks">
<FormattedMessage id="config.categories.tasks" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="library">
<FormattedMessage id="library" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
@@ -51,21 +58,23 @@ export const Settings: React.FC = () => {
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="tasks">
<FormattedMessage id="config.categories.tasks" />
<Nav.Link eventKey="security">
<FormattedMessage id="config.categories.security" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="dlna">DLNA</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="tools">
<FormattedMessage id="config.categories.tools" />
<Nav.Link eventKey="metadata-providers">
<FormattedMessage id="config.categories.metadata_providers" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="scraping">
<FormattedMessage id="config.categories.scraping" />
<Nav.Link eventKey="services">
<FormattedMessage id="config.categories.services" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="system">
<FormattedMessage id="config.categories.system" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
@@ -78,6 +87,11 @@ export const Settings: React.FC = () => {
<FormattedMessage id="config.categories.logs" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="tools">
<FormattedMessage id="config.categories.tools" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="about">
<FormattedMessage id="config.categories.about" />
@@ -86,26 +100,38 @@ export const Settings: React.FC = () => {
<hr className="d-sm-none" />
</Nav>
</Col>
<Col sm={9} md={10}>
<Tab.Content>
<Tab.Pane eventKey="configuration">
<SettingsConfigurationPanel />
<Col
id="settings-container"
sm={{ offset: 3 }}
md={{ offset: 3 }}
xl={{ offset: 2 }}
>
<SettingsContext>
<Tab.Content className="mx-auto">
<Tab.Pane eventKey="library">
<SettingsLibraryPanel />
</Tab.Pane>
<Tab.Pane eventKey="interface">
<SettingsInterfacePanel />
</Tab.Pane>
<Tab.Pane eventKey="security">
<SettingsSecurityPanel />
</Tab.Pane>
<Tab.Pane eventKey="tasks">
<SettingsTasksPanel />
</Tab.Pane>
<Tab.Pane eventKey="dlna" unmountOnExit>
<SettingsDLNAPanel />
<Tab.Pane eventKey="services" unmountOnExit>
<SettingsServicesPanel />
</Tab.Pane>
<Tab.Pane eventKey="tools" unmountOnExit>
<SettingsToolsPanel />
</Tab.Pane>
<Tab.Pane eventKey="scraping" unmountOnExit>
<Tab.Pane eventKey="metadata-providers" unmountOnExit>
<SettingsScrapingPanel />
</Tab.Pane>
<Tab.Pane eventKey="system">
<SettingsConfigurationPanel />
</Tab.Pane>
<Tab.Pane eventKey="plugins" unmountOnExit>
<SettingsPluginsPanel />
</Tab.Pane>
@@ -116,9 +142,9 @@ export const Settings: React.FC = () => {
<SettingsAboutPanel />
</Tab.Pane>
</Tab.Content>
</SettingsContext>
</Col>
</Row>
</Tab.Container>
</Card>
);
};

View File

@@ -1,8 +1,9 @@
import React from "react";
import { Button, Table } from "react-bootstrap";
import { Button } from "react-bootstrap";
import { useIntl } from "react-intl";
import { LoadingIndicator } from "src/components/Shared";
import { useLatestVersion } from "src/core/StashService";
import { ConstantSetting, Setting, SettingGroup } from "./Inputs";
import { SettingSection } from "./SettingSection";
export const SettingsAboutPanel: React.FC = () => {
const gitHash = import.meta.env.VITE_APP_GITHASH;
@@ -19,95 +20,73 @@ export const SettingsAboutPanel: React.FC = () => {
networkStatus,
} = useLatestVersion();
function maybeRenderTag() {
if (!stashVersion) {
return;
}
return (
<tr>
<td>{intl.formatMessage({ id: "config.about.version" })}:</td>
<td>{stashVersion}</td>
</tr>
);
}
const hasNew = dataLatest && gitHash !== dataLatest.latestversion.shorthash;
function maybeRenderLatestVersion() {
if (
!dataLatest?.latestversion.shorthash ||
!dataLatest?.latestversion.url
) {
return;
}
if (gitHash !== dataLatest.latestversion.shorthash) {
return (
<>
<strong>
{dataLatest.latestversion.shorthash}{" "}
{intl.formatMessage({ id: "config.about.new_version_notice" })}{" "}
</strong>
<a href={dataLatest.latestversion.url}>
{intl.formatMessage({ id: "actions.download" })}
</a>
</>
);
}
<SettingSection headingID="config.about.version">
<SettingGroup
settingProps={{
heading: stashVersion,
}}
>
<ConstantSetting
headingID="config.about.build_hash"
value={gitHash}
/>
<ConstantSetting
headingID="config.about.build_time"
value={buildTime}
/>
</SettingGroup>
return <>{dataLatest.latestversion.shorthash}</>;
}
function renderLatestVersion() {
return (
<Table>
<tbody>
<tr>
<td>
<SettingGroup
settingProps={{
headingID: "config.about.latest_version",
}}
>
{errorLatest ? (
<Setting heading={errorLatest.message} />
) : !dataLatest || loadingLatest || networkStatus === 4 ? (
<Setting headingID="loading.generic" />
) : (
<div className="setting">
<div>
<h3>
{intl.formatMessage({
id: "config.about.latest_version_build_hash",
})}{" "}
</td>
<td>{maybeRenderLatestVersion()} </td>
</tr>
<tr>
<td>
})}
</h3>
<div className="value">
{dataLatest.latestversion.shorthash}{" "}
{hasNew
? intl.formatMessage({
id: "config.about.new_version_notice",
})
: undefined}
</div>
</div>
<div>
<a href={dataLatest.latestversion.url}>
<Button>
{intl.formatMessage({ id: "actions.download" })}
</Button>
</a>
<Button onClick={() => refetch()}>
{intl.formatMessage({
id: "config.about.check_for_new_version",
})}
</Button>
</td>
</tr>
</tbody>
</Table>
);
}
</div>
</div>
)}
</SettingGroup>
</SettingSection>
function renderVersion() {
return (
<>
<Table>
<tbody>
{maybeRenderTag()}
<tr>
<td>{intl.formatMessage({ id: "config.about.build_hash" })}</td>
<td>{gitHash}</td>
</tr>
<tr>
<td>{intl.formatMessage({ id: "config.about.build_time" })}</td>
<td>{buildTime}</td>
</tr>
</tbody>
</Table>
</>
);
}
return (
<>
<h4>{intl.formatMessage({ id: "config.categories.about" })}</h4>
<Table>
<tbody>
<tr>
<td>
<SettingSection headingID="config.categories.about">
<div className="setting">
<div>
<p>
{intl.formatMessage(
{ id: "config.about.stash_home" },
{
@@ -122,10 +101,8 @@ export const SettingsAboutPanel: React.FC = () => {
),
}
)}
</td>
</tr>
<tr>
<td>
</p>
<p>
{intl.formatMessage(
{ id: "config.about.stash_wiki" },
{
@@ -140,10 +117,8 @@ export const SettingsAboutPanel: React.FC = () => {
),
}
)}
</td>
</tr>
<tr>
<td>
</p>
<p>
{intl.formatMessage(
{ id: "config.about.stash_discord" },
{
@@ -158,10 +133,8 @@ export const SettingsAboutPanel: React.FC = () => {
),
}
)}
</td>
</tr>
<tr>
<td>
</p>
<p>
{intl.formatMessage(
{ id: "config.about.stash_open_collective" },
{
@@ -176,17 +149,11 @@ export const SettingsAboutPanel: React.FC = () => {
),
}
)}
</td>
</tr>
</tbody>
</Table>
{errorLatest && <span>{errorLatest.message}</span>}
{renderVersion()}
{!dataLatest || loadingLatest || networkStatus === 4 ? (
<LoadingIndicator inline />
) : (
renderLatestVersion()
)}
</p>
</div>
<div />
</div>
</SettingSection>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
import React from "react";
import { Form } from "react-bootstrap";
import { BooleanSetting } from "../Inputs";
interface IItem {
id: string;
label: string;
headingID: string;
}
interface ICheckboxGroupProps {
@@ -25,22 +25,20 @@ export const CheckboxGroup: React.FC<ICheckboxGroupProps> = ({
return (
<>
{items.map(({ id, label }) => (
<Form.Check
{items.map(({ id, headingID }) => (
<BooleanSetting
key={id}
type="checkbox"
id={generateId(id)}
label={label}
headingID={headingID}
checked={checkedIds.includes(id)}
onChange={(event) => {
const target = event.currentTarget;
if (target.checked) {
onChange={(v) => {
if (v) {
onChange?.(
items
.map((item) => item.id)
.filter(
(itemId) =>
generateId(itemId) === target.id ||
generateId(itemId) === generateId(id) ||
checkedIds.includes(itemId)
)
);
@@ -50,7 +48,7 @@ export const CheckboxGroup: React.FC<ICheckboxGroupProps> = ({
.map((item) => item.id)
.filter(
(itemId) =>
generateId(itemId) !== target.id &&
generateId(itemId) !== generateId(id) &&
checkedIds.includes(itemId)
)
);

View File

@@ -1,174 +1,48 @@
import React, { useEffect, useState } from "react";
import { Button, Form } from "react-bootstrap";
import React from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { DurationInput, LoadingIndicator } from "src/components/Shared";
import {
useConfiguration,
useConfigureDefaults,
useConfigureInterface,
} from "src/core/StashService";
import { useToast } from "src/hooks";
import * as GQL from "src/core/generated-graphql";
import { CheckboxGroup } from "./CheckboxGroup";
import { withoutTypename } from "src/utils";
import { SettingSection } from "../SettingSection";
import {
BooleanSetting,
ModalSetting,
NumberSetting,
SelectSetting,
StringSetting,
} from "../Inputs";
import { SettingStateContext } from "../context";
import { DurationUtils } from "src/utils";
const allMenuItems = [
{ id: "scenes", label: "Scenes" },
{ id: "images", label: "Images" },
{ id: "movies", label: "Movies" },
{ id: "markers", label: "Markers" },
{ id: "galleries", label: "Galleries" },
{ id: "performers", label: "Performers" },
{ id: "studios", label: "Studios" },
{ id: "tags", label: "Tags" },
{ id: "scenes", headingID: "scenes" },
{ id: "images", headingID: "images" },
{ id: "movies", headingID: "movies" },
{ id: "markers", headingID: "markers" },
{ id: "galleries", headingID: "galleries" },
{ id: "performers", headingID: "performers" },
{ id: "studios", headingID: "studios" },
{ id: "tags", headingID: "tags" },
];
const SECONDS_TO_MS = 1000;
export const SettingsInterfacePanel: React.FC = () => {
const intl = useIntl();
const Toast = useToast();
const { data: config, error, loading } = useConfiguration();
const [menuItemIds, setMenuItemIds] = useState<string[]>(
allMenuItems.map((item) => item.id)
const { interface: iface, saveInterface, loading, error } = React.useContext(
SettingStateContext
);
const [noBrowser, setNoBrowserFlag] = useState<boolean>(false);
const [soundOnPreview, setSoundOnPreview] = useState<boolean>(true);
const [wallShowTitle, setWallShowTitle] = useState<boolean>(true);
const [wallPlayback, setWallPlayback] = useState<string>("video");
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
const [autostartVideo, setAutostartVideo] = useState<boolean>(false);
const [
autostartVideoOnPlaySelected,
setAutostartVideoOnPlaySelected,
] = useState(true);
const [continuePlaylistDefault, setContinuePlaylistDefault] = useState(false);
const [slideshowDelay, setSlideshowDelay] = useState<number>(0);
const [showStudioAsText, setShowStudioAsText] = useState<boolean>(false);
const [css, setCSS] = useState<string>();
const [cssEnabled, setCSSEnabled] = useState<boolean>(false);
const [language, setLanguage] = useState<string>("en");
const [handyKey, setHandyKey] = useState<string>();
const [funscriptOffset, setFunscriptOffset] = useState<number>(0);
const [deleteFileDefault, setDeleteFileDefault] = useState<boolean>(false);
const [deleteGeneratedDefault, setDeleteGeneratedDefault] = useState<boolean>(
true
);
const [
disableDropdownCreate,
setDisableDropdownCreate,
] = useState<GQL.ConfigDisableDropdownCreateInput>({});
const [updateInterfaceConfig] = useConfigureInterface({
menuItems: menuItemIds,
soundOnPreview,
wallShowTitle,
wallPlayback,
maximumLoopDuration,
noBrowser,
autostartVideo,
autostartVideoOnPlaySelected,
continuePlaylistDefault,
showStudioAsText,
css,
cssEnabled,
language,
slideshowDelay,
handyKey,
funscriptOffset,
disableDropdownCreate,
});
const [updateDefaultsConfig] = useConfigureDefaults();
useEffect(() => {
if (config) {
const { interface: iCfg, defaults } = config.configuration;
setMenuItemIds(iCfg.menuItems ?? allMenuItems.map((item) => item.id));
setSoundOnPreview(iCfg.soundOnPreview ?? true);
setWallShowTitle(iCfg.wallShowTitle ?? true);
setWallPlayback(iCfg.wallPlayback ?? "video");
setMaximumLoopDuration(iCfg.maximumLoopDuration ?? 0);
setNoBrowserFlag(iCfg?.noBrowser ?? false);
setAutostartVideo(iCfg.autostartVideo ?? false);
setAutostartVideoOnPlaySelected(
iCfg.autostartVideoOnPlaySelected ?? true
);
setContinuePlaylistDefault(iCfg.continuePlaylistDefault ?? false);
setShowStudioAsText(iCfg.showStudioAsText ?? false);
setCSS(iCfg.css ?? "");
setCSSEnabled(iCfg.cssEnabled ?? false);
setLanguage(iCfg.language ?? "en-US");
setSlideshowDelay(iCfg.slideshowDelay ?? 5000);
setHandyKey(iCfg.handyKey ?? "");
setFunscriptOffset(iCfg.funscriptOffset ?? 0);
setDisableDropdownCreate({
performer: iCfg.disabledDropdownCreate.performer,
studio: iCfg.disabledDropdownCreate.studio,
tag: iCfg.disabledDropdownCreate.tag,
});
setDeleteFileDefault(defaults.deleteFile ?? false);
setDeleteGeneratedDefault(defaults.deleteGenerated ?? true);
}
}, [config]);
async function onSave() {
const prevCSS = config?.configuration.interface.css;
const prevCSSenabled = config?.configuration.interface.cssEnabled;
try {
if (config?.configuration.defaults) {
await updateDefaultsConfig({
variables: {
input: {
...withoutTypename(config?.configuration.defaults),
deleteFile: deleteFileDefault,
deleteGenerated: deleteGeneratedDefault,
},
},
});
}
const result = await updateInterfaceConfig();
// Force refetch of custom css if it was changed
if (
prevCSS !== result.data?.configureInterface.css ||
prevCSSenabled !== result.data?.configureInterface.cssEnabled
) {
await fetch("/css", { cache: "reload" });
window.location.reload();
}
Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl
.formatMessage({ id: "configuration" })
.toLocaleLowerCase(),
}
),
});
} catch (e) {
Toast.error(e);
}
}
if (error) return <h1>{error.message}</h1>;
if (loading) return <LoadingIndicator />;
return (
<>
<h4>{intl.formatMessage({ id: "config.ui.title" })}</h4>
<Form.Group controlId="language">
<h5>{intl.formatMessage({ id: "config.ui.language.heading" })}</h5>
<Form.Control
as="select"
className="col-4 input-control"
value={language}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setLanguage(e.currentTarget.value)
}
<SettingSection headingID="config.ui.basic_settings">
<SelectSetting
id="language"
headingID="config.ui.language.heading"
value={iface.language ?? undefined}
onChange={(v) => saveInterface({ language: v })}
>
<option value="en-US">English (United States)</option>
<option value="en-GB">English (United Kingdom)</option>
@@ -181,76 +55,61 @@ export const SettingsInterfacePanel: React.FC = () => {
<option value="sv-SE">Swedish (Sweden)</option>
<option value="zh-TW"> ()</option>
<option value="zh-CN"> ()</option>
</Form.Control>
</Form.Group>
<Form.Group>
<h5>{intl.formatMessage({ id: "config.ui.menu_items.heading" })}</h5>
</SelectSetting>
<div className="setting-group">
<div className="setting">
<div>
<h3>
{intl.formatMessage({
id: "config.ui.menu_items.heading",
})}
</h3>
<div className="sub-heading">
{intl.formatMessage({ id: "config.ui.menu_items.description" })}
</div>
</div>
<div />
</div>
<CheckboxGroup
groupId="menu-items"
items={allMenuItems}
checkedIds={menuItemIds}
onChange={setMenuItemIds}
checkedIds={iface.menuItems ?? undefined}
onChange={(v) => saveInterface({ menuItems: v })}
/>
<Form.Text className="text-muted">
{intl.formatMessage({ id: "config.ui.menu_items.description" })}
</Form.Text>
</Form.Group>
</div>
</SettingSection>
<hr />
<h4>
{intl.formatMessage({
id: "config.ui.desktop_integration.desktop_integration",
})}
</h4>
<Form.Group>
<Form.Check
<SettingSection headingID="config.ui.desktop_integration.desktop_integration">
<BooleanSetting
id="skip-browser"
checked={noBrowser}
label={intl.formatMessage({
id: "config.ui.desktop_integration.skip_opening_browser",
})}
onChange={() => setNoBrowserFlag(!noBrowser)}
headingID="config.ui.desktop_integration.skip_opening_browser"
subHeadingID="config.ui.desktop_integration.skip_opening_browser_on_startup"
checked={iface.noBrowser ?? undefined}
onChange={(v) => saveInterface({ noBrowser: v })}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "config.ui.desktop_integration.skip_opening_browser_on_startup",
})}
</Form.Text>
</Form.Group>
<hr />
</SettingSection>
<Form.Group>
<h5>{intl.formatMessage({ id: "config.ui.scene_wall.heading" })}</h5>
<Form.Check
<SettingSection headingID="config.ui.scene_wall.heading">
<BooleanSetting
id="wall-show-title"
checked={wallShowTitle}
label={intl.formatMessage({
id: "config.ui.scene_wall.options.display_title",
})}
onChange={() => setWallShowTitle(!wallShowTitle)}
headingID="config.ui.scene_wall.options.display_title"
checked={iface.wallShowTitle ?? undefined}
onChange={(v) => saveInterface({ wallShowTitle: v })}
/>
<Form.Check
<BooleanSetting
id="wall-sound-enabled"
checked={soundOnPreview}
label={intl.formatMessage({
id: "config.ui.scene_wall.options.toggle_sound",
})}
onChange={() => setSoundOnPreview(!soundOnPreview)}
headingID="config.ui.scene_wall.options.toggle_sound"
checked={iface.soundOnPreview ?? undefined}
onChange={(v) => saveInterface({ soundOnPreview: v })}
/>
<Form.Label htmlFor="wall-preview">
<h6>
{intl.formatMessage({ id: "config.ui.preview_type.heading" })}
</h6>
</Form.Label>
<Form.Control
as="select"
name="wall-preview"
className="col-4 input-control"
value={wallPlayback}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setWallPlayback(e.currentTarget.value)
}
<SelectSetting
id="wall-preview"
headingID="config.ui.preview_type.heading"
subHeadingID="config.ui.preview_type.description"
value={iface.wallPlayback ?? undefined}
onChange={(v) => saveInterface({ wallPlayback: v })}
>
<option value="video">
{intl.formatMessage({ id: "config.ui.preview_type.options.video" })}
@@ -265,269 +124,172 @@ export const SettingsInterfacePanel: React.FC = () => {
id: "config.ui.preview_type.options.static",
})}
</option>
</Form.Control>
<Form.Text className="text-muted">
{intl.formatMessage({ id: "config.ui.preview_type.description" })}
</Form.Text>
</Form.Group>
</SelectSetting>
</SettingSection>
<Form.Group>
<h5>{intl.formatMessage({ id: "config.ui.scene_list.heading" })}</h5>
<Form.Check
<SettingSection headingID="config.ui.scene_list.heading">
<BooleanSetting
id="show-text-studios"
checked={showStudioAsText}
label={intl.formatMessage({
id: "config.ui.scene_list.options.show_studio_as_text",
})}
onChange={() => {
setShowStudioAsText(!showStudioAsText);
}}
headingID="config.ui.scene_list.options.show_studio_as_text"
checked={iface.showStudioAsText ?? undefined}
onChange={(v) => saveInterface({ showStudioAsText: v })}
/>
</Form.Group>
</SettingSection>
<Form.Group>
<h5>{intl.formatMessage({ id: "config.ui.scene_player.heading" })}</h5>
<Form.Group>
<Form.Check
<SettingSection headingID="config.ui.scene_player.heading">
<BooleanSetting
id="auto-start-video"
checked={autostartVideo}
label={intl.formatMessage({
id: "config.ui.scene_player.options.auto_start_video",
})}
onChange={() => {
setAutostartVideo(!autostartVideo);
}}
headingID="config.ui.scene_player.options.auto_start_video"
checked={iface.autostartVideo ?? undefined}
onChange={(v) => saveInterface({ autostartVideo: v })}
/>
</Form.Group>
<Form.Group id="auto-start-video-on-play-selected">
<Form.Check
checked={autostartVideoOnPlaySelected}
label={intl.formatMessage({
id:
"config.ui.scene_player.options.auto_start_video_on_play_selected.heading",
})}
onChange={() => {
setAutostartVideoOnPlaySelected(!autostartVideoOnPlaySelected);
}}
<BooleanSetting
id="auto-start-video-on-play-selected"
headingID="config.ui.scene_player.options.auto_start_video_on_play_selected.heading"
subHeadingID="config.ui.scene_player.options.auto_start_video_on_play_selected.description"
checked={iface.autostartVideoOnPlaySelected ?? undefined}
onChange={(v) => saveInterface({ autostartVideoOnPlaySelected: v })}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id:
"config.ui.scene_player.options.auto_start_video_on_play_selected.description",
})}
</Form.Text>
</Form.Group>
<Form.Group id="continue-playlist-default">
<Form.Check
checked={continuePlaylistDefault}
label={intl.formatMessage({
id:
"config.ui.scene_player.options.continue_playlist_default.heading",
})}
onChange={() => {
setContinuePlaylistDefault(!continuePlaylistDefault);
}}
<BooleanSetting
id="continue-playlist-default"
headingID="config.ui.scene_player.options.continue_playlist_default.heading"
subHeadingID="config.ui.scene_player.options.continue_playlist_default.description"
checked={iface.continuePlaylistDefault ?? undefined}
onChange={(v) => saveInterface({ continuePlaylistDefault: v })}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id:
"config.ui.scene_player.options.continue_playlist_default.description",
})}
</Form.Text>
</Form.Group>
<Form.Group id="max-loop-duration">
<h6>
{intl.formatMessage({ id: "config.ui.max_loop_duration.heading" })}
</h6>
<ModalSetting<number>
id="max-loop-duration"
headingID="config.ui.max_loop_duration.heading"
subHeadingID="config.ui.max_loop_duration.description"
value={iface.maximumLoopDuration ?? undefined}
onChange={(v) => saveInterface({ maximumLoopDuration: v })}
renderField={(value, setValue) => (
<DurationInput
className="row col col-4"
numericValue={maximumLoopDuration}
onValueChange={(duration) => setMaximumLoopDuration(duration ?? 0)}
numericValue={value}
onValueChange={(duration) => setValue(duration ?? 0)}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "config.ui.max_loop_duration.description",
})}
</Form.Text>
</Form.Group>
</Form.Group>
<Form.Group id="slideshow-delay">
<h5>
{intl.formatMessage({ id: "config.ui.slideshow_delay.heading" })}
</h5>
<Form.Control
className="col col-sm-6 text-input"
type="number"
value={slideshowDelay / SECONDS_TO_MS}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSlideshowDelay(
Number.parseInt(e.currentTarget.value, 10) * SECONDS_TO_MS
);
)}
renderValue={(v) => {
return <span>{DurationUtils.secondsToString(v ?? 0)}</span>;
}}
/>
<Form.Text className="text-muted">
{intl.formatMessage({ id: "config.ui.slideshow_delay.description" })}
</Form.Text>
</Form.Group>
</SettingSection>
<Form.Group>
<h5>{intl.formatMessage({ id: "config.ui.editing.heading" })}</h5>
<SettingSection headingID="config.ui.images.heading">
<NumberSetting
headingID="config.ui.slideshow_delay.heading"
subHeadingID="config.ui.slideshow_delay.description"
value={iface.slideshowDelay ?? undefined}
onChange={(v) => saveInterface({ slideshowDelay: v })}
/>
</SettingSection>
<Form.Group>
<h6>
<SettingSection headingID="config.ui.editing.heading">
<div className="setting-group">
<div className="setting">
<div>
<h3>
{intl.formatMessage({
id: "config.ui.editing.disable_dropdown_create.heading",
})}
</h6>
<Form.Check
id="disableDropdownCreate_performer"
checked={disableDropdownCreate.performer ?? false}
label={intl.formatMessage({
id: "performer",
})}
onChange={() => {
setDisableDropdownCreate({
...disableDropdownCreate,
performer: !disableDropdownCreate.performer ?? true,
});
}}
/>
<Form.Check
id="disableDropdownCreate_studio"
checked={disableDropdownCreate.studio ?? false}
label={intl.formatMessage({
id: "studio",
})}
onChange={() => {
setDisableDropdownCreate({
...disableDropdownCreate,
studio: !disableDropdownCreate.studio ?? true,
});
}}
/>
<Form.Check
id="disableDropdownCreate_tag"
checked={disableDropdownCreate.tag ?? false}
label={intl.formatMessage({
id: "tag",
})}
onChange={() => {
setDisableDropdownCreate({
...disableDropdownCreate,
tag: !disableDropdownCreate.tag ?? true,
});
}}
/>
<Form.Text className="text-muted">
</h3>
<div className="sub-heading">
{intl.formatMessage({
id: "config.ui.editing.disable_dropdown_create.description",
})}
</Form.Text>
</Form.Group>
</Form.Group>
</div>
</div>
<div />
</div>
<BooleanSetting
id="disableDropdownCreate_performer"
headingID="performer"
checked={iface.disableDropdownCreate?.performer ?? undefined}
onChange={(v) =>
saveInterface({
disableDropdownCreate: {
...iface.disableDropdownCreate,
performer: v,
},
})
}
/>
<BooleanSetting
id="disableDropdownCreate_studio"
headingID="studio"
checked={iface.disableDropdownCreate?.studio ?? undefined}
onChange={(v) =>
saveInterface({
disableDropdownCreate: {
...iface.disableDropdownCreate,
studio: v,
},
})
}
/>
<BooleanSetting
id="disableDropdownCreate_tag"
headingID="tag"
checked={iface.disableDropdownCreate?.tag ?? undefined}
onChange={(v) =>
saveInterface({
disableDropdownCreate: {
...iface.disableDropdownCreate,
tag: v,
},
})
}
/>
</div>
</SettingSection>
<Form.Group>
<h5>{intl.formatMessage({ id: "config.ui.custom_css.heading" })}</h5>
<Form.Check
id="custom-css"
checked={cssEnabled}
label={intl.formatMessage({
id: "config.ui.custom_css.option_label",
})}
onChange={() => {
setCSSEnabled(!cssEnabled);
}}
<SettingSection headingID="config.ui.custom_css.heading">
<BooleanSetting
id="custom-css-enabled"
headingID="config.ui.custom_css.option_label"
checked={iface.cssEnabled ?? undefined}
onChange={(v) => saveInterface({ cssEnabled: v })}
/>
<ModalSetting<string>
id="custom-css"
headingID="config.ui.custom_css.heading"
subHeadingID="config.ui.custom_css.description"
value={iface.css ?? undefined}
onChange={(v) => saveInterface({ css: v })}
renderField={(value, setValue) => (
<Form.Control
as="textarea"
value={css}
value={value}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setCSS(e.currentTarget.value)
setValue(e.currentTarget.value)
}
rows={16}
className="col col-sm-6 text-input code"
className="text-input code"
/>
<Form.Text className="text-muted">
{intl.formatMessage({ id: "config.ui.custom_css.description" })}
</Form.Text>
</Form.Group>
)}
renderValue={() => {
return <></>;
}}
/>
</SettingSection>
<Form.Group>
<h5>
{intl.formatMessage({ id: "config.ui.handy_connection_key.heading" })}
</h5>
<Form.Control
className="col col-sm-6 text-input"
value={handyKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setHandyKey(e.currentTarget.value);
}}
<SettingSection headingID="config.ui.interactive_options">
<StringSetting
headingID="config.ui.handy_connection_key.heading"
subHeadingID="config.ui.handy_connection_key.description"
value={iface.handyKey ?? undefined}
onChange={(v) => saveInterface({ handyKey: v })}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "config.ui.handy_connection_key.description",
})}
</Form.Text>
</Form.Group>
<Form.Group>
<h5>
{intl.formatMessage({ id: "config.ui.funscript_offset.heading" })}
</h5>
<Form.Control
className="col col-sm-6 text-input"
type="number"
value={funscriptOffset}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setFunscriptOffset(Number.parseInt(e.currentTarget.value, 10));
}}
<NumberSetting
headingID="config.ui.funscript_offset.heading"
subHeadingID="config.ui.funscript_offset.description"
value={iface.funscriptOffset ?? undefined}
onChange={(v) => saveInterface({ funscriptOffset: v })}
/>
<Form.Text className="text-muted">
{intl.formatMessage({ id: "config.ui.funscript_offset.description" })}
</Form.Text>
</Form.Group>
<Form.Group>
<h5>
{intl.formatMessage({ id: "config.ui.delete_options.heading" })}
</h5>
<Form.Check
id="delete-file-default"
checked={deleteFileDefault}
label={intl.formatMessage({
id: "config.ui.delete_options.options.delete_file",
})}
onChange={() => {
setDeleteFileDefault(!deleteFileDefault);
}}
/>
<Form.Check
id="delete-generated-default"
checked={deleteGeneratedDefault}
label={intl.formatMessage({
id:
"config.ui.delete_options.options.delete_generated_supporting_files",
})}
onChange={() => {
setDeleteGeneratedDefault(!deleteGeneratedDefault);
}}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "config.ui.delete_options.description",
})}
</Form.Text>
</Form.Group>
<hr />
<Button variant="primary" onClick={() => onSave()}>
{intl.formatMessage({ id: "actions.save" })}
</Button>
</SettingSection>
</>
);
};

View File

@@ -0,0 +1,159 @@
import React from "react";
import { Icon, LoadingIndicator } from "src/components/Shared";
import { StashSetting } from "./StashConfiguration";
import { SettingSection } from "./SettingSection";
import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs";
import { SettingStateContext } from "./context";
import { useIntl } from "react-intl";
export const SettingsLibraryPanel: React.FC = () => {
const intl = useIntl();
const {
general,
loading,
error,
saveGeneral,
defaults,
saveDefaults,
} = React.useContext(SettingStateContext);
function commaDelimitedToList(value: string | undefined) {
if (value) {
return value.split(",").map((s) => s.trim());
}
}
function listToCommaDelimited(value: string[] | undefined) {
if (value) {
return value.join(", ");
}
}
if (error) return <h1>{error.message}</h1>;
if (loading) return <LoadingIndicator />;
return (
<>
<StashSetting
value={general.stashes ?? []}
onChange={(v) => saveGeneral({ stashes: v })}
/>
<SettingSection headingID="config.library.media_content_extensions">
<StringSetting
id="video-extensions"
headingID="config.general.video_ext_head"
subHeadingID="config.general.video_ext_desc"
value={listToCommaDelimited(general.videoExtensions ?? undefined)}
onChange={(v) =>
saveGeneral({ videoExtensions: commaDelimitedToList(v) })
}
/>
<StringSetting
id="image-extensions"
headingID="config.general.image_ext_head"
subHeadingID="config.general.image_ext_desc"
value={listToCommaDelimited(general.imageExtensions ?? undefined)}
onChange={(v) =>
saveGeneral({ imageExtensions: commaDelimitedToList(v) })
}
/>
<StringSetting
id="gallery-extensions"
headingID="config.general.gallery_ext_head"
subHeadingID="config.general.gallery_ext_desc"
value={listToCommaDelimited(general.galleryExtensions ?? undefined)}
onChange={(v) =>
saveGeneral({ galleryExtensions: commaDelimitedToList(v) })
}
/>
</SettingSection>
<SettingSection headingID="config.library.exclusions">
<StringListSetting
id="excluded-video-patterns"
headingID="config.general.excluded_video_patterns_head"
subHeading={
<span>
{intl.formatMessage({
id: "config.general.excluded_video_patterns_desc",
})}
<a
href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration"
rel="noopener noreferrer"
target="_blank"
>
<Icon icon="question-circle" />
</a>
</span>
}
value={general.excludes ?? undefined}
onChange={(v) => saveGeneral({ excludes: v })}
defaultNewValue="sample\.mp4$"
/>
<StringListSetting
id="excluded-image-gallery-patterns"
headingID="config.general.excluded_image_gallery_patterns_head"
subHeading={
<span>
{intl.formatMessage({
id: "config.general.excluded_image_gallery_patterns_desc",
})}
<a
href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration"
rel="noopener noreferrer"
target="_blank"
>
<Icon icon="question-circle" />
</a>
</span>
}
value={general.imageExcludes ?? undefined}
onChange={(v) => saveGeneral({ imageExcludes: v })}
defaultNewValue="sample\.jpg$"
/>
</SettingSection>
<SettingSection headingID="config.library.gallery_and_image_options">
<BooleanSetting
id="create-galleries-from-folders"
headingID="config.general.create_galleries_from_folders_label"
subHeadingID="config.general.create_galleries_from_folders_desc"
checked={general.createGalleriesFromFolders ?? false}
onChange={(v) => saveGeneral({ createGalleriesFromFolders: v })}
/>
<BooleanSetting
id="write-image-thumbnails"
headingID="config.ui.images.options.write_image_thumbnails.heading"
subHeadingID="config.ui.images.options.write_image_thumbnails.description"
checked={general.writeImageThumbnails ?? false}
onChange={(v) => saveGeneral({ writeImageThumbnails: v })}
/>
</SettingSection>
<SettingSection headingID="config.ui.delete_options.heading">
<BooleanSetting
id="delete-file-default"
headingID="config.ui.delete_options.options.delete_file"
checked={defaults.deleteFile ?? undefined}
onChange={(v) => {
saveDefaults({ deleteFile: v });
}}
/>
<BooleanSetting
id="delete-generated-default"
headingID="config.ui.delete_options.options.delete_generated_supporting_files"
subHeadingID="config.ui.delete_options.description"
checked={defaults.deleteGenerated ?? undefined}
onChange={(v) => {
saveDefaults({ deleteGenerated: v });
}}
/>
</SettingSection>
</>
);
};

View File

@@ -1,8 +1,8 @@
import React, { useEffect, useReducer, useState } from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { useLogs, useLoggingSubscribe } from "src/core/StashService";
import { SelectSetting } from "./Inputs";
import { SettingSection } from "./SettingSection";
function convertTime(logEntry: GQL.LogEntryDataFragment) {
function pad(val: number) {
@@ -75,7 +75,6 @@ const logReducer = (existingEntries: LogEntry[], newEntries: LogEntry[]) => [
];
export const SettingsLogsPanel: React.FC = () => {
const intl = useIntl();
const { data, error } = useLoggingSubscribe();
const { data: existingData } = useLogs();
const [currentData, dispatchLogUpdate] = useReducer(logReducer, []);
@@ -108,24 +107,21 @@ export const SettingsLogsPanel: React.FC = () => {
return (
<>
<h4>{intl.formatMessage({ id: "config.categories.logs" })}</h4>
<Form.Row id="log-level">
<Form.Label className="col-6 col-sm-2">
{intl.formatMessage({ id: "config.logs.log_level" })}
</Form.Label>
<Form.Control
className="col-6 col-sm-2 input-control"
as="select"
defaultValue={logLevel}
onChange={(event) => setLogLevel(event.currentTarget.value)}
<SettingSection headingID="config.categories.logs">
<SelectSetting
id="log-level"
headingID="config.logs.log_level"
value={logLevel}
onChange={(v) => setLogLevel(v)}
>
{logLevels.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</Form.Control>
</Form.Row>
</SelectSetting>
</SettingSection>
<div className="logs">
{maybeRenderError}
{filteredLogEntries.map((logEntry) => (

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useMemo } from "react";
import { Button } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
@@ -6,6 +6,8 @@ import { mutateReloadPlugins, usePlugins } from "src/core/StashService";
import { useToast } from "src/hooks";
import { TextUtils } from "src/utils";
import { CollapseButton, Icon, LoadingIndicator } from "src/components/Shared";
import { SettingSection } from "./SettingSection";
import { Setting, SettingGroup } from "./Inputs";
export const SettingsPluginsPanel: React.FC = () => {
const Toast = useToast();
@@ -17,6 +19,7 @@ export const SettingsPluginsPanel: React.FC = () => {
await mutateReloadPlugins().catch((e) => Toast.error(e));
}
const pluginElements = useMemo(() => {
function renderLink(url?: string) {
if (url) {
return (
@@ -36,17 +39,18 @@ export const SettingsPluginsPanel: React.FC = () => {
function renderPlugins() {
const elements = (data?.plugins ?? []).map((plugin) => (
<div key={plugin.id}>
<h4>
{plugin.name} {plugin.version ? `(${plugin.version})` : undefined}{" "}
{renderLink(plugin.url ?? undefined)}
</h4>
{plugin.description ? (
<small className="text-muted">{plugin.description}</small>
) : undefined}
<SettingGroup
key={plugin.id}
settingProps={{
heading: `${plugin.name} ${
plugin.version ? `(${plugin.version})` : undefined
}`,
subHeading: plugin.description,
}}
topLevel={renderLink(plugin.url ?? undefined)}
>
{renderPluginHooks(plugin.hooks ?? undefined)}
<hr />
</div>
</SettingGroup>
));
return <div>{elements}</div>;
@@ -60,15 +64,18 @@ export const SettingsPluginsPanel: React.FC = () => {
}
return (
<div className="mt-2">
<div className="setting">
<div>
<h5>
<FormattedMessage id="config.plugins.hooks" />
</h5>
{hooks.map((h) => (
<div key={`${h.name}`} className="mb-3">
<div key={`${h.name}`}>
<h6>{h.name}</h6>
<CollapseButton
text={intl.formatMessage({ id: "config.plugins.triggers_on" })}
text={intl.formatMessage({
id: "config.plugins.triggers_on",
})}
>
<ul>
{h.hooks?.map((hh) => (
@@ -82,18 +89,20 @@ export const SettingsPluginsPanel: React.FC = () => {
</div>
))}
</div>
<div />
</div>
);
}
return renderPlugins();
}, [data?.plugins, intl]);
if (loading) return <LoadingIndicator />;
return (
<>
<h3>
<FormattedMessage id="config.categories.plugins" />
</h3>
<hr />
{renderPlugins()}
<SettingSection headingID="config.categories.plugins">
<Setting headingID="actions.reload_plugins">
<Button onClick={() => onReloadPlugins()}>
<span className="fa-icon">
<Icon icon="sync-alt" />
@@ -102,6 +111,9 @@ export const SettingsPluginsPanel: React.FC = () => {
<FormattedMessage id="actions.reload_plugins" />
</span>
</Button>
</Setting>
{pluginElements}
</SettingSection>
</>
);
};

View File

@@ -1,20 +1,21 @@
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Button, Form } from "react-bootstrap";
import { Button } from "react-bootstrap";
import {
mutateReloadScrapers,
useListMovieScrapers,
useListPerformerScrapers,
useListSceneScrapers,
useListGalleryScrapers,
useConfiguration,
useConfigureScraping,
} from "src/core/StashService";
import { useToast } from "src/hooks";
import { TextUtils } from "src/utils";
import { CollapseButton, Icon, LoadingIndicator } from "src/components/Shared";
import { ScrapeType } from "src/core/generated-graphql";
import { StringListInput } from "../Shared/StringListInput";
import { SettingSection } from "./SettingSection";
import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs";
import { SettingStateContext } from "./context";
import { StashBoxSetting } from "./StashBoxConfiguration";
interface IURLList {
urls: string[];
@@ -90,58 +91,19 @@ export const SettingsScrapingPanel: React.FC = () => {
loading: loadingMovies,
} = useListMovieScrapers();
const [scraperUserAgent, setScraperUserAgent] = useState<string | undefined>(
undefined
);
const [scraperCDPPath, setScraperCDPPath] = useState<string | undefined>(
undefined
);
const [scraperCertCheck, setScraperCertCheck] = useState<boolean>(true);
const [excludeTagPatterns, setExcludeTagPatterns] = useState<string[]>([]);
const { data, error } = useConfiguration();
const [updateScrapingConfig] = useConfigureScraping({
scraperUserAgent,
scraperCDPPath,
scraperCertCheck,
excludeTagPatterns,
});
useEffect(() => {
if (!data?.configuration || error) return;
const conf = data.configuration;
if (conf.scraping) {
setScraperUserAgent(conf.scraping.scraperUserAgent ?? undefined);
setScraperCDPPath(conf.scraping.scraperCDPPath ?? undefined);
setScraperCertCheck(conf.scraping.scraperCertCheck);
setExcludeTagPatterns(conf.scraping.excludeTagPatterns);
}
}, [data, error]);
const {
general,
scraping,
loading,
error,
saveGeneral,
saveScraping,
} = React.useContext(SettingStateContext);
async function onReloadScrapers() {
await mutateReloadScrapers().catch((e) => Toast.error(e));
}
async function onSave() {
try {
await updateScrapingConfig();
Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl
.formatMessage({ id: "configuration" })
.toLocaleLowerCase(),
}
),
});
} catch (e) {
Toast.error(e);
}
}
function renderPerformerScrapeTypes(types: ScrapeType[]) {
const typeStrings = types
.filter((t) => t !== ScrapeType.Fragment)
@@ -344,88 +306,59 @@ export const SettingsScrapingPanel: React.FC = () => {
}
}
if (loadingScenes || loadingGalleries || loadingPerformers || loadingMovies)
if (error) return <h1>{error.message}</h1>;
if (
loading ||
loadingScenes ||
loadingGalleries ||
loadingPerformers ||
loadingMovies
)
return <LoadingIndicator />;
return (
<>
<Form.Group>
<h4>{intl.formatMessage({ id: "config.general.scraping" })}</h4>
<Form.Group id="scraperUserAgent">
<h6>
{intl.formatMessage({ id: "config.general.scraper_user_agent" })}
</h6>
<Form.Control
className="col col-sm-6 text-input"
defaultValue={scraperUserAgent}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setScraperUserAgent(e.currentTarget.value)
}
<StashBoxSetting
value={general.stashBoxes ?? []}
onChange={(v) => saveGeneral({ stashBoxes: v })}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "config.general.scraper_user_agent_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="scraperCDPPath">
<h6>
{intl.formatMessage({ id: "config.general.chrome_cdp_path" })}
</h6>
<Form.Control
className="col col-sm-6 text-input"
defaultValue={scraperCDPPath}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setScraperCDPPath(e.currentTarget.value)
}
<SettingSection headingID="config.general.scraping">
<StringSetting
id="scraperUserAgent"
headingID="config.general.scraper_user_agent"
subHeadingID="config.general.scraper_user_agent_desc"
value={scraping.scraperUserAgent ?? undefined}
onChange={(v) => saveScraping({ scraperUserAgent: v })}
/>
<Form.Text className="text-muted">
{intl.formatMessage({ id: "config.general.chrome_cdp_path_desc" })}
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Check
id="scaper-cert-check"
checked={scraperCertCheck}
label={intl.formatMessage({
id: "config.general.check_for_insecure_certificates",
})}
onChange={() => setScraperCertCheck(!scraperCertCheck)}
<StringSetting
id="scraperCDPPath"
headingID="config.general.chrome_cdp_path"
subHeadingID="config.general.chrome_cdp_path_desc"
value={scraping.scraperCDPPath ?? undefined}
onChange={(v) => saveScraping({ scraperCDPPath: v })}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "config.general.check_for_insecure_certificates_desc",
})}
</Form.Text>
</Form.Group>
</Form.Group>
<Form.Group>
<h6>
{intl.formatMessage({
id: "config.scraping.excluded_tag_patterns_head",
})}
</h6>
<StringListInput
className="w-50"
value={excludeTagPatterns}
setValue={setExcludeTagPatterns}
defaultNewValue="4K"
<BooleanSetting
id="scraper-cert-check"
headingID="config.general.check_for_insecure_certificates"
subHeadingID="config.general.check_for_insecure_certificates_desc"
checked={scraping.scraperCertCheck ?? undefined}
onChange={(v) => saveScraping({ scraperCertCheck: v })}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "config.scraping.excluded_tag_patterns_desc",
})}
</Form.Text>
</Form.Group>
<hr />
<StringListSetting
id="excluded-tag-patterns"
headingID="config.scraping.excluded_tag_patterns_head"
subHeadingID="config.scraping.excluded_tag_patterns_desc"
value={scraping.excludeTagPatterns ?? undefined}
onChange={(v) => saveScraping({ excludeTagPatterns: v })}
/>
</SettingSection>
<h4>{intl.formatMessage({ id: "config.scraping.scrapers" })}</h4>
<div className="mb-3">
<SettingSection headingID="config.scraping.scrapers">
<div className="content">
<Button onClick={() => onReloadScrapers()}>
<span className="fa-icon">
<Icon icon="sync-alt" />
@@ -436,18 +369,13 @@ export const SettingsScrapingPanel: React.FC = () => {
</Button>
</div>
<div>
<div className="content">
{renderSceneScrapers()}
{renderGalleryScrapers()}
{renderPerformerScrapers()}
{renderMovieScrapers()}
</div>
<hr />
<Button variant="primary" onClick={() => onSave()}>
<FormattedMessage id="actions.save" />
</Button>
</SettingSection>
</>
);
};

View File

@@ -0,0 +1,176 @@
import React from "react";
import { ModalSetting, NumberSetting, StringListSetting } from "./Inputs";
import { SettingSection } from "./SettingSection";
import * as GQL from "src/core/generated-graphql";
import { Button, Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { SettingStateContext } from "./context";
import { LoadingIndicator } from "../Shared";
import { useToast } from "src/hooks";
import { useGenerateAPIKey } from "src/core/StashService";
type AuthenticationSettingsInput = Pick<
GQL.ConfigGeneralInput,
"username" | "password"
>;
interface IAuthenticationInput {
value: AuthenticationSettingsInput;
setValue: (v: AuthenticationSettingsInput) => void;
}
const AuthenticationInput: React.FC<IAuthenticationInput> = ({
value,
setValue,
}) => {
const intl = useIntl();
function set(v: Partial<AuthenticationSettingsInput>) {
setValue({
...value,
...v,
});
}
const { username, password } = value;
return (
<div>
<Form.Group id="username">
<h6>{intl.formatMessage({ id: "config.general.auth.username" })}</h6>
<Form.Control
className="text-input"
value={username ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
set({ username: e.currentTarget.value })
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({ id: "config.general.auth.username_desc" })}
</Form.Text>
</Form.Group>
<Form.Group id="password">
<h6>{intl.formatMessage({ id: "config.general.auth.password" })}</h6>
<Form.Control
className="text-input"
type="password"
value={password ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
set({ password: e.currentTarget.value })
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({ id: "config.general.auth.password_desc" })}
</Form.Text>
</Form.Group>
</div>
);
};
export const SettingsSecurityPanel: React.FC = () => {
const intl = useIntl();
const Toast = useToast();
const { general, apiKey, loading, error, saveGeneral } = React.useContext(
SettingStateContext
);
const [generateAPIKey] = useGenerateAPIKey();
async function onGenerateAPIKey() {
try {
await generateAPIKey({
variables: {
input: {},
},
});
} catch (e) {
Toast.error(e);
}
}
async function onClearAPIKey() {
try {
await generateAPIKey({
variables: {
input: {
clear: true,
},
},
});
} catch (e) {
Toast.error(e);
}
}
if (error) return <h1>{error.message}</h1>;
if (loading) return <LoadingIndicator />;
return (
<>
<SettingSection headingID="config.general.auth.authentication">
<ModalSetting<AuthenticationSettingsInput>
id="authentication-settings"
headingID="config.general.auth.credentials.heading"
subHeadingID="config.general.auth.credentials.description"
value={{
username: general.username,
password: general.password,
}}
onChange={(v) => saveGeneral(v)}
renderField={(value, setValue) => (
<AuthenticationInput value={value ?? {}} setValue={setValue} />
)}
renderValue={(v) => {
if (v?.username && v?.password)
return <span>{v?.username ?? ""}</span>;
return <></>;
}}
/>
<div className="setting" id="apikey">
<div>
<h3>{intl.formatMessage({ id: "config.general.auth.api_key" })}</h3>
<div className="value text-break">{apiKey}</div>
<div className="sub-heading">
{intl.formatMessage({ id: "config.general.auth.api_key_desc" })}
</div>
</div>
<div>
<Button
disabled={!general.username || !general.password}
onClick={() => onGenerateAPIKey()}
>
{intl.formatMessage({
id: "config.general.auth.generate_api_key",
})}
</Button>
<Button variant="danger" onClick={() => onClearAPIKey()}>
{intl.formatMessage({
id: "config.general.auth.clear_api_key",
})}
</Button>
</div>
</div>
<NumberSetting
id="maxSessionAge"
headingID="config.general.auth.maximum_session_age"
subHeadingID="config.general.auth.maximum_session_age_desc"
value={general.maxSessionAge ?? undefined}
onChange={(v) => saveGeneral({ maxSessionAge: v })}
/>
<StringListSetting
id="trusted-proxies"
headingID="config.general.auth.trusted_proxies"
subHeadingID="config.general.auth.trusted_proxies_desc"
value={general.trustedProxies ?? undefined}
onChange={(v) => saveGeneral({ trustedProxies: v })}
/>
</SettingSection>
</>
);
};

View File

@@ -1,12 +1,7 @@
import React, { useState } from "react";
import { Formik, useFormikContext } from "formik";
import { Button, Form } from "react-bootstrap";
import { Prompt } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import * as yup from "yup";
import {
useConfiguration,
useConfigureDLNA,
useDisableDLNA,
useDLNAStatus,
useEnableDLNA,
@@ -15,12 +10,18 @@ import {
} from "src/core/StashService";
import { useToast } from "src/hooks";
import { DurationInput, Icon, LoadingIndicator, Modal } from "../Shared";
import { StringListInput } from "../Shared/StringListInput";
import { SettingSection } from "./SettingSection";
import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs";
import { SettingStateContext } from "./context";
export const SettingsDLNAPanel: React.FC = () => {
export const SettingsServicesPanel: React.FC = () => {
const intl = useIntl();
const Toast = useToast();
const { dlna, loading: configLoading, error, saveDLNA } = React.useContext(
SettingStateContext
);
// undefined to hide dialog, true for enable, false for disable
const [enableDisable, setEnableDisable] = useState<boolean | undefined>(
undefined
@@ -34,64 +35,15 @@ export const SettingsDLNAPanel: React.FC = () => {
const [ipEntry, setIPEntry] = useState<string>("");
const [tempIP, setTempIP] = useState<string | undefined>();
const { data, refetch: configRefetch } = useConfiguration();
const { data: statusData, loading, refetch: statusRefetch } = useDLNAStatus();
const [updateDLNAConfig] = useConfigureDLNA();
const [enableDLNA] = useEnableDLNA();
const [disableDLNA] = useDisableDLNA();
const [addTempDLANIP] = useAddTempDLNAIP();
const [removeTempDLNAIP] = useRemoveTempDLNAIP();
if (loading) return <LoadingIndicator />;
// settings
const schema = yup.object({
serverName: yup.string(),
enabled: yup.boolean().required(),
whitelistedIPs: yup.array(yup.string().required()).required(),
interfaces: yup.array(yup.string().required()).required(),
});
interface IConfigValues {
serverName: string;
enabled: boolean;
whitelistedIPs: string[];
interfaces: string[];
}
const initialValues: IConfigValues = {
serverName: data?.configuration.dlna.serverName ?? "",
enabled: data?.configuration.dlna.enabled ?? false,
whitelistedIPs: data?.configuration.dlna.whitelistedIPs ?? [],
interfaces: data?.configuration.dlna.interfaces ?? [],
};
async function onSave(input: IConfigValues) {
try {
await updateDLNAConfig({
variables: {
input,
},
});
configRefetch();
Toast.success({
content: intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl
.formatMessage({ id: "configuration" })
.toLocaleLowerCase(),
}
),
});
} catch (e) {
Toast.error(e);
} finally {
statusRefetch();
}
}
if (error) return <h1>{error.message}</h1>;
if (loading || configLoading) return <LoadingIndicator />;
async function onTempEnable() {
const input = {
@@ -185,13 +137,9 @@ export const SettingsDLNAPanel: React.FC = () => {
}
function renderEnableButton() {
if (!data?.configuration.dlna) {
return;
}
// if enabled by default, then show the disable temporarily
// if disabled by default, then show enable temporarily
if (data?.configuration.dlna.enabled) {
if (dlna.enabled) {
return (
<Button onClick={() => setEnableDisable(false)} className="mr-1">
<FormattedMessage id="actions.temp_disable" />
@@ -207,12 +155,12 @@ export const SettingsDLNAPanel: React.FC = () => {
}
function canCancel() {
if (!statusData || !data) {
if (!statusData || !dlna) {
return false;
}
const { dlnaStatus } = statusData;
const { enabled } = data.configuration.dlna;
const { enabled } = dlna;
return dlnaStatus.until || dlnaStatus.running !== enabled;
}
@@ -348,7 +296,7 @@ export const SettingsDLNAPanel: React.FC = () => {
const { allowedIPAddresses } = statusData.dlnaStatus;
return (
<Form.Group>
<Form.Group className="content">
<h6>
{intl.formatMessage({ id: "config.dlna.allowed_ip_addresses" })}
</h6>
@@ -429,93 +377,47 @@ export const SettingsDLNAPanel: React.FC = () => {
}
const DLNASettingsForm: React.FC = () => {
const {
handleSubmit,
values,
setFieldValue,
dirty,
} = useFormikContext<IConfigValues>();
return (
<Form noValidate onSubmit={handleSubmit}>
<Prompt
when={dirty}
message={intl.formatMessage({ id: "dialogs.unsaved_changes" })}
/>
<Form.Group>
<h5>{intl.formatMessage({ id: "settings" })}</h5>
<Form.Group>
<Form.Label>
{intl.formatMessage({ id: "config.dlna.server_display_name" })}
</Form.Label>
<Form.Control
className="text-input server-name"
value={values.serverName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setFieldValue("serverName", e.currentTarget.value)
}
/>
<Form.Text className="text-muted">
{intl.formatMessage(
<>
<SettingSection headingID="settings">
<StringSetting
headingID="config.dlna.server_display_name"
subHeading={intl.formatMessage(
{ id: "config.dlna.server_display_name_desc" },
{ server_name: <code>stash</code> }
)}
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Check
value={dlna.serverName ?? undefined}
onChange={(v) => saveDLNA({ serverName: v })}
/>
<BooleanSetting
id="dlna-enabled-by-default"
checked={values.enabled}
label={intl.formatMessage({
id: "config.dlna.enabled_by_default",
})}
onChange={() => setFieldValue("enabled", !values.enabled)}
headingID="config.dlna.enabled_by_default"
checked={dlna.enabled ?? undefined}
onChange={(v) => saveDLNA({ enabled: v })}
/>
</Form.Group>
<Form.Group>
<h6>
{intl.formatMessage({ id: "config.dlna.network_interfaces" })}
</h6>
<StringListInput
value={values.interfaces}
setValue={(value) => setFieldValue("interfaces", value)}
defaultNewValue=""
className="interfaces-input"
<StringListSetting
id="dlna-network-interfaces"
headingID="config.dlna.network_interfaces"
subHeadingID="config.dlna.network_interfaces_desc"
value={dlna.interfaces ?? undefined}
onChange={(v) => saveDLNA({ interfaces: v })}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "config.dlna.network_interfaces_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group>
<h6>
{intl.formatMessage({ id: "config.dlna.default_ip_whitelist" })}
</h6>
<StringListInput
value={values.whitelistedIPs}
setValue={(value) => setFieldValue("whitelistedIPs", value)}
defaultNewValue="*"
className="ip-whitelist-input"
/>
<Form.Text className="text-muted">
{intl.formatMessage(
<StringListSetting
id="dlna-default-ip-whitelist"
headingID="config.dlna.default_ip_whitelist"
subHeading={intl.formatMessage(
{ id: "config.dlna.default_ip_whitelist_desc" },
{ wildcard: <code>*</code> }
)}
</Form.Text>
</Form.Group>
</Form.Group>
<hr />
<Button variant="primary" type="submit" disabled={!dirty}>
<FormattedMessage id="actions.save" />
</Button>
</Form>
defaultNewValue="*"
value={dlna.whitelistedIPs ?? undefined}
onChange={(v) => saveDLNA({ whitelistedIPs: v })}
/>
</SettingSection>
</>
);
};
@@ -532,17 +434,15 @@ export const SettingsDLNAPanel: React.FC = () => {
</h5>
</Form.Group>
<Form.Group>
<h5>{intl.formatMessage({ id: "actions_name" })}</h5>
<Form.Group>
<SettingSection headingID="actions_name">
<Form.Group className="content">
{renderEnableButton()}
{renderTempCancelButton()}
</Form.Group>
{renderAllowedIPs()}
<Form.Group>
<Form.Group className="content">
<h6>
{intl.formatMessage({ id: "config.dlna.recent_ip_addresses" })}
</h6>
@@ -553,18 +453,9 @@ export const SettingsDLNAPanel: React.FC = () => {
</Button>
</Form.Group>
</Form.Group>
</Form.Group>
</SettingSection>
<hr />
<Formik
initialValues={initialValues}
validationSchema={schema}
onSubmit={(values) => onSave(values)}
enableReinitialize
>
<DLNASettingsForm />
</Formik>
</div>
);
};

View File

@@ -0,0 +1,303 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { LoadingIndicator } from "src/components/Shared";
import { SettingSection } from "./SettingSection";
import {
BooleanSetting,
ModalSetting,
NumberSetting,
SelectSetting,
StringSetting,
} from "./Inputs";
import { SettingStateContext } from "./context";
import {
VideoPreviewInput,
VideoPreviewSettingsInput,
} from "./GeneratePreviewOptions";
export const SettingsConfigurationPanel: React.FC = () => {
const { general, loading, error, saveGeneral } = React.useContext(
SettingStateContext
);
const transcodeQualities = [
GQL.StreamingResolutionEnum.Low,
GQL.StreamingResolutionEnum.Standard,
GQL.StreamingResolutionEnum.StandardHd,
GQL.StreamingResolutionEnum.FullHd,
GQL.StreamingResolutionEnum.FourK,
GQL.StreamingResolutionEnum.Original,
].map(resolutionToString);
function resolutionToString(r: GQL.StreamingResolutionEnum | undefined) {
switch (r) {
case GQL.StreamingResolutionEnum.Low:
return "240p";
case GQL.StreamingResolutionEnum.Standard:
return "480p";
case GQL.StreamingResolutionEnum.StandardHd:
return "720p";
case GQL.StreamingResolutionEnum.FullHd:
return "1080p";
case GQL.StreamingResolutionEnum.FourK:
return "4k";
case GQL.StreamingResolutionEnum.Original:
return "Original";
}
return "Original";
}
function translateQuality(quality: string) {
switch (quality) {
case "240p":
return GQL.StreamingResolutionEnum.Low;
case "480p":
return GQL.StreamingResolutionEnum.Standard;
case "720p":
return GQL.StreamingResolutionEnum.StandardHd;
case "1080p":
return GQL.StreamingResolutionEnum.FullHd;
case "4k":
return GQL.StreamingResolutionEnum.FourK;
case "Original":
return GQL.StreamingResolutionEnum.Original;
}
return GQL.StreamingResolutionEnum.Original;
}
const namingHashAlgorithms = [
GQL.HashAlgorithm.Md5,
GQL.HashAlgorithm.Oshash,
].map(namingHashToString);
function namingHashToString(value: GQL.HashAlgorithm | undefined) {
switch (value) {
case GQL.HashAlgorithm.Oshash:
return "oshash";
case GQL.HashAlgorithm.Md5:
return "MD5";
}
return "MD5";
}
function translateNamingHash(value: string) {
switch (value) {
case "oshash":
return GQL.HashAlgorithm.Oshash;
case "MD5":
return GQL.HashAlgorithm.Md5;
}
return GQL.HashAlgorithm.Md5;
}
if (error) return <h1>{error.message}</h1>;
if (loading) return <LoadingIndicator />;
return (
<>
<SettingSection headingID="config.application_paths.heading">
<StringSetting
id="database-path"
headingID="config.general.db_path_head"
subHeadingID="config.general.sqlite_location"
value={general.databasePath ?? undefined}
onChange={(v) => saveGeneral({ databasePath: v })}
/>
<StringSetting
id="generated-path"
headingID="config.general.generated_path_head"
subHeadingID="config.general.generated_files_location"
value={general.generatedPath ?? undefined}
onChange={(v) => saveGeneral({ generatedPath: v })}
/>
<StringSetting
id="metadata-path"
headingID="config.general.metadata_path.heading"
subHeadingID="config.general.metadata_path.description"
value={general.metadataPath ?? undefined}
onChange={(v) => saveGeneral({ metadataPath: v })}
/>
<StringSetting
id="cache-path"
headingID="config.general.cache_path_head"
subHeadingID="config.general.cache_location"
value={general.cachePath ?? undefined}
onChange={(v) => saveGeneral({ cachePath: v })}
/>
<StringSetting
id="custom-performer-image-location"
headingID="config.ui.performers.options.image_location.heading"
subHeadingID="config.ui.performers.options.image_location.description"
value={general.customPerformerImageLocation ?? undefined}
onChange={(v) => saveGeneral({ customPerformerImageLocation: v })}
/>
</SettingSection>
<SettingSection headingID="config.general.hashing">
<BooleanSetting
id="calculate-md5-and-ohash"
headingID="config.general.calculate_md5_and_ohash_label"
subHeadingID="config.general.calculate_md5_and_ohash_desc"
checked={general.calculateMD5 ?? false}
onChange={(v) => saveGeneral({ calculateMD5: v })}
/>
<SelectSetting
id="generated_file_naming_hash"
headingID="config.general.generated_file_naming_hash_head"
subHeadingID="config.general.generated_file_naming_hash_desc"
value={namingHashToString(
general.videoFileNamingAlgorithm ?? undefined
)}
onChange={(v) =>
saveGeneral({ videoFileNamingAlgorithm: translateNamingHash(v) })
}
>
{namingHashAlgorithms.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</SelectSetting>
</SettingSection>
<SettingSection headingID="config.system.transcoding">
<SelectSetting
id="transcode-size"
headingID="config.general.maximum_transcode_size_head"
subHeadingID="config.general.maximum_transcode_size_desc"
onChange={(v) =>
saveGeneral({ maxTranscodeSize: translateQuality(v) })
}
value={resolutionToString(general.maxTranscodeSize ?? undefined)}
>
{transcodeQualities.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</SelectSetting>
<SelectSetting
id="streaming-transcode-size"
headingID="config.general.maximum_streaming_transcode_size_head"
subHeadingID="config.general.maximum_streaming_transcode_size_desc"
onChange={(v) =>
saveGeneral({ maxStreamingTranscodeSize: translateQuality(v) })
}
value={resolutionToString(
general.maxStreamingTranscodeSize ?? undefined
)}
>
{transcodeQualities.map((q) => (
<option key={q} value={q}>
{q}
</option>
))}
</SelectSetting>
</SettingSection>
<SettingSection headingID="config.general.parallel_scan_head">
<NumberSetting
id="parallel-tasks"
headingID="config.general.number_of_parallel_task_for_scan_generation_head"
subHeadingID="config.general.number_of_parallel_task_for_scan_generation_desc"
value={general.parallelTasks ?? undefined}
onChange={(v) => saveGeneral({ parallelTasks: v })}
/>
</SettingSection>
<SettingSection headingID="config.general.preview_generation">
<SelectSetting
id="scene-gen-preview-preset"
headingID="dialogs.scene_gen.preview_preset_head"
subHeadingID="dialogs.scene_gen.preview_preset_desc"
value={general.previewPreset ?? undefined}
onChange={(v) =>
saveGeneral({
previewPreset: (v as GQL.PreviewPreset) ?? undefined,
})
}
>
{Object.keys(GQL.PreviewPreset).map((p) => (
<option value={p.toLowerCase()} key={p}>
{p}
</option>
))}
</SelectSetting>
<BooleanSetting
id="preview-include-audio"
headingID="config.general.include_audio_head"
subHeadingID="config.general.include_audio_desc"
checked={general.previewAudio ?? false}
onChange={(v) => saveGeneral({ previewAudio: v })}
/>
<ModalSetting<VideoPreviewSettingsInput>
id="video-preview-settings"
headingID="dialogs.scene_gen.preview_generation_options"
value={{
previewExcludeEnd: general.previewExcludeEnd,
previewExcludeStart: general.previewExcludeStart,
previewSegmentDuration: general.previewSegmentDuration,
previewSegments: general.previewSegments,
}}
onChange={(v) => saveGeneral(v)}
renderField={(value, setValue) => (
<VideoPreviewInput value={value ?? {}} setValue={setValue} />
)}
renderValue={() => {
return <></>;
}}
/>
</SettingSection>
<SettingSection headingID="config.general.logging">
<StringSetting
headingID="config.general.auth.log_file"
subHeadingID="config.general.auth.log_file_desc"
value={general.logFile ?? undefined}
onChange={(v) => saveGeneral({ logFile: v })}
/>
<BooleanSetting
id="log-terminal"
headingID="config.general.auth.log_to_terminal"
subHeadingID="config.general.auth.log_to_terminal_desc"
checked={general.logOut ?? false}
onChange={(v) => saveGeneral({ logOut: v })}
/>
<SelectSetting
id="log-level"
headingID="config.logs.log_level"
onChange={(v) => saveGeneral({ logLevel: v })}
value={general.logLevel ?? undefined}
>
{["Trace", "Debug", "Info", "Warning", "Error"].map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</SelectSetting>
<BooleanSetting
id="log-http"
headingID="config.general.auth.log_http"
subHeadingID="config.general.auth.log_http_desc"
checked={general.logAccess ?? false}
onChange={(v) => saveGeneral({ logAccess: v })}
/>
</SettingSection>
</>
);
};

View File

@@ -1,26 +1,34 @@
import React from "react";
import { Form } from "react-bootstrap";
import { Button } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import { Setting } from "./Inputs";
import { SettingSection } from "./SettingSection";
export const SettingsToolsPanel: React.FC = () => {
return (
<>
<h4>
<FormattedMessage id="config.tools.scene_tools" />
</h4>
<Form.Group>
<SettingSection headingID="config.tools.scene_tools">
<Setting
heading={
<Link to="/sceneFilenameParser">
<Button>
<FormattedMessage id="config.tools.scene_filename_parser.title" />
</Button>
</Link>
</Form.Group>
}
/>
<Form.Group>
<Setting
heading={
<Link to="/sceneDuplicateChecker">
<Button>
<FormattedMessage id="config.tools.scene_duplicate_checker" />
</Button>
</Link>
</Form.Group>
}
/>
</SettingSection>
</>
);
};

View File

@@ -1,142 +1,172 @@
import React, { useState } from "react";
import { Button, Form, InputGroup } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Icon } from "src/components/Shared";
import { Button, Form } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { SettingSection } from "./SettingSection";
import * as GQL from "src/core/generated-graphql";
import { SettingModal } from "./Inputs";
interface IInstanceProps {
instance: IStashBoxInstance;
onSave: (instance: IStashBoxInstance) => void;
onDelete: (id: number) => void;
isMulti: boolean;
export interface IStashBoxModal {
value: GQL.StashBoxInput;
close: (v?: GQL.StashBoxInput) => void;
}
const Instance: React.FC<IInstanceProps> = ({
instance,
onSave,
onDelete,
isMulti,
}) => {
export const StashBoxModal: React.FC<IStashBoxModal> = ({ value, close }) => {
const intl = useIntl();
const handleInput = (key: string, value: string) => {
const newObj = {
...instance,
[key]: value,
};
onSave(newObj);
};
return (
<Form.Group className="row no-gutters">
<InputGroup className="col">
<SettingModal<GQL.StashBoxInput>
headingID="config.stashbox.title"
value={value}
renderField={(v, setValue) => (
<>
<Form.Group id="stashbox-name">
<h6>
{intl.formatMessage({
id: "config.stashbox.name",
})}
</h6>
<Form.Control
placeholder={intl.formatMessage({ id: "config.stashbox.name" })}
className="text-input col-3 stash-box-name"
value={instance?.name}
isValid={!isMulti || (instance?.name?.length ?? 0) > 0}
onInput={(e: React.ChangeEvent<HTMLInputElement>) =>
handleInput("name", e.currentTarget.value)
className="text-input stash-box-name"
value={v?.name}
isValid={(v?.name?.length ?? 0) > 0}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setValue({ ...v!, name: e.currentTarget.value })
}
/>
</Form.Group>
<Form.Group id="stashbox-name">
<h6>
{intl.formatMessage({
id: "config.stashbox.graphql_endpoint",
})}
</h6>
<Form.Control
placeholder={intl.formatMessage({
id: "config.stashbox.graphql_endpoint",
})}
className="text-input col-3 stash-box-endpoint"
value={instance?.endpoint}
isValid={(instance?.endpoint?.length ?? 0) > 0}
onInput={(e: React.ChangeEvent<HTMLInputElement>) =>
handleInput("endpoint", e.currentTarget.value.trim())
className="text-input stash-box-endpoint"
value={v?.endpoint}
isValid={(v?.endpoint?.length ?? 0) > 0}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setValue({ ...v!, endpoint: e.currentTarget.value.trim() })
}
/>
<Form.Control
placeholder={intl.formatMessage({ id: "config.stashbox.api_key" })}
className="text-input col-3 stash-box-apikey"
value={instance?.api_key}
isValid={(instance?.api_key?.length ?? 0) > 0}
onInput={(e: React.ChangeEvent<HTMLInputElement>) =>
handleInput("api_key", e.currentTarget.value.trim())
}
/>
<InputGroup.Append>
<Button
className=""
variant="danger"
title={intl.formatMessage({ id: "actions.delete" })}
onClick={() => onDelete(instance.index)}
>
<Icon icon="minus" />
</Button>
</InputGroup.Append>
</InputGroup>
</Form.Group>
<Form.Group id="stashbox-name">
<h6>
{intl.formatMessage({
id: "config.stashbox.api_key",
})}
</h6>
<Form.Control
placeholder={intl.formatMessage({
id: "config.stashbox.api_key",
})}
className="text-input stash-box-apikey"
value={v?.api_key}
isValid={(v?.api_key?.length ?? 0) > 0}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setValue({ ...v!, api_key: e.currentTarget.value.trim() })
}
/>
</Form.Group>
</>
)}
close={close}
/>
);
};
interface IStashBoxConfigurationProps {
boxes: IStashBoxInstance[];
saveBoxes: (boxes: IStashBoxInstance[]) => void;
interface IStashBoxSetting {
value: GQL.StashBoxInput[];
onChange: (v: GQL.StashBoxInput[]) => void;
}
export interface IStashBoxInstance {
name?: string;
endpoint?: string;
api_key?: string;
index: number;
}
export const StashBoxConfiguration: React.FC<IStashBoxConfigurationProps> = ({
boxes,
saveBoxes,
export const StashBoxSetting: React.FC<IStashBoxSetting> = ({
value,
onChange,
}) => {
const intl = useIntl();
const [index, setIndex] = useState(1000);
const [isCreating, setIsCreating] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | undefined>();
const handleSave = (instance: IStashBoxInstance) =>
saveBoxes(
boxes.map((box) => (box.index === instance.index ? instance : box))
);
const handleDelete = (id: number) =>
saveBoxes(boxes.filter((box) => box.index !== id));
const handleAdd = () => {
saveBoxes([...boxes, { index }]);
setIndex(index + 1);
};
function onEdit(index: number) {
setEditingIndex(index);
}
function onDelete(index: number) {
onChange(value.filter((v, i) => i !== index));
}
function onNew() {
setIsCreating(true);
}
return (
<Form.Group>
<h6>{intl.formatMessage({ id: "config.stashbox.title" })}</h6>
{boxes.length > 0 && (
<div className="row no-gutters">
<h6 className="col-3 ml-1">
{intl.formatMessage({ id: "config.stashbox.name" })}
</h6>
<h6 className="col-3 ml-1">
{intl.formatMessage({ id: "config.stashbox.endpoint" })}
</h6>
<h6 className="col-3 ml-1">
{intl.formatMessage({ id: "config.general.auth.api_key" })}
</h6>
</div>
)}
{boxes.map((instance) => (
<Instance
instance={instance}
onSave={handleSave}
onDelete={handleDelete}
key={instance.index}
isMulti={boxes.length > 1}
/>
))}
<Button
className="minimal"
title={intl.formatMessage({ id: "config.stashbox.add_instance" })}
onClick={handleAdd}
<SettingSection
id="stash-boxes"
headingID="config.stashbox.title"
subHeadingID="config.stashbox.description"
>
<Icon icon="plus" />
{isCreating ? (
<StashBoxModal
value={{
endpoint: "",
api_key: "",
name: "",
}}
close={(v) => {
if (v) onChange([...value, v]);
setIsCreating(false);
}}
/>
) : undefined}
{editingIndex !== undefined ? (
<StashBoxModal
value={value[editingIndex]}
close={(v) => {
if (v)
onChange(
value.map((vv, index) => {
if (index === editingIndex) {
return v;
}
return vv;
})
);
setEditingIndex(undefined);
}}
/>
) : undefined}
{value.map((b, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={index} className="setting">
<div>
<h3>{b.name ?? `#${index}`}</h3>
<div className="value">{b.endpoint ?? ""}</div>
</div>
<div>
<Button onClick={() => onEdit(index)}>
<FormattedMessage id="actions.edit" />
</Button>
<Form.Text className="text-muted">
{intl.formatMessage({ id: "config.stashbox.description" })}
</Form.Text>
</Form.Group>
<Button variant="danger" onClick={() => onDelete(index)}>
<FormattedMessage id="actions.delete" />
</Button>
</div>
</div>
))}
<div className="setting">
<div />
<div>
<Button onClick={() => onNew()}>
<FormattedMessage id="actions.add" />
</Button>
</div>
</div>
</SettingSection>
);
};

View File

@@ -1,18 +1,27 @@
import React, { useState } from "react";
import { Button, Form, Row, Col } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Button, Form, Row, Col, Dropdown } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { Icon } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql";
import { FolderSelectDialog } from "../Shared/FolderSelect/FolderSelectDialog";
import { BooleanSetting } from "./Inputs";
import { SettingSection } from "./SettingSection";
interface IStashProps {
index: number;
stash: GQL.StashConfig;
onSave: (instance: GQL.StashConfig) => void;
onEdit: () => void;
onDelete: () => void;
}
const Stash: React.FC<IStashProps> = ({ index, stash, onSave, onDelete }) => {
const Stash: React.FC<IStashProps> = ({
index,
stash,
onSave,
onEdit,
onDelete,
}) => {
// eslint-disable-next-line
const handleInput = (key: string, value: any) => {
const newObj = {
@@ -22,38 +31,58 @@ const Stash: React.FC<IStashProps> = ({ index, stash, onSave, onDelete }) => {
onSave(newObj);
};
const intl = useIntl();
const classAdd = index % 2 === 1 ? "bg-dark" : "";
return (
<Row className={`align-items-center ${classAdd}`}>
<Form.Label column xs={4}>
<Row className={`stash-row align-items-center ${classAdd}`}>
<Form.Label column md={7}>
{stash.path}
</Form.Label>
<Col xs={3}>
<Form.Check
id="stash-exclude-video"
checked={stash.excludeVideo}
onChange={() => handleInput("excludeVideo", !stash.excludeVideo)}
<Col md={2} xs={4} className="col form-label">
{/* NOTE - language is opposite to meaning:
internally exclude flags, displayed as include */}
<div>
<h6 className="d-md-none">
<FormattedMessage id="videos" />
</h6>
<BooleanSetting
id={`stash-exclude-video-${index}`}
checked={!stash.excludeVideo}
onChange={(v) => handleInput("excludeVideo", !v)}
/>
</div>
</Col>
<Col xs={3}>
<Form.Check
id="stash-exclude-image"
checked={stash.excludeImage}
onChange={() => handleInput("excludeImage", !stash.excludeImage)}
<Col md={2} xs={4} className="col-form-label">
<div>
<h6 className="d-md-none">
<FormattedMessage id="images" />
</h6>
<BooleanSetting
id={`stash-exclude-image-${index}`}
checked={!stash.excludeImage}
onChange={(v) => handleInput("excludeImage", !v)}
/>
</div>
</Col>
<Col xs={2}>
<Button
size="sm"
variant="danger"
title={intl.formatMessage({ id: "actions.delete" })}
onClick={() => onDelete()}
<Col className="justify-content-end" xs={4} md={1}>
<Dropdown className="text-right">
<Dropdown.Toggle
variant="minimal"
id={`stash-menu-${index}`}
className="minimal"
>
<Icon icon="minus" />
</Button>
<Icon icon="ellipsis-v" />
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white">
<Dropdown.Item onClick={() => onEdit()}>
<FormattedMessage id="actions.edit" />
</Dropdown.Item>
<Dropdown.Item onClick={() => onDelete()}>
<FormattedMessage id="actions.delete" />
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</Col>
</Row>
);
@@ -68,51 +97,75 @@ const StashConfiguration: React.FC<IStashConfigurationProps> = ({
stashes,
setStashes,
}) => {
const [isDisplayingDialog, setIsDisplayingDialog] = useState<boolean>(false);
const [isCreating, setIsCreating] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | undefined>();
function onEdit(index: number) {
setEditingIndex(index);
}
function onDelete(index: number) {
setStashes(stashes.filter((v, i) => i !== index));
}
function onNew() {
setIsCreating(true);
}
const handleSave = (index: number, stash: GQL.StashConfig) =>
setStashes(stashes.map((s, i) => (i === index ? stash : s)));
const handleDelete = (index: number) =>
setStashes(stashes.filter((s, i) => i !== index));
const handleAdd = (folder?: string) => {
setIsDisplayingDialog(false);
if (!folder) {
return;
}
setStashes([
...stashes,
{
path: folder,
excludeImage: false,
excludeVideo: false,
},
]);
};
function maybeRenderDialog() {
if (!isDisplayingDialog) {
return;
}
return <FolderSelectDialog onClose={handleAdd} />;
}
return (
<>
{maybeRenderDialog()}
<Form.Group>
{isCreating ? (
<FolderSelectDialog
onClose={(v) => {
if (v)
setStashes([
...stashes,
{
path: v,
excludeVideo: false,
excludeImage: false,
},
]);
setIsCreating(false);
}}
/>
) : undefined}
{editingIndex !== undefined ? (
<FolderSelectDialog
defaultValue={stashes[editingIndex].path}
onClose={(v) => {
if (v)
setStashes(
stashes.map((vv, index) => {
if (index === editingIndex) {
return {
...vv,
path: v,
};
}
return vv;
})
);
setEditingIndex(undefined);
}}
/>
) : undefined}
<div className="content" id="stash-table">
{stashes.length > 0 && (
<Row>
<h6 className="col-4">
<Row className="d-none d-md-flex">
<h6 className="col-md-7">
<FormattedMessage id="path" />
</h6>
<h6 className="col-3">
<FormattedMessage id="config.general.exclude_video" />
<h6 className="col-md-2 col-4">
<FormattedMessage id="videos" />
</h6>
<h6 className="col-3">
<FormattedMessage id="config.general.exclude_image" />
<h6 className="col-md-2 col-4">
<FormattedMessage id="images" />
</h6>
</Row>
)}
@@ -121,20 +174,34 @@ const StashConfiguration: React.FC<IStashConfigurationProps> = ({
index={index}
stash={stash}
onSave={(s) => handleSave(index, s)}
onDelete={() => handleDelete(index)}
onEdit={() => onEdit(index)}
onDelete={() => onDelete(index)}
key={stash.path}
/>
))}
<Button
className="mt-2"
variant="secondary"
onClick={() => setIsDisplayingDialog(true)}
>
<Button className="mt-2" variant="secondary" onClick={() => onNew()}>
<FormattedMessage id="actions.add_directory" />
</Button>
</Form.Group>
</div>
</>
);
};
interface IStashSetting {
value: GQL.StashConfigInput[];
onChange: (v: GQL.StashConfigInput[]) => void;
}
export const StashSetting: React.FC<IStashSetting> = ({ value, onChange }) => {
return (
<SettingSection
id="stashes"
headingID="library"
subHeadingID="config.general.directory_locations_to_your_content"
>
<StashConfiguration stashes={value} setStashes={(v) => onChange(v)} />
</SettingSection>
);
};
export default StashConfiguration;

View File

@@ -12,8 +12,11 @@ import { useToast } from "src/hooks";
import { downloadFile } from "src/utils";
import { Modal } from "../../Shared";
import { ImportDialog } from "./ImportDialog";
import { Task } from "./Task";
import * as GQL from "src/core/generated-graphql";
import { SettingSection } from "../SettingSection";
import { BooleanSetting, Setting } from "../Inputs";
import { ManualLink } from "src/components/Help/Manual";
import { Icon } from "src/components/Shared";
interface ICleanOptions {
options: GQL.CleanMetadataInput;
@@ -24,21 +27,19 @@ const CleanOptions: React.FC<ICleanOptions> = ({
options,
setOptions: setOptionsState,
}) => {
const intl = useIntl();
function setOptions(input: Partial<GQL.CleanMetadataInput>) {
setOptionsState({ ...options, ...input });
}
return (
<Form.Group>
<Form.Check
<>
<BooleanSetting
id="clean-dryrun"
checked={options.dryRun}
label={intl.formatMessage({ id: "config.tasks.only_dry_run" })}
onChange={() => setOptions({ dryRun: !options.dryRun })}
headingID="config.tasks.only_dry_run"
onChange={(v) => setOptions({ dryRun: v })}
/>
</Form.Group>
</>
);
};
@@ -213,19 +214,19 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
{renderImportDialog()}
{renderCleanDialog()}
<Form.Group>
<div className="task-group">
<h5>{intl.formatMessage({ id: "config.tasks.maintenance" })}</h5>
<Task
headingID="actions.clean"
description={intl.formatMessage({
id: "config.tasks.cleanup_desc",
})}
<SettingSection headingID="config.tasks.maintenance">
<div className="setting-group">
<Setting
heading={
<>
<FormattedMessage id="actions.clean" />
<ManualLink tab="Tasks">
<Icon icon="question-circle" />
</ManualLink>
</>
}
subHeadingID="config.tasks.cleanup_desc"
>
<CleanOptions
options={cleanOptions}
setOptions={(o) => setCleanOptions(o)}
/>
<Button
variant="danger"
type="submit"
@@ -233,19 +234,18 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
>
<FormattedMessage id="actions.clean" />
</Button>
</Task>
</Setting>
<CleanOptions
options={cleanOptions}
setOptions={(o) => setCleanOptions(o)}
/>
</div>
</Form.Group>
</SettingSection>
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "metadata" })}</h5>
<div className="task-group">
<Task
description={intl.formatMessage({
id: "config.tasks.export_to_json",
})}
<SettingSection headingID="metadata">
<Setting
headingID="actions.full_export"
subHeadingID="config.tasks.export_to_json"
>
<Button
id="export"
@@ -255,12 +255,11 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
>
<FormattedMessage id="actions.full_export" />
</Button>
</Task>
</Setting>
<Task
description={intl.formatMessage({
id: "config.tasks.import_from_exported_json",
})}
<Setting
headingID="actions.full_import"
subHeadingID="config.tasks.import_from_exported_json"
>
<Button
id="import"
@@ -270,12 +269,11 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
>
<FormattedMessage id="actions.full_import" />
</Button>
</Task>
</Setting>
<Task
description={intl.formatMessage({
id: "config.tasks.incremental_import",
})}
<Setting
headingID="actions.import_from_file"
subHeadingID="config.tasks.incremental_import"
>
<Button
id="partial-import"
@@ -285,17 +283,13 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
>
<FormattedMessage id="actions.import_from_file" />
</Button>
</Task>
</div>
</Form.Group>
</Setting>
</SettingSection>
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "actions.backup" })}</h5>
<div className="task-group">
<Task
description={intl.formatMessage(
<SettingSection headingID="actions.backup">
<Setting
headingID="actions.backup"
subHeading={intl.formatMessage(
{ id: "config.tasks.backup_database" },
{
filename_format: (
@@ -314,12 +308,11 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
>
<FormattedMessage id="actions.backup" />
</Button>
</Task>
</Setting>
<Task
description={intl.formatMessage({
id: "config.tasks.backup_and_download",
})}
<Setting
headingID="actions.download_backup"
subHeadingID="config.tasks.backup_and_download"
>
<Button
id="backupDownload"
@@ -329,20 +322,13 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
>
<FormattedMessage id="actions.download_backup" />
</Button>
</Task>
</div>
</Form.Group>
</Setting>
</SettingSection>
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "config.tasks.migrations" })}</h5>
<div className="task-group">
<Task
description={intl.formatMessage({
id: "config.tasks.migrate_hash_files",
})}
<SettingSection headingID="config.tasks.migrations">
<Setting
headingID="actions.rename_gen_files"
subHeadingID="config.tasks.migrate_hash_files"
>
<Button
id="migrateHashNaming"
@@ -351,9 +337,8 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
>
<FormattedMessage id="actions.rename_gen_files" />
</Button>
</Task>
</div>
</Form.Group>
</Setting>
</SettingSection>
</Form.Group>
);
};

View File

@@ -1,7 +1,10 @@
import React, { useState } from "react";
import { Form, Button, Collapse } from "react-bootstrap";
import { Icon } from "src/components/Shared";
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { BooleanSetting, ModalSetting } from "../Inputs";
import {
VideoPreviewInput,
VideoPreviewSettingsInput,
} from "../GeneratePreviewOptions";
import { useIntl } from "react-intl";
interface IGenerateOptions {
@@ -15,8 +18,6 @@ export const GenerateOptions: React.FC<IGenerateOptions> = ({
}) => {
const intl = useIntl();
const [previewOptionsOpen, setPreviewOptionsOpen] = useState(false);
const previewOptions: GQL.GeneratePreviewOptionsInput =
options.previewOptions ?? {};
@@ -24,275 +25,110 @@ export const GenerateOptions: React.FC<IGenerateOptions> = ({
setOptionsState({ ...options, ...input });
}
function setPreviewOptions(input: Partial<GQL.GeneratePreviewOptionsInput>) {
setOptions({
previewOptions: {
...previewOptions,
...input,
},
});
}
return (
<Form.Group>
<Form.Group>
<Form.Check
<>
<BooleanSetting
id="preview-task"
checked={options.previews ?? false}
label={intl.formatMessage({
id: "dialogs.scene_gen.video_previews",
})}
onChange={() => setOptions({ previews: !options.previews })}
headingID="dialogs.scene_gen.video_previews"
tooltipID="dialogs.scene_gen.video_previews_tooltip"
onChange={(v) => setOptions({ previews: v })}
/>
<div className="d-flex flex-row">
<div></div>
<Form.Check
<BooleanSetting
className="sub-setting"
id="image-preview-task"
checked={options.imagePreviews ?? false}
disabled={!options.previews}
label={intl.formatMessage({
id: "dialogs.scene_gen.image_previews",
})}
onChange={() =>
setOptions({ imagePreviews: !options.imagePreviews })
}
className="ml-2 flex-grow"
headingID="dialogs.scene_gen.image_previews"
tooltipID="dialogs.scene_gen.image_previews_tooltip"
onChange={(v) => setOptions({ imagePreviews: v })}
/>
</div>
</Form.Group>
<Form.Group>
<Button
onClick={() => setPreviewOptionsOpen(!previewOptionsOpen)}
className="minimal pl-0 no-focus"
>
<Icon icon={previewOptionsOpen ? "chevron-down" : "chevron-right"} />
<span>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_options",
})}
</span>
</Button>
<Form.Group>
<Collapse in={previewOptionsOpen}>
<Form.Group className="mt-2">
<Form.Group id="preview-preset">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_preset_head",
})}
</h6>
<Form.Control
className="w-auto input-control"
as="select"
value={previewOptions.previewPreset ?? GQL.PreviewPreset.Slow}
onChange={(e) =>
setPreviewOptions({
previewPreset: e.currentTarget.value as GQL.PreviewPreset,
})
}
>
{Object.keys(GQL.PreviewPreset).map((p) => (
<option value={p.toLowerCase()} key={p}>
{p}
</option>
))}
</Form.Control>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_preset_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-segments">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
type="number"
value={previewOptions.previewSegments?.toString() ?? ""}
onChange={(e) =>
setPreviewOptions({
previewSegments: Number.parseInt(
e.currentTarget.value,
10
),
})
}
<ModalSetting<VideoPreviewSettingsInput>
id="video-preview-settings"
className="sub-setting"
disabled={!options.previews}
buttonText={`${intl.formatMessage({
id: "dialogs.scene_gen.preview_generation_options",
})}…`}
value={{
previewExcludeEnd: previewOptions.previewExcludeEnd,
previewExcludeStart: previewOptions.previewExcludeStart,
previewSegmentDuration: previewOptions.previewSegmentDuration,
previewSegments: previewOptions.previewSegments,
}}
onChange={(v) => setOptions({ previewOptions: v })}
renderField={(value, setValue) => (
<VideoPreviewInput value={value ?? {}} setValue={setValue} />
)}
renderValue={() => {
return <></>;
}}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-segment-duration">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_duration_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
type="number"
value={
previewOptions.previewSegmentDuration?.toString() ?? ""
}
onChange={(e) =>
setPreviewOptions({
previewSegmentDuration: Number.parseFloat(
e.currentTarget.value
),
})
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_duration_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-exclude-start">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_start_time_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
value={previewOptions.previewExcludeStart ?? ""}
onChange={(e) =>
setPreviewOptions({
previewExcludeStart: e.currentTarget.value,
})
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_start_time_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-exclude-start">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_end_time_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
value={previewOptions.previewExcludeEnd ?? ""}
onChange={(e) =>
setPreviewOptions({
previewExcludeEnd: e.currentTarget.value,
})
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_end_time_desc",
})}
</Form.Text>
</Form.Group>
</Form.Group>
</Collapse>
</Form.Group>
</Form.Group>
<Form.Group>
<Form.Check
<BooleanSetting
id="sprite-task"
checked={options.sprites ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.sprites" })}
onChange={() => setOptions({ sprites: !options.sprites })}
headingID="dialogs.scene_gen.sprites"
tooltipID="dialogs.scene_gen.sprites_tooltip"
onChange={(v) => setOptions({ sprites: v })}
/>
<Form.Group>
<Form.Check
<BooleanSetting
id="marker-task"
checked={options.markers ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.markers" })}
onChange={() => setOptions({ markers: !options.markers })}
headingID="dialogs.scene_gen.markers"
tooltipID="dialogs.scene_gen.markers_tooltip"
onChange={(v) => setOptions({ markers: v })}
/>
<div className="d-flex flex-row">
<div></div>
<Form.Group>
<Form.Check
<BooleanSetting
id="marker-image-preview-task"
className="sub-setting"
checked={options.markerImagePreviews ?? false}
disabled={!options.markers}
label={intl.formatMessage({
id: "dialogs.scene_gen.marker_image_previews",
})}
onChange={() =>
headingID="dialogs.scene_gen.marker_image_previews"
tooltipID="dialogs.scene_gen.marker_image_previews_tooltip"
onChange={(v) =>
setOptions({
markerImagePreviews: !options.markerImagePreviews,
markerImagePreviews: v,
})
}
className="ml-2 flex-grow"
/>
<Form.Check
<BooleanSetting
id="marker-screenshot-task"
className="sub-setting"
checked={options.markerScreenshots ?? false}
disabled={!options.markers}
label={intl.formatMessage({
id: "dialogs.scene_gen.marker_screenshots",
})}
onChange={() =>
setOptions({ markerScreenshots: !options.markerScreenshots })
}
className="ml-2 flex-grow"
headingID="dialogs.scene_gen.marker_screenshots"
tooltipID="dialogs.scene_gen.marker_screenshots_tooltip"
onChange={(v) => setOptions({ markerScreenshots: v })}
/>
</Form.Group>
</div>
</Form.Group>
<Form.Group>
<Form.Check
<BooleanSetting
id="transcode-task"
checked={options.transcodes ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.transcodes" })}
onChange={() => setOptions({ transcodes: !options.transcodes })}
headingID="dialogs.scene_gen.transcodes"
tooltipID="dialogs.scene_gen.transcodes_tooltip"
onChange={(v) => setOptions({ transcodes: v })}
/>
<Form.Check
<BooleanSetting
id="phash-task"
checked={options.phashes ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.phash" })}
onChange={() => setOptions({ phashes: !options.phashes })}
headingID="dialogs.scene_gen.phash"
onChange={(v) => setOptions({ phashes: v })}
/>
</Form.Group>
<Form.Group>
<Form.Check
<BooleanSetting
id="interactive-heatmap-speed-task"
checked={options.interactiveHeatmapsSpeeds ?? false}
label={intl.formatMessage({
id: "dialogs.scene_gen.interactive_heatmap_speed",
})}
onChange={() =>
setOptions({
interactiveHeatmapsSpeeds: !options.interactiveHeatmapsSpeeds,
})
}
headingID="dialogs.scene_gen.interactive_heatmap_speed"
onChange={(v) => setOptions({ interactiveHeatmapsSpeeds: v })}
/>
</Form.Group>
<hr />
<Form.Group>
<Form.Check
<BooleanSetting
id="overwrite"
checked={options.overwrite ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.overwrite" })}
onChange={() => setOptions({ overwrite: !options.overwrite })}
headingID="dialogs.scene_gen.overwrite"
onChange={(v) => setOptions({ overwrite: v })}
/>
</Form.Group>
</Form.Group>
</Form.Group>
</>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { Button, ProgressBar } from "react-bootstrap";
import { Button, Card, ProgressBar } from "react-bootstrap";
import {
mutateStopJob,
useJobQueue,
@@ -8,6 +8,7 @@ import {
import * as GQL from "src/core/generated-graphql";
import { Icon } from "src/components/Shared";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { useIntl } from "react-intl";
type JobFragment = Pick<
GQL.Job,
@@ -153,6 +154,7 @@ const Task: React.FC<IJob> = ({ job }) => {
};
export const JobTable: React.FC = () => {
const intl = useIntl();
const jobStatus = useJobQueue();
const jobsSubscribe = useJobsSubscribe();
@@ -200,12 +202,17 @@ export const JobTable: React.FC = () => {
}, [jobsSubscribe.data]);
return (
<div className="job-table">
<Card className="job-table">
<ul>
{!queue?.length ? (
<span className="empty-queue-message">
{intl.formatMessage({ id: "config.tasks.empty_queue" })}
</span>
) : undefined}
{(queue ?? []).map((j) => (
<Task job={j} key={j.id} />
))}
</ul>
</div>
</Card>
);
};

View File

@@ -15,7 +15,10 @@ import { DirectorySelectionDialog } from "./DirectorySelectionDialog";
import { ScanOptions } from "./ScanOptions";
import { useToast } from "src/hooks";
import { GenerateOptions } from "./GenerateOptions";
import { Task } from "./Task";
import { SettingSection } from "../SettingSection";
import { BooleanSetting, Setting, SettingGroup } from "../Inputs";
import { ManualLink } from "src/components/Help/Manual";
import { Icon } from "src/components/Shared";
interface IAutoTagOptions {
options: GQL.AutoTagMetadataInput;
@@ -26,13 +29,11 @@ const AutoTagOptions: React.FC<IAutoTagOptions> = ({
options,
setOptions: setOptionsState,
}) => {
const intl = useIntl();
const { performers, studios, tags } = options;
const wildcard = ["*"];
function toggle(v?: GQL.Maybe<string[]>) {
if (!v?.length) {
function set(v?: boolean) {
if (v) {
return wildcard;
}
return [];
@@ -43,26 +44,26 @@ const AutoTagOptions: React.FC<IAutoTagOptions> = ({
}
return (
<Form.Group>
<Form.Check
<>
<BooleanSetting
id="autotag-performers"
checked={!!performers?.length}
label={intl.formatMessage({ id: "performers" })}
onChange={() => setOptions({ performers: toggle(performers) })}
headingID="performers"
onChange={(v) => setOptions({ performers: set(v) })}
/>
<Form.Check
<BooleanSetting
id="autotag-studios"
checked={!!studios?.length}
label={intl.formatMessage({ id: "studios" })}
onChange={() => setOptions({ studios: toggle(studios) })}
headingID="studios"
onChange={(v) => setOptions({ studios: set(v) })}
/>
<Form.Check
<BooleanSetting
id="autotag-tags"
checked={!!tags?.length}
label={intl.formatMessage({ id: "tags" })}
onChange={() => setOptions({ tags: toggle(tags) })}
headingID="tags"
onChange={(v) => setOptions({ tags: set(v) })}
/>
</Form.Group>
</>
);
};
@@ -279,17 +280,21 @@ export const LibraryTasks: React.FC = () => {
{renderAutoTagDialog()}
{maybeRenderIdentifyDialog()}
<Form.Group>
<h5>{intl.formatMessage({ id: "library" })}</h5>
<div className="task-group">
<Task
headingID="actions.scan"
description={intl.formatMessage({
id: "config.tasks.scan_for_content_desc",
})}
>
<ScanOptions options={scanOptions} setOptions={setScanOptions} />
<SettingSection headingID="library">
<SettingGroup
settingProps={{
heading: (
<>
<FormattedMessage id="actions.scan" />
<ManualLink tab="Tasks">
<Icon icon="question-circle" />
</ManualLink>
</>
),
subHeadingID: "config.tasks.scan_for_content_desc",
}}
topLevel={
<>
<Button
variant="secondary"
type="submit"
@@ -307,13 +312,25 @@ export const LibraryTasks: React.FC = () => {
>
<FormattedMessage id="actions.selective_scan" />
</Button>
</Task>
</>
}
collapsible
>
<ScanOptions options={scanOptions} setOptions={setScanOptions} />
</SettingGroup>
</SettingSection>
<Task
headingID="config.tasks.identify.heading"
description={intl.formatMessage({
id: "config.tasks.identify.description",
})}
<SettingSection>
<Setting
heading={
<>
<FormattedMessage id="config.tasks.identify.heading" />
<ManualLink tab="Identify">
<Icon icon="question-circle" />
</ManualLink>
</>
}
subHeadingID="config.tasks.identify.description"
>
<Button
variant="secondary"
@@ -322,19 +339,24 @@ export const LibraryTasks: React.FC = () => {
>
<FormattedMessage id="actions.identify" />
</Button>
</Task>
<Task
headingID="config.tasks.auto_tagging"
description={intl.formatMessage({
id: "config.tasks.auto_tag_based_on_filenames",
})}
>
<AutoTagOptions
options={autoTagOptions}
setOptions={(o) => setAutoTagOptions(o)}
/>
</Setting>
</SettingSection>
<SettingSection>
<SettingGroup
settingProps={{
heading: (
<>
<FormattedMessage id="actions.auto_tag" />
<ManualLink tab="AutoTagging">
<Icon icon="question-circle" />
</ManualLink>
</>
),
subHeadingID: "config.tasks.auto_tag_based_on_filenames",
}}
topLevel={
<>
<Button
variant="secondary"
type="submit"
@@ -350,25 +372,31 @@ export const LibraryTasks: React.FC = () => {
>
<FormattedMessage id="actions.selective_auto_tag" />
</Button>
</Task>
</div>
</Form.Group>
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "config.tasks.generated_content" })}</h5>
<div className="task-group">
<Task
description={intl.formatMessage({
id: "config.tasks.generate_desc",
})}
</>
}
collapsible
>
<GenerateOptions
options={generateOptions}
setOptions={setGenerateOptions}
<AutoTagOptions
options={autoTagOptions}
setOptions={(o) => setAutoTagOptions(o)}
/>
</SettingGroup>
</SettingSection>
<SettingSection headingID="config.tasks.generated_content">
<SettingGroup
settingProps={{
heading: (
<>
<FormattedMessage id="actions.generate" />
<ManualLink tab="Tasks">
<Icon icon="question-circle" />
</ManualLink>
</>
),
subHeadingID: "config.tasks.generate_desc",
}}
topLevel={
<Button
variant="secondary"
type="submit"
@@ -376,9 +404,15 @@ export const LibraryTasks: React.FC = () => {
>
<FormattedMessage id="actions.generate" />
</Button>
</Task>
</div>
</Form.Group>
}
collapsible
>
<GenerateOptions
options={generateOptions}
setOptions={setGenerateOptions}
/>
</SettingGroup>
</SettingSection>
</Form.Group>
);
};

View File

@@ -4,7 +4,8 @@ import { Button, Form } from "react-bootstrap";
import { mutateRunPluginTask, usePlugins } from "src/core/StashService";
import { useToast } from "src/hooks";
import * as GQL from "src/core/generated-graphql";
import { Task } from "./Task";
import { SettingSection } from "../SettingSection";
import { Setting, SettingGroup } from "../Inputs";
type Plugin = Pick<GQL.Plugin, "id">;
type PluginTask = Pick<GQL.PluginTask, "name" | "description">;
@@ -25,19 +26,21 @@ export const PluginTasks: React.FC = () => {
);
return (
<Form.Group>
<h5>{intl.formatMessage({ id: "config.tasks.plugin_tasks" })}</h5>
<SettingSection headingID="config.tasks.plugin_tasks">
{taskPlugins.map((o) => {
return (
<Form.Group key={`${o.id}`}>
<h6>{o.name}</h6>
<div className="task-group">
<SettingGroup
key={`${o.id}`}
settingProps={{
heading: o.name,
}}
collapsible
>
{renderPluginTasks(o, o.tasks ?? [])}
</div>
</Form.Group>
</SettingGroup>
);
})}
</Form.Group>
</SettingSection>
);
}
@@ -48,7 +51,11 @@ export const PluginTasks: React.FC = () => {
return pluginTasks.map((o) => {
return (
<Task description={o.description} key={o.name}>
<Setting
heading={o.name}
subHeading={o.description ?? undefined}
key={o.name}
>
<Button
onClick={() => onPluginTaskClicked(plugin, o)}
variant="secondary"
@@ -56,7 +63,7 @@ export const PluginTasks: React.FC = () => {
>
{o.name}
</Button>
</Task>
</Setting>
);
});
}

View File

@@ -1,7 +1,6 @@
import React from "react";
import { Form } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { useIntl } from "react-intl";
import { BooleanSetting } from "../Inputs";
interface IScanOptions {
options: GQL.ScanMetadataInput;
@@ -12,8 +11,6 @@ export const ScanOptions: React.FC<IScanOptions> = ({
options,
setOptions: setOptionsState,
}) => {
const intl = useIntl();
const {
useFileMetadata,
stripFileExtension,
@@ -29,80 +26,55 @@ export const ScanOptions: React.FC<IScanOptions> = ({
}
return (
<Form.Group>
<Form.Check
<>
<BooleanSetting
id="scan-generate-previews"
headingID="config.tasks.generate_video_previews_during_scan"
tooltipID="config.tasks.generate_video_previews_during_scan_tooltip"
checked={scanGeneratePreviews ?? false}
label={intl.formatMessage({
id: "config.tasks.generate_video_previews_during_scan",
})}
onChange={() =>
setOptions({ scanGeneratePreviews: !scanGeneratePreviews })
}
onChange={(v) => setOptions({ scanGeneratePreviews: v })}
/>
<div className="d-flex flex-row">
<div></div>
<Form.Check
<BooleanSetting
id="scan-generate-image-previews"
className="sub-setting"
headingID="config.tasks.generate_previews_during_scan"
tooltipID="config.tasks.generate_previews_during_scan_tooltip"
checked={scanGenerateImagePreviews ?? false}
disabled={!scanGeneratePreviews}
label={intl.formatMessage({
id: "config.tasks.generate_previews_during_scan",
})}
onChange={() =>
setOptions({
scanGenerateImagePreviews: !scanGenerateImagePreviews,
})
}
className="ml-2 flex-grow"
onChange={(v) => setOptions({ scanGenerateImagePreviews: v })}
/>
</div>
<Form.Check
<BooleanSetting
id="scan-generate-sprites"
headingID="config.tasks.generate_sprites_during_scan"
checked={scanGenerateSprites ?? false}
label={intl.formatMessage({
id: "config.tasks.generate_sprites_during_scan",
})}
onChange={() =>
setOptions({ scanGenerateSprites: !scanGenerateSprites })
}
onChange={(v) => setOptions({ scanGenerateSprites: v })}
/>
<Form.Check
<BooleanSetting
id="scan-generate-phashes"
checked={scanGeneratePhashes ?? false}
label={intl.formatMessage({
id: "config.tasks.generate_phashes_during_scan",
})}
onChange={() =>
setOptions({ scanGeneratePhashes: !scanGeneratePhashes })
}
headingID="config.tasks.generate_phashes_during_scan"
tooltipID="config.tasks.generate_phashes_during_scan_tooltip"
onChange={(v) => setOptions({ scanGeneratePhashes: v })}
/>
<Form.Check
<BooleanSetting
id="scan-generate-thumbnails"
checked={scanGenerateThumbnails ?? false}
label={intl.formatMessage({
id: "config.tasks.generate_thumbnails_during_scan",
})}
onChange={() =>
setOptions({ scanGenerateThumbnails: !scanGenerateThumbnails })
}
headingID="config.tasks.generate_thumbnails_during_scan"
onChange={(v) => setOptions({ scanGenerateThumbnails: v })}
/>
<Form.Check
<BooleanSetting
id="strip-file-extension"
checked={stripFileExtension ?? false}
label={intl.formatMessage({
id: "config.tasks.dont_include_file_extension_as_part_of_the_title",
})}
onChange={() => setOptions({ stripFileExtension: !stripFileExtension })}
headingID="config.tasks.dont_include_file_extension_as_part_of_the_title"
onChange={(v) => setOptions({ stripFileExtension: v })}
/>
<Form.Check
<BooleanSetting
id="use-file-metadata"
checked={useFileMetadata ?? false}
label={intl.formatMessage({
id: "config.tasks.set_name_date_details_from_metadata_if_present",
})}
onChange={() => setOptions({ useFileMetadata: !useFileMetadata })}
headingID="config.tasks.set_name_date_details_from_metadata_if_present"
onChange={(v) => setOptions({ useFileMetadata: v })}
/>
</Form.Group>
</>
);
};

View File

@@ -19,18 +19,19 @@ export const SettingsTasksPanel: React.FC = () => {
}
return (
<>
<h4>{intl.formatMessage({ id: "config.tasks.job_queue" })}</h4>
<div id="tasks-panel">
<div className="tasks-panel-queue">
<h1>{intl.formatMessage({ id: "config.tasks.job_queue" })}</h1>
<JobTable />
</div>
<hr />
<div className="tasks-panel-tasks">
<LibraryTasks />
<hr />
<DataManagementTasks setIsBackupRunning={setIsBackupRunning} />
<hr />
<PluginTasks />
</>
</div>
</div>
);
};

View File

@@ -1,26 +0,0 @@
import React, { PropsWithChildren } from "react";
import { Form } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
interface ITask {
headingID?: string;
description?: React.ReactNode;
}
export const Task: React.FC<PropsWithChildren<ITask>> = ({
children,
headingID,
description,
}) => (
<div className="task">
{headingID ? (
<h6>
<FormattedMessage id={headingID} />
</h6>
) : undefined}
{children}
{description ? (
<Form.Text className="text-muted">{description}</Form.Text>
) : undefined}
</div>
);

View File

@@ -0,0 +1,419 @@
import { ApolloError } from "@apollo/client/errors";
import { debounce } from "lodash";
import React, {
useState,
useEffect,
useMemo,
useCallback,
useRef,
} from "react";
import { Spinner } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import {
useConfiguration,
useConfigureDefaults,
useConfigureDLNA,
useConfigureGeneral,
useConfigureInterface,
useConfigureScraping,
} from "src/core/StashService";
import { useToast } from "src/hooks";
import { withoutTypename } from "src/utils";
import { Icon } from "../Shared";
export interface ISettingsContextState {
loading: boolean;
error: ApolloError | undefined;
general: GQL.ConfigGeneralInput;
interface: GQL.ConfigInterfaceInput;
defaults: GQL.ConfigDefaultSettingsInput;
scraping: GQL.ConfigScrapingInput;
dlna: GQL.ConfigDlnaInput;
// apikey isn't directly settable, so expose it here
apiKey: string;
saveGeneral: (input: Partial<GQL.ConfigGeneralInput>) => void;
saveInterface: (input: Partial<GQL.ConfigInterfaceInput>) => void;
saveDefaults: (input: Partial<GQL.ConfigDefaultSettingsInput>) => void;
saveScraping: (input: Partial<GQL.ConfigScrapingInput>) => void;
saveDLNA: (input: Partial<GQL.ConfigDlnaInput>) => void;
}
export const SettingStateContext = React.createContext<ISettingsContextState>({
loading: false,
error: undefined,
general: {},
interface: {},
defaults: {},
scraping: {},
dlna: {},
apiKey: "",
saveGeneral: () => {},
saveInterface: () => {},
saveDefaults: () => {},
saveScraping: () => {},
saveDLNA: () => {},
});
export const SettingsContext: React.FC = ({ children }) => {
const Toast = useToast();
const { data, error, loading } = useConfiguration();
const initialRef = useRef(false);
const [general, setGeneral] = useState<GQL.ConfigGeneralInput>({});
const [pendingGeneral, setPendingGeneral] = useState<
GQL.ConfigGeneralInput | undefined
>();
const [updateGeneralConfig] = useConfigureGeneral();
const [iface, setIface] = useState<GQL.ConfigInterfaceInput>({});
const [pendingInterface, setPendingInterface] = useState<
GQL.ConfigInterfaceInput | undefined
>();
const [updateInterfaceConfig] = useConfigureInterface();
const [defaults, setDefaults] = useState<GQL.ConfigDefaultSettingsInput>({});
const [pendingDefaults, setPendingDefaults] = useState<
GQL.ConfigDefaultSettingsInput | undefined
>();
const [updateDefaultsConfig] = useConfigureDefaults();
const [scraping, setScraping] = useState<GQL.ConfigScrapingInput>({});
const [pendingScraping, setPendingScraping] = useState<
GQL.ConfigScrapingInput | undefined
>();
const [updateScrapingConfig] = useConfigureScraping();
const [dlna, setDLNA] = useState<GQL.ConfigDlnaInput>({});
const [pendingDLNA, setPendingDLNA] = useState<
GQL.ConfigDlnaInput | undefined
>();
const [updateDLNAConfig] = useConfigureDLNA();
const [updateSuccess, setUpdateSuccess] = useState(false);
const [apiKey, setApiKey] = useState("");
useEffect(() => {
// only initialise once - assume we have control over these settings and
// they aren't modified elsewhere
if (!data?.configuration || error || initialRef.current) return;
initialRef.current = true;
setGeneral({ ...withoutTypename(data.configuration.general) });
setIface({ ...withoutTypename(data.configuration.interface) });
setDefaults({ ...withoutTypename(data.configuration.defaults) });
setScraping({ ...withoutTypename(data.configuration.scraping) });
setDLNA({ ...withoutTypename(data.configuration.dlna) });
setApiKey(data.configuration.general.apiKey);
}, [data, error]);
const resetSuccess = useMemo(
() =>
debounce(() => {
setUpdateSuccess(false);
}, 4000),
[]
);
const onSuccess = useCallback(() => {
setUpdateSuccess(true);
resetSuccess();
}, [resetSuccess]);
// saves the configuration if no further changes are made after a half second
const saveGeneralConfig = useMemo(
() =>
debounce(async (input: GQL.ConfigGeneralInput) => {
try {
await updateGeneralConfig({
variables: {
input,
},
});
setPendingGeneral(undefined);
onSuccess();
} catch (e) {
Toast.error(e);
}
}, 500),
[Toast, updateGeneralConfig, onSuccess]
);
useEffect(() => {
if (!pendingGeneral) {
return;
}
saveGeneralConfig(pendingGeneral);
}, [pendingGeneral, saveGeneralConfig]);
function saveGeneral(input: Partial<GQL.ConfigGeneralInput>) {
if (!general) {
return;
}
setGeneral({
...general,
...input,
});
setPendingGeneral((current) => {
if (!current) {
return input;
}
return {
...current,
...input,
};
});
}
// saves the configuration if no further changes are made after a half second
const saveInterfaceConfig = useMemo(
() =>
debounce(async (input: GQL.ConfigInterfaceInput) => {
try {
await updateInterfaceConfig({
variables: {
input,
},
});
setPendingInterface(undefined);
onSuccess();
} catch (e) {
Toast.error(e);
}
}, 500),
[Toast, updateInterfaceConfig, onSuccess]
);
useEffect(() => {
if (!pendingInterface) {
return;
}
saveInterfaceConfig(pendingInterface);
}, [pendingInterface, saveInterfaceConfig]);
function saveInterface(input: Partial<GQL.ConfigInterfaceInput>) {
if (!iface) {
return;
}
setIface({
...iface,
...input,
});
setPendingInterface((current) => {
if (!current) {
return input;
}
return {
...current,
...input,
};
});
}
// saves the configuration if no further changes are made after a half second
const saveDefaultsConfig = useMemo(
() =>
debounce(async (input: GQL.ConfigDefaultSettingsInput) => {
try {
await updateDefaultsConfig({
variables: {
input,
},
});
setPendingDefaults(undefined);
onSuccess();
} catch (e) {
Toast.error(e);
}
}, 500),
[Toast, updateDefaultsConfig, onSuccess]
);
useEffect(() => {
if (!pendingDefaults) {
return;
}
saveDefaultsConfig(pendingDefaults);
}, [pendingDefaults, saveDefaultsConfig]);
function saveDefaults(input: Partial<GQL.ConfigDefaultSettingsInput>) {
if (!defaults) {
return;
}
setDefaults({
...defaults,
...input,
});
setPendingDefaults((current) => {
if (!current) {
return input;
}
return {
...current,
...input,
};
});
}
// saves the configuration if no further changes are made after a half second
const saveScrapingConfig = useMemo(
() =>
debounce(async (input: GQL.ConfigScrapingInput) => {
try {
await updateScrapingConfig({
variables: {
input,
},
});
setPendingScraping(undefined);
onSuccess();
} catch (e) {
Toast.error(e);
}
}, 500),
[Toast, updateScrapingConfig, onSuccess]
);
useEffect(() => {
if (!pendingScraping) {
return;
}
saveScrapingConfig(pendingScraping);
}, [pendingScraping, saveScrapingConfig]);
function saveScraping(input: Partial<GQL.ConfigScrapingInput>) {
if (!scraping) {
return;
}
setScraping({
...scraping,
...input,
});
setPendingScraping((current) => {
if (!current) {
return input;
}
return {
...current,
...input,
};
});
}
// saves the configuration if no further changes are made after a half second
const saveDLNAConfig = useMemo(
() =>
debounce(async (input: GQL.ConfigDlnaInput) => {
try {
await updateDLNAConfig({
variables: {
input,
},
});
setPendingDLNA(undefined);
onSuccess();
} catch (e) {
Toast.error(e);
}
}, 500),
[Toast, updateDLNAConfig, onSuccess]
);
useEffect(() => {
if (!pendingDLNA) {
return;
}
saveDLNAConfig(pendingDLNA);
}, [pendingDLNA, saveDLNAConfig]);
function saveDLNA(input: Partial<GQL.ConfigDlnaInput>) {
if (!dlna) {
return;
}
setDLNA({
...dlna,
...input,
});
setPendingDLNA((current) => {
if (!current) {
return input;
}
return {
...current,
...input,
};
});
}
function maybeRenderLoadingIndicator() {
if (
pendingGeneral ||
pendingInterface ||
pendingDefaults ||
pendingScraping ||
pendingDLNA
) {
return (
<div className="loading-indicator">
<Spinner animation="border" role="status">
<span className="sr-only">Loading...</span>
</Spinner>
</div>
);
}
if (updateSuccess) {
return (
<div className="loading-indicator">
<Icon icon="check-circle" className="fa-fw" />
</div>
);
}
}
return (
<SettingStateContext.Provider
value={{
loading,
error,
apiKey,
general,
interface: iface,
defaults,
scraping,
dlna,
saveGeneral,
saveInterface,
saveDefaults,
saveScraping,
saveDLNA,
}}
>
{maybeRenderLoadingIndicator()}
{children}
</SettingStateContext.Provider>
);
};

View File

@@ -1,3 +1,187 @@
@include media-breakpoint-up(sm) {
#settings-menu-container {
position: fixed;
}
}
#settings-container .tab-content {
max-width: 780px;
}
.setting-section {
&:not(:first-child) {
margin-top: 1.5em;
}
.card {
padding: 0;
}
h1 {
font-size: 2rem;
}
.sub-heading {
font-size: 0.8rem;
margin-top: 0.5rem;
}
.content {
padding: 15px;
width: 100%;
}
.setting {
align-items: center;
display: flex;
justify-content: space-between;
padding: 15px;
width: 100%;
&.sub-setting {
padding-left: 2rem;
}
h3 {
font-size: 1.25rem;
margin-bottom: 0;
&[title] {
cursor: help;
text-decoration: underline dotted;
}
}
&.disabled {
.custom-switch,
h3 {
opacity: 0.5;
}
}
> div:first-child {
flex-grow: 0;
}
> div:last-child {
min-width: 100px;
text-align: right;
button {
margin: 0.25rem;
}
}
&:not(:last-child) {
border-bottom: 1px solid #000;
}
.value {
font-family: "Courier New", Courier, monospace;
margin-bottom: 0.5rem;
margin-top: 0.5rem;
pre {
max-height: 250px;
width: 100%;
}
}
}
.setting-group {
&.collapsible > .setting {
cursor: pointer;
}
padding-bottom: 15px;
width: 100%;
.setting-group-collapse-button {
color: $text-muted;
font-size: 1.5rem;
padding: 0;
}
&:not(:last-child) {
border-bottom: 1px solid #000;
}
> .setting:first-child {
border-bottom: none;
padding-bottom: 0;
}
> .setting:not(:first-child),
.collapsible-section .setting {
margin-left: 2.5rem;
margin-right: 1.5rem;
padding-bottom: 10px;
padding-left: 0;
padding-top: 10px;
h3 {
font-size: 1rem;
}
&.sub-setting {
padding-left: 2rem;
}
}
.setting {
flex-wrap: wrap;
width: auto;
& > div:last-child {
margin-left: auto;
}
}
}
}
#stashes .card {
// override overflow so that menu shows correctly
overflow: visible;
}
#stash-table {
@include media-breakpoint-down(sm) {
padding-top: 0;
}
.setting {
justify-content: start;
padding: 0;
}
.stash-row .setting > div:last-child {
text-align: left;
}
}
#tasks-panel {
@media (min-width: 576px) and (min-height: 600px) {
.tasks-panel-queue {
background-color: #202b33;
margin-top: -1rem;
padding-bottom: 0.25rem;
padding-top: 1rem;
position: sticky;
top: 3rem;
z-index: 2;
}
}
h1 {
font-size: 2rem;
}
}
#setting-dialog .sub-heading {
font-size: 0.8rem;
margin-top: 0.5rem;
}
.logs {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
@@ -71,9 +255,12 @@
}
}
.job-table {
.job-table.card {
background-color: $card-bg;
height: 10em;
margin-bottom: 30px;
overflow-y: auto;
padding: 0.5rem 15px;
ul {
list-style: none;
@@ -179,3 +366,40 @@
}
}
}
.loading-indicator {
opacity: 50%;
position: fixed;
right: 30px;
z-index: 1051;
@include media-breakpoint-down(xs) {
top: 30px;
}
@include media-breakpoint-up(sm) {
bottom: 30px;
}
.fa-icon {
animation: fadeOut 2s forwards;
animation-delay: 2s;
color: $success;
height: 2rem;
margin: 0;
width: 2rem;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.empty-queue-message {
color: $text-muted;
}

View File

@@ -570,7 +570,7 @@ export const Setup: React.FC = () => {
</section>
<section className="mt-5">
<div className="d-flex justify-content-center">
<Link to="/settings?tab=configuration">
<Link to="/settings?tab=library">
<Button variant="success mx-2 p-5" onClick={() => goBack(2)}>
<FormattedMessage id="actions.finish" />
</Button>

View File

@@ -4,14 +4,20 @@ import { Button, Modal } from "react-bootstrap";
import { FolderSelect } from "./FolderSelect";
interface IProps {
defaultValue?: string;
onClose: (directory?: string) => void;
}
export const FolderSelectDialog: React.FC<IProps> = (props: IProps) => {
const [currentDirectory, setCurrentDirectory] = useState<string>("");
export const FolderSelectDialog: React.FC<IProps> = ({
defaultValue: currentValue,
onClose,
}) => {
const [currentDirectory, setCurrentDirectory] = useState<string>(
currentValue ?? ""
);
return (
<Modal show onHide={() => props.onClose()} title="">
<Modal show onHide={() => onClose()} title="">
<Modal.Header>Select Directory</Modal.Header>
<Modal.Body>
<div className="dialog-content">
@@ -22,11 +28,11 @@ export const FolderSelectDialog: React.FC<IProps> = (props: IProps) => {
</div>
</Modal.Body>
<Modal.Footer>
<Button
variant="success"
onClick={() => props.onClose(currentDirectory)}
>
<FormattedMessage id="actions.add" />
<Button variant="secondary" onClick={() => onClose()}>
<FormattedMessage id="actions.cancel" />
</Button>
<Button variant="success" onClick={() => onClose(currentDirectory)}>
<FormattedMessage id="actions.confirm" />
</Button>
</Modal.Footer>
</Modal>

View File

@@ -403,7 +403,7 @@ export const PerformerSelect: React.FC<IFilterProps> = (props) => {
const { configuration } = React.useContext(ConfigurationContext);
const defaultCreatable =
!configuration?.interface.disabledDropdownCreate.performer ?? true;
!configuration?.interface.disableDropdownCreate.performer ?? true;
const performers = data?.allPerformers ?? [];
@@ -443,7 +443,7 @@ export const StudioSelect: React.FC<
const { configuration } = React.useContext(ConfigurationContext);
const defaultCreatable =
!configuration?.interface.disabledDropdownCreate.studio ?? true;
!configuration?.interface.disableDropdownCreate.studio ?? true;
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
const studios = useMemo(
@@ -584,7 +584,7 @@ export const TagSelect: React.FC<IFilterProps & { excludeIds?: string[] }> = (
const { configuration } = React.useContext(ConfigurationContext);
const defaultCreatable =
!configuration?.interface.disabledDropdownCreate.tag ?? true;
!configuration?.interface.disableDropdownCreate.tag ?? true;
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
const tags = useMemo(

View File

@@ -640,7 +640,7 @@ export const PerformerTagger: React.FC<ITaggerProps> = ({ performers }) => {
<h5 className="text-center">
Please see{" "}
<HashLink
to="/settings?tab=configuration#stashbox"
to="/settings?tab=metadata-providers#stash-boxes"
scroll={(el) =>
el.scrollIntoView({ behavior: "smooth", block: "center" })
}

View File

@@ -737,16 +737,14 @@ export const useTagsMerge = () =>
update: deleteCache(tagMutationImpactedQueries),
});
export const useConfigureGeneral = (input: GQL.ConfigGeneralInput) =>
export const useConfigureGeneral = () =>
GQL.useConfigureGeneralMutation({
variables: { input },
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
update: deleteCache([GQL.ConfigurationDocument]),
});
export const useConfigureInterface = (input: GQL.ConfigInterfaceInput) =>
export const useConfigureInterface = () =>
GQL.useConfigureInterfaceMutation({
variables: { input },
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
update: deleteCache([GQL.ConfigurationDocument]),
});
@@ -781,9 +779,8 @@ export const useRemoveTempDLNAIP = () => GQL.useRemoveTempDlnaipMutation();
export const useLoggingSubscribe = () => GQL.useLoggingSubscribeSubscription();
export const useConfigureScraping = (input: GQL.ConfigScrapingInput) =>
export const useConfigureScraping = () =>
GQL.useConfigureScrapingMutation({
variables: { input },
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
update: deleteCache([GQL.ConfigurationDocument]),
});

View File

@@ -10,7 +10,17 @@ Stash currently identifies files by performing a quick file hash. This means tha
Stash currently ignores duplicate files. If two files contain identical content, only the first one it comes across is used.
The "Set name, data, details from metadata" option will parse the files metadata (where supported) and set the scene attributes accordingly. It has previously been noted that this information is frequently incorrect, so only use this option where you are certain that the metadata is correct in the files.
The scan task accepts the following options:
| Option | Description |
|--------|-------------|
| Generate previews | Generates video previews which play when hovering over a scene. |
| Generate animated image previews | Generates animated webp previews. Only required if the Preview Type is set to Animated Image. Requires Generate previews to be enabled. |
| Generate sprites | Generates sprites for the scene scrubber. |
| Generate perceptual hashes | Generates perceptual hashes for scene deduplication and identification. |
| Generate thumbnails for images | Generates thumbnails for image files. |
| Don't include file extension in title | By default, scenes, images and galleries have their title created using the file basename. When the flag is enabled, the file extension is stripped when setting the title. |
| Set name, date, details from embedded file metadata. | Parse the video file metadata (where supported) and set the scene attributes accordingly. It has previously been noted that this information is frequently incorrect, so only use this option where you are certain that the metadata is correct in the files. |
# Auto Tagging
See the [Auto Tagging](/help/AutoTagging.md) page.
@@ -28,6 +38,20 @@ The scanning function automatically generates a screenshot of each scene. The ge
* Transcoded versions of scenes. See below
* Image thumbnails of galleries
The generate task accepts the following options:
| Option | Description |
|--------|-------------|
| Previews | Generates video previews which play when hovering over a scene. |
| Animated image previews | Generates animated webp previews. Only required if the Preview Type is set to Animated Image. Requires Generate previews to be enabled. |
| Scene Scrubber Sprites | Generates sprites for the scene scrubber. |
| Markers Previews | Generates 20 second videos which begin at the marker timecode. |
| Marker Animated Image Previews | Generates animated webp previews for markers. Only required if the Preview Type is set to Animated Image. Requires Markers to be enabled. |
| Marker Screenshots | Generates static JPG images for markers. Only required if Preview Type is set to Static Image. Requires Marker Previews to be enabled. |
| Transcodes | MP4 conversions of unsupported video formats. Allows direct streaming instead of live transcoding. |
| Perceptual hashes | Generates perceptual hashes for scene deduplication and identification. |
| Overwrite existing generated files | By default, where a generated file exists, it is not regenerated. When this flag is enabled, then the generated files are regenerated. |
## Transcodes
Web browsers support a limited number of video and audio codecs and containers. Stash will directly stream video files where the browser supports the codecs and container. Originally, stash did not support viewing scene videos where the browser did not support the codecs/container, and generating transcodes was a way of viewing these files.

View File

@@ -159,6 +159,7 @@
"build_hash": "Build hash:",
"build_time": "Build time:",
"check_for_new_version": "Check for new version",
"latest_version": "Latest Version",
"latest_version_build_hash": "Latest Version Build Hash:",
"new_version_notice": "[NEW]",
"stash_discord": "Join our {url} channel",
@@ -167,12 +168,19 @@
"stash_wiki": "Stash {url} page",
"version": "Version"
},
"application_paths": {
"heading": "Application Paths"
},
"categories": {
"about": "About",
"interface": "Interface",
"logs": "Logs",
"metadata_providers": "Metadata Providers",
"plugins": "Plugins",
"scraping": "Scraping",
"security": "Security",
"services": "Services",
"system": "System",
"tasks": "Tasks",
"tools": "Tools"
},
@@ -195,6 +203,10 @@
"api_key_desc": "API key for external systems. Only required when username/password is configured. Username must be saved before generating API key.",
"authentication": "Authentication",
"clear_api_key": "Clear API key",
"credentials": {
"description": "Credentials to restrict access to stash.",
"heading": "Credentials"
},
"generate_api_key": "Generate API key",
"log_file": "Log file",
"log_file_desc": "Path to the file to output logging to. Blank to disable file logging. Requires restart.",
@@ -224,8 +236,6 @@
"create_galleries_from_folders_label": "Create galleries from folders containing images",
"db_path_head": "Database Path",
"directory_locations_to_your_content": "Directory locations to your content",
"exclude_image": "Exclude Image",
"exclude_video": "Exclude Video",
"excluded_image_gallery_patterns_desc": "Regexps of image and gallery files/paths to exclude from Scan and add to Clean",
"excluded_image_gallery_patterns_head": "Excluded Image/Gallery Patterns",
"excluded_video_patterns_desc": "Regexps of video files/paths to exclude from Scan and add to Clean",
@@ -262,6 +272,11 @@
"video_ext_head": "Video Extensions",
"video_head": "Video"
},
"library": {
"exclusions": "Exclusions",
"gallery_and_image_options": "Gallery and Image options",
"media_content_extensions": "Media content extensions"
},
"logs": {
"log_level": "Log Level"
},
@@ -289,6 +304,9 @@
"name": "Name",
"title": "Stash-box Endpoints"
},
"system": {
"transcoding": "Transcoding"
},
"tasks": {
"added_job_to_queue": "Added {operation_name} to job queue",
"auto_tag": {
@@ -304,17 +322,21 @@
"data_management": "Data management",
"defaults_set": "Defaults have been set and will be used when clicking the {action} button on the Tasks page.",
"dont_include_file_extension_as_part_of_the_title": "Don't include file extension as part of the title",
"empty_queue": "No tasks are currently running.",
"export_to_json": "Exports the database content into JSON format in the metadata directory.",
"generate": {
"generating_scenes": "Generating for {num} {scene}",
"generating_from_paths": "Generating for scenes from the following paths"
},
"generate_desc": "Generate supporting image, sprite, video, vtt and other files.",
"generate_phashes_during_scan": "Generate perceptual hashes during scan (for deduplication and scene identification)",
"generate_previews_during_scan": "Generate image previews during scan (animated WebP previews, only required if Preview Type is set to Animated Image)",
"generate_sprites_during_scan": "Generate sprites during scan (for the scene scrubber)",
"generate_thumbnails_during_scan": "Generate thumbnails for images during scan.",
"generate_video_previews_during_scan": "Generate previews during scan (video previews which play when hovering over a scene)",
"generate_phashes_during_scan": "Generate perceptual hashes",
"generate_phashes_during_scan_tooltip": "For deduplication and scene identification.",
"generate_previews_during_scan": "Generate animated image previews",
"generate_previews_during_scan_tooltip": "Generate animated WebP previews, only required if Preview Type is set to Animated Image.",
"generate_sprites_during_scan": "Generate scrubber sprites",
"generate_thumbnails_during_scan": "Generate thumbnails for images",
"generate_video_previews_during_scan": "Generate previews",
"generate_video_previews_during_scan_tooltip": "Generate video previews which play when hovering over a scene",
"generated_content": "Generated Content",
"identify": {
"and_create_missing": "and create missing",
@@ -338,7 +360,7 @@
},
"import_from_exported_json": "Import from exported JSON in the metadata directory. Wipes the existing database.",
"incremental_import": "Incremental import from a supplied export zip file.",
"job_queue": "Job Queue",
"job_queue": "Task Queue",
"maintenance": "Maintenance",
"migrate_hash_files": "Used after changing the Generated file naming hash to rename existing generated files to the new hash format.",
"migrations": "Migrations",
@@ -349,7 +371,7 @@
"scanning_all_paths": "Scanning all paths"
},
"scan_for_content_desc": "Scan for new content and add it to the database.",
"set_name_date_details_from_metadata_if_present": "Set name, date, details from embedded file metadata (if present)"
"set_name_date_details_from_metadata_if_present": "Set name, date, details from embedded file metadata"
},
"tools": {
"scene_duplicate_checker": "Scene Duplicate Checker",
@@ -371,6 +393,7 @@
"scene_tools": "Scene Tools"
},
"ui": {
"basic_settings": "Basic Settings",
"custom_css": {
"description": "Page must be reloaded for changes to take effect.",
"heading": "Custom CSS",
@@ -405,6 +428,7 @@
"heading": "Handy Connection Key"
},
"images": {
"heading": "Images",
"options": {
"write_image_thumbnails": {
"description": "Write image thumbnails to disk when generated on-the-fly",
@@ -412,6 +436,7 @@
}
}
},
"interactive_options": "Interactive Options",
"language": {
"heading": "Language"
},
@@ -559,17 +584,22 @@
},
"overwrite_filter_confirm": "Are you sure you want to overwrite existing saved query {entityName}?",
"scene_gen": {
"image_previews": "Image Previews (animated WebP previews, only required if Preview Type is set to Animated Image)",
"image_previews": "Animated Image Previews",
"image_previews_tooltip": "Animated WebP previews, only required if Preview Type is set to Animated Image.",
"interactive_heatmap_speed": "Generate heatmaps and speeds for interactive scenes",
"marker_image_previews": "Marker Previews (animated WebP previews, only required if Preview Type is set to Animated Image)",
"marker_screenshots": "Marker Screenshots (static JPG image, only required if Preview Type is set to Static Image)",
"markers": "Markers (20 second videos which begin at the given timecode)",
"marker_image_previews": "Marker Animated Image Previews",
"marker_image_previews_tooltip": "Animated marker WebP previews, only required if Preview Type is set to Animated Image.",
"marker_screenshots": "Marker Screenshots",
"marker_screenshots_tooltip": "Marker static JPG images, only required if Preview Type is set to Static Image.",
"markers": "Marker Previews",
"markers_tooltip": "20 second videos which begin at the given timecode.",
"overwrite": "Overwrite existing generated files",
"phash": "Perceptual hashes (for deduplication)",
"preview_exclude_end_time_desc": "Exclude the last x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.",
"preview_exclude_end_time_head": "Exclude end time",
"preview_exclude_start_time_desc": "Exclude the first x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.",
"preview_exclude_start_time_head": "Exclude start time",
"preview_generation_options": "Preview Generation Options",
"preview_options": "Preview Options",
"preview_preset_desc": "The preset regulates size, quality and encoding time of preview generation. Presets beyond “slow” have diminishing returns and are not recommended.",
"preview_preset_head": "Preview encoding preset",
@@ -577,9 +607,12 @@
"preview_seg_count_head": "Number of segments in preview",
"preview_seg_duration_desc": "Duration of each preview segment, in seconds.",
"preview_seg_duration_head": "Preview segment duration",
"sprites": "Sprites (for the scene scrubber)",
"transcodes": "Transcodes (MP4 conversions of unsupported video formats)",
"video_previews": "Previews (video previews which play when hovering over a scene)"
"sprites": "Scene Scrubber Sprites",
"sprites_tooltip": "Sprites (for the scene scrubber)",
"transcodes": "Transcodes",
"transcodes_tooltip": "MP4 conversions of unsupported video formats",
"video_previews": "Previews",
"video_previews_tooltip": "Video previews which play when hovering over a scene"
},
"scenes_found": "{count} scenes found",
"scrape_entity_query": "{entity_type} Scrape Query",
@@ -853,6 +886,7 @@
"up-dir": "Up a directory",
"updated_at": "Updated At",
"url": "URL",
"videos": "Videos",
"weight": "Weight",
"years_old": "years old"
}