From d176e9f192b67282266349649035af3e1ed18591 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 14 Dec 2021 15:06:05 +1100 Subject: [PATCH] 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 --- graphql/documents/data/config.graphql | 2 +- graphql/schema/types/config.graphql | 21 +- pkg/api/resolver_mutation_configure.go | 60 +- pkg/api/resolver_query_configuration.go | 13 +- ui/v2.5/src/App.tsx | 15 +- .../components/Changelog/versions/v0120.md | 1 + .../src/components/Dialogs/GenerateDialog.tsx | 5 +- ui/v2.5/src/components/Help/Manual.tsx | 68 +- ui/v2.5/src/components/MainNavbar.tsx | 9 +- .../Settings/GeneratePreviewOptions.tsx | 133 +++ ui/v2.5/src/components/Settings/Inputs.tsx | 467 ++++++++ .../components/Settings/SettingSection.tsx | 31 + ui/v2.5/src/components/Settings/Settings.tsx | 164 +-- .../Settings/SettingsAboutPanel.tsx | 189 ++- .../Settings/SettingsConfigurationPanel.tsx | 1054 ----------------- .../SettingsInterfacePanel/CheckboxGroup.tsx | 20 +- .../SettingsInterfacePanel.tsx | 680 ++++------- .../Settings/SettingsLibraryPanel.tsx | 159 +++ .../components/Settings/SettingsLogsPanel.tsx | 26 +- .../Settings/SettingsPluginsPanel.tsx | 166 +-- .../Settings/SettingsScrapingPanel.tsx | 218 ++-- .../Settings/SettingsSecurityPanel.tsx | 176 +++ ...LNAPanel.tsx => SettingsServicesPanel.tsx} | 221 +--- .../Settings/SettingsSystemPanel.tsx | 303 +++++ .../Settings/SettingsToolsPanel.tsx | 38 +- .../Settings/StashBoxConfiguration.tsx | 290 +++-- .../Settings/StashConfiguration.tsx | 205 ++-- .../Settings/Tasks/DataManagementTasks.tsx | 241 ++-- .../Settings/Tasks/GenerateOptions.tsx | 376 ++---- .../components/Settings/Tasks/JobTable.tsx | 13 +- .../Settings/Tasks/LibraryTasks.tsx | 244 ++-- .../components/Settings/Tasks/PluginTasks.tsx | 31 +- .../components/Settings/Tasks/ScanOptions.tsx | 94 +- .../Settings/Tasks/SettingsTasksPanel.tsx | 25 +- .../src/components/Settings/Tasks/Task.tsx | 26 - ui/v2.5/src/components/Settings/context.tsx | 419 +++++++ ui/v2.5/src/components/Settings/styles.scss | 226 +++- ui/v2.5/src/components/Setup/Setup.tsx | 2 +- .../FolderSelect/FolderSelectDialog.tsx | 22 +- ui/v2.5/src/components/Shared/Select.tsx | 6 +- .../Tagger/performers/PerformerTagger.tsx | 2 +- ui/v2.5/src/core/StashService.ts | 9 +- ui/v2.5/src/docs/en/Tasks.md | 26 +- ui/v2.5/src/locales/en-GB.json | 66 +- 44 files changed, 3540 insertions(+), 3022 deletions(-) create mode 100644 ui/v2.5/src/components/Settings/GeneratePreviewOptions.tsx create mode 100644 ui/v2.5/src/components/Settings/Inputs.tsx create mode 100644 ui/v2.5/src/components/Settings/SettingSection.tsx delete mode 100644 ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx create mode 100644 ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx create mode 100644 ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx rename ui/v2.5/src/components/Settings/{SettingsDLNAPanel.tsx => SettingsServicesPanel.tsx} (67%) create mode 100644 ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx delete mode 100644 ui/v2.5/src/components/Settings/Tasks/Task.tsx create mode 100644 ui/v2.5/src/components/Settings/context.tsx diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index f0a59a8ba..3fb5b6714 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -61,7 +61,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult { cssEnabled language slideshowDelay - disabledDropdownCreate { + disableDropdownCreate { performer tag studio diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 42259de12..b1c91a207 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -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!] } diff --git a/pkg/api/resolver_mutation_configure.go b/pkg/api/resolver_mutation_configure.go index f7af4df5f..eef6f5651 100644 --- a/pkg/api/resolver_mutation_configure.go +++ b/pkg/api/resolver_mutation_configure.go @@ -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 { - return makeConfigGeneralResult(), errors.New("calculateMD5 must be true if using MD5") - } + 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) } - c.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders) + 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) } - c.Set(config.DLNADefaultIPWhitelist, input.WhitelistedIPs) + 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 } } - c.Set(config.DLNAInterfaces, input.Interfaces) + 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) } - c.Set(config.ScraperCertCheck, input.ScraperCertCheck) + if input.ScraperCertCheck != nil { + c.Set(config.ScraperCertCheck, input.ScraperCertCheck) + } + if refreshScraperCache { manager.GetInstance().RefreshScraperCache() } diff --git a/pkg/api/resolver_query_configuration.go b/pkg/api/resolver_query_configuration.go index bfb1ab808..b54019b4c 100644 --- a/pkg/api/resolver_query_configuration.go +++ b/pkg/api/resolver_query_configuration.go @@ -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,9 +139,13 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult { CSSEnabled: &cssEnabled, Language: &language, SlideshowDelay: &slideshowDelay, - DisabledDropdownCreate: config.GetDisableDropdownCreate(), - HandyKey: &handyKey, - FunscriptOffset: &scriptOffset, + + // FIXME - see above + DisabledDropdownCreate: disableDropdownCreate, + DisableDropdownCreate: disableDropdownCreate, + + HandyKey: &handyKey, + FunscriptOffset: &scriptOffset, } } diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index 8ee1c2b9b..5e5185a70 100755 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -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 = () => { > - - {maybeRenderNavbar()} -
{renderContent()}
+ + + {maybeRenderNavbar()} +
{renderContent()}
+
diff --git a/ui/v2.5/src/components/Changelog/versions/v0120.md b/ui/v2.5/src/components/Changelog/versions/v0120.md index c12f3640d..df2b0af2b 100644 --- a/ui/v2.5/src/components/Changelog/versions/v0120.md +++ b/ui/v2.5/src/components/Changelog/versions/v0120.md @@ -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)) diff --git a/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx b/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx index 156f62849..5ad142636 100644 --- a/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx +++ b/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx @@ -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 = ({ >
{selectionStatus} - + + + ); diff --git a/ui/v2.5/src/components/Help/Manual.tsx b/ui/v2.5/src/components/Help/Manual.tsx index aaa27168f..e0c6ccbc7 100644 --- a/ui/v2.5/src/components/Help/Manual.tsx +++ b/ui/v2.5/src/components/Help/Manual.tsx @@ -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 = ({ 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 = ({ ); }; + +interface IManualContextState { + openManual: (tab?: string) => void; +} + +export const ManualStateContext = React.createContext({ + openManual: () => {}, +}); + +export const ManualProvider: React.FC = ({ children }) => { + const [showManual, setShowManual] = useState(false); + const [manualLink, setManualLink] = useState(); + + function openManual(tab?: string) { + setManualLink(tab); + setShowManual(true); + } + + useEffect(() => { + if (manualLink) setManualLink(undefined); + }, [manualLink]); + + return ( + + setShowManual(false)} + defaultActiveTab={manualLink} + /> + {children} + + ); +}; + +interface IManualLink { + tab: string; +} + +export const ManualLink: React.FC> = ({ + tab, + children, +}) => { + const { openManual } = React.useContext(ManualStateContext); + + return ( + { + openManual(`${tab}.md`); + e.preventDefault(); + }} + > + {children} + + ); +}; diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index ae7931e3e..c98c1f5eb 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -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(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 = () => { + ); + } + + function onDivClick(e: React.MouseEvent) { + 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 ( +
+ + {topLevel} + {renderCollapseButton()} + + +
{children}
+
+
+ ); +}; + +interface IBooleanSetting extends ISetting { + id: string; + checked?: boolean; + onChange: (v: boolean) => void; +} + +export const BooleanSetting: React.FC = (props) => { + const { id, disabled, checked, onChange, ...settingProps } = props; + + return ( + + onChange(!checked)} + /> + + ); +}; + +interface ISelectSetting extends ISetting { + value?: string | number | string[] | undefined; + onChange: (v: string) => void; +} + +export const SelectSetting: React.FC> = ({ + id, + headingID, + subHeadingID, + value, + children, + onChange, +}) => { + return ( + + onChange(e.currentTarget.value)} + > + {children} + + + ); +}; + +interface IDialogSetting extends ISetting { + buttonText?: string; + buttonTextID?: string; + value?: T; + renderValue?: (v: T | undefined) => JSX.Element; + onChange: () => void; +} + +export const ChangeButtonSetting = (props: IDialogSetting) => { + const { + id, + className, + headingID, + subHeadingID, + subHeading, + value, + onChange, + renderValue, + buttonText, + buttonTextID, + disabled, + } = props; + const intl = useIntl(); + + const disabledClassName = disabled ? "disabled" : ""; + + return ( +
+
+

{headingID ? intl.formatMessage({ id: headingID }) : undefined}

+ +
+ {renderValue ? renderValue(value) : undefined} +
+ + {subHeadingID ? ( +
+ {intl.formatMessage({ id: subHeadingID })} +
+ ) : subHeading ? ( +
{subHeading}
+ ) : undefined} +
+
+ +
+
+ ); +}; + +export interface ISettingModal { + 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 = (props: ISettingModal) => { + const { + heading, + headingID, + subHeading, + subHeadingID, + value, + close, + renderField, + modalProps, + } = props; + + const intl = useIntl(); + const [currentValue, setCurrentValue] = useState(value); + + return ( + close()} id="setting-dialog" {...modalProps}> +
{ + close(currentValue); + e.preventDefault(); + }} + > + + {headingID ? : heading} + + + {renderField(currentValue, setCurrentValue)} + {subHeadingID ? ( +
+ {intl.formatMessage({ id: subHeadingID })} +
+ ) : subHeading ? ( +
{subHeading}
+ ) : undefined} +
+ + + + +
+
+ ); +}; + +interface IModalSetting 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 = (props: IModalSetting) => { + const { + id, + className, + value, + headingID, + subHeadingID, + subHeading, + onChange, + renderField, + renderValue, + buttonText, + buttonTextID, + modalProps, + disabled, + } = props; + const [showModal, setShowModal] = useState(false); + + return ( + <> + {showModal ? ( + + headingID={headingID} + subHeadingID={subHeadingID} + subHeading={subHeading} + value={value} + renderField={renderField} + close={(v) => { + if (v !== undefined) onChange(v); + setShowModal(false); + }} + {...modalProps} + /> + ) : undefined} + + + 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 = (props) => { + return ( + + {...props} + renderField={(value, setValue) => ( + ) => + setValue(e.currentTarget.value) + } + /> + )} + renderValue={(value) => {value}} + /> + ); +}; + +interface INumberSetting extends ISetting { + value: number | undefined; + onChange: (v: number) => void; +} + +export const NumberSetting: React.FC = (props) => { + return ( + + {...props} + renderField={(value, setValue) => ( + ) => + setValue(Number.parseInt(e.currentTarget.value || "0", 10)) + } + /> + )} + renderValue={(value) => {value}} + /> + ); +}; + +interface IStringListSetting extends ISetting { + value: string[] | undefined; + defaultNewValue?: string; + onChange: (v: string[]) => void; +} + +export const StringListSetting: React.FC = (props) => { + return ( + + {...props} + renderField={(value, setValue) => ( + + )} + renderValue={(value) => ( +
+ {value?.map((v, i) => ( + // eslint-disable-next-line react/no-array-index-key +
{v}
+ ))} +
+ )} + /> + ); +}; + +interface IConstantSetting extends ISetting { + value?: T; + renderValue?: (v: T | undefined) => JSX.Element; +} + +export const ConstantSetting = (props: IConstantSetting) => { + const { id, headingID, subHeading, subHeadingID, renderValue, value } = props; + const intl = useIntl(); + + return ( +
+
+

{headingID ? intl.formatMessage({ id: headingID }) : undefined}

+ +
{renderValue ? renderValue(value) : value}
+ + {subHeadingID ? ( +
+ {intl.formatMessage({ id: subHeadingID })} +
+ ) : subHeading ? ( +
{subHeading}
+ ) : undefined} +
+
+
+ ); +}; diff --git a/ui/v2.5/src/components/Settings/SettingSection.tsx b/ui/v2.5/src/components/Settings/SettingSection.tsx new file mode 100644 index 000000000..a14ab4c8d --- /dev/null +++ b/ui/v2.5/src/components/Settings/SettingSection.tsx @@ -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> = ({ + id, + children, + headingID, + subHeadingID, +}) => { + const intl = useIntl(); + + return ( +
+

{headingID ? intl.formatMessage({ id: headingID }) : undefined}

+ {subHeadingID ? ( +
+ {intl.formatMessage({ id: subHeadingID })} +
+ ) : undefined} + {children} +
+ ); +}; diff --git a/ui/v2.5/src/components/Settings/Settings.tsx b/ui/v2.5/src/components/Settings/Settings.tsx index 805b10f98..bd2defc19 100644 --- a/ui/v2.5/src/components/Settings/Settings.tsx +++ b/ui/v2.5/src/components/Settings/Settings.tsx @@ -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,85 +30,108 @@ export const Settings: React.FC = () => { id: "settings", })} ${TITLE_SUFFIX}`; return ( - + tab && onSelect(tab)} + > - tab && onSelect(tab)} - > - - - - - - - - + + + + + + + + + + + + - - + + - + + + + @@ -116,9 +142,9 @@ export const Settings: React.FC = () => { - - - - + + + + ); }; diff --git a/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx b/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx index efff47b79..320146ae2 100644 --- a/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsAboutPanel.tsx @@ -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 ( - - {intl.formatMessage({ id: "config.about.version" })}: - {stashVersion} - - ); - } + const hasNew = dataLatest && gitHash !== dataLatest.latestversion.shorthash; - function maybeRenderLatestVersion() { - if ( - !dataLatest?.latestversion.shorthash || - !dataLatest?.latestversion.url - ) { - return; - } - - if (gitHash !== dataLatest.latestversion.shorthash) { - return ( - <> - - {dataLatest.latestversion.shorthash}{" "} - {intl.formatMessage({ id: "config.about.new_version_notice" })}{" "} - - - {intl.formatMessage({ id: "actions.download" })} - - - ); - } - - return <>{dataLatest.latestversion.shorthash}; - } - - function renderLatestVersion() { - return ( - - - - - - - - - - -
- {intl.formatMessage({ - id: "config.about.latest_version_build_hash", - })}{" "} - {maybeRenderLatestVersion()}
- -
- ); - } - - function renderVersion() { - return ( - <> - - - {maybeRenderTag()} - - - - - - - - - -
{intl.formatMessage({ id: "config.about.build_hash" })}{gitHash}
{intl.formatMessage({ id: "config.about.build_time" })}{buildTime}
- - ); - } return ( <> -

{intl.formatMessage({ id: "config.categories.about" })}

- - - - - - - - - - - - - - - -
+ + + + + + + + {errorLatest ? ( + + ) : !dataLatest || loadingLatest || networkStatus === 4 ? ( + + ) : ( +
+
+

+ {intl.formatMessage({ + id: "config.about.latest_version_build_hash", + })} +

+
+ {dataLatest.latestversion.shorthash}{" "} + {hasNew + ? intl.formatMessage({ + id: "config.about.new_version_notice", + }) + : undefined} +
+
+
+ + + + +
+
+ )} +
+
+ + +
+
+

{intl.formatMessage( { id: "config.about.stash_home" }, { @@ -122,10 +101,8 @@ export const SettingsAboutPanel: React.FC = () => { ), } )} -

+

+

{intl.formatMessage( { id: "config.about.stash_wiki" }, { @@ -140,10 +117,8 @@ export const SettingsAboutPanel: React.FC = () => { ), } )} -

+

+

{intl.formatMessage( { id: "config.about.stash_discord" }, { @@ -158,10 +133,8 @@ export const SettingsAboutPanel: React.FC = () => { ), } )} -

+

+

{intl.formatMessage( { id: "config.about.stash_open_collective" }, { @@ -176,17 +149,11 @@ export const SettingsAboutPanel: React.FC = () => { ), } )} -

- {errorLatest && {errorLatest.message}} - {renderVersion()} - {!dataLatest || loadingLatest || networkStatus === 4 ? ( - - ) : ( - renderLatestVersion() - )} +

+
+
+
+ ); }; diff --git a/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx b/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx deleted file mode 100644 index 7f696139b..000000000 --- a/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx +++ /dev/null @@ -1,1054 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { FormattedMessage, useIntl } from "react-intl"; -import { Button, Form, InputGroup } from "react-bootstrap"; -import * as GQL from "src/core/generated-graphql"; -import { - useConfiguration, - useConfigureGeneral, - useGenerateAPIKey, -} from "src/core/StashService"; -import { useToast } from "src/hooks"; -import { Icon, LoadingIndicator } from "src/components/Shared"; -import { - StashBoxConfiguration, - IStashBoxInstance, -} from "./StashBoxConfiguration"; -import StashConfiguration from "./StashConfiguration"; -import { StringListInput } from "../Shared/StringListInput"; - -export const SettingsConfigurationPanel: React.FC = () => { - const intl = useIntl(); - const Toast = useToast(); - // Editing config state - const [stashes, setStashes] = useState([]); - const [databasePath, setDatabasePath] = useState( - undefined - ); - const [generatedPath, setGeneratedPath] = useState( - undefined - ); - const [metadataPath, setMetadataPath] = useState( - undefined - ); - const [cachePath, setCachePath] = useState(undefined); - const [calculateMD5, setCalculateMD5] = useState(false); - const [videoFileNamingAlgorithm, setVideoFileNamingAlgorithm] = useState< - GQL.HashAlgorithm | undefined - >(undefined); - const [parallelTasks, setParallelTasks] = useState(0); - const [previewAudio, setPreviewAudio] = useState(true); - const [previewSegments, setPreviewSegments] = useState(0); - const [previewSegmentDuration, setPreviewSegmentDuration] = useState( - 0 - ); - const [previewExcludeStart, setPreviewExcludeStart] = useState< - string | undefined - >(undefined); - const [previewExcludeEnd, setPreviewExcludeEnd] = useState< - string | undefined - >(undefined); - const [previewPreset, setPreviewPreset] = useState( - GQL.PreviewPreset.Slow - ); - const [maxTranscodeSize, setMaxTranscodeSize] = useState< - GQL.StreamingResolutionEnum | undefined - >(undefined); - const [maxStreamingTranscodeSize, setMaxStreamingTranscodeSize] = useState< - GQL.StreamingResolutionEnum | undefined - >(undefined); - const [writeImageThumbnails, setWriteImageThumbnails] = useState(true); - const [username, setUsername] = useState(undefined); - const [password, setPassword] = useState(undefined); - const [maxSessionAge, setMaxSessionAge] = useState(0); - const [trustedProxies, setTrustedProxies] = useState( - undefined - ); - const [logFile, setLogFile] = useState(); - const [logOut, setLogOut] = useState(true); - const [logLevel, setLogLevel] = useState("Info"); - const [logAccess, setLogAccess] = useState(true); - - const [videoExtensions, setVideoExtensions] = useState(); - const [imageExtensions, setImageExtensions] = useState(); - const [galleryExtensions, setGalleryExtensions] = useState< - string | undefined - >(); - const [ - createGalleriesFromFolders, - setCreateGalleriesFromFolders, - ] = useState(false); - - const [excludes, setExcludes] = useState([]); - const [imageExcludes, setImageExcludes] = useState([]); - const [ - customPerformerImageLocation, - setCustomPerformerImageLocation, - ] = useState(); - const [stashBoxes, setStashBoxes] = useState([]); - - const { data, error, loading } = useConfiguration(); - - const [generateAPIKey] = useGenerateAPIKey(); - - const [updateGeneralConfig] = useConfigureGeneral({ - stashes: stashes.map((s) => ({ - path: s.path, - excludeVideo: s.excludeVideo, - excludeImage: s.excludeImage, - })), - databasePath, - generatedPath, - metadataPath, - cachePath, - calculateMD5, - videoFileNamingAlgorithm: - (videoFileNamingAlgorithm as GQL.HashAlgorithm) ?? undefined, - parallelTasks, - previewAudio, - previewSegments, - previewSegmentDuration, - previewExcludeStart, - previewExcludeEnd, - previewPreset: (previewPreset as GQL.PreviewPreset) ?? undefined, - maxTranscodeSize, - maxStreamingTranscodeSize, - writeImageThumbnails, - username, - password, - maxSessionAge, - trustedProxies, - logFile, - logOut, - logLevel, - logAccess, - createGalleriesFromFolders, - videoExtensions: commaDelimitedToList(videoExtensions), - imageExtensions: commaDelimitedToList(imageExtensions), - galleryExtensions: commaDelimitedToList(galleryExtensions), - excludes, - imageExcludes, - customPerformerImageLocation, - stashBoxes: stashBoxes.map( - (b) => - ({ - name: b?.name ?? "", - api_key: b?.api_key ?? "", - endpoint: b?.endpoint ?? "", - } as GQL.StashBoxInput) - ), - }); - - useEffect(() => { - if (!data?.configuration || error) return; - - const conf = data.configuration; - if (conf.general) { - setStashes(conf.general.stashes ?? []); - setDatabasePath(conf.general.databasePath); - setGeneratedPath(conf.general.generatedPath); - setMetadataPath(conf.general.metadataPath); - setCachePath(conf.general.cachePath); - setVideoFileNamingAlgorithm(conf.general.videoFileNamingAlgorithm); - setCalculateMD5(conf.general.calculateMD5); - setParallelTasks(conf.general.parallelTasks); - setPreviewAudio(conf.general.previewAudio); - setPreviewSegments(conf.general.previewSegments); - setPreviewSegmentDuration(conf.general.previewSegmentDuration); - setPreviewExcludeStart(conf.general.previewExcludeStart); - setPreviewExcludeEnd(conf.general.previewExcludeEnd); - setPreviewPreset(conf.general.previewPreset); - setMaxTranscodeSize(conf.general.maxTranscodeSize ?? undefined); - setMaxStreamingTranscodeSize( - conf.general.maxStreamingTranscodeSize ?? undefined - ); - setWriteImageThumbnails(conf.general.writeImageThumbnails); - setUsername(conf.general.username); - setPassword(conf.general.password); - setMaxSessionAge(conf.general.maxSessionAge); - setTrustedProxies(conf.general.trustedProxies ?? undefined); - setLogFile(conf.general.logFile ?? undefined); - setLogOut(conf.general.logOut); - setLogLevel(conf.general.logLevel); - setLogAccess(conf.general.logAccess); - setCreateGalleriesFromFolders(conf.general.createGalleriesFromFolders); - setVideoExtensions(listToCommaDelimited(conf.general.videoExtensions)); - setImageExtensions(listToCommaDelimited(conf.general.imageExtensions)); - setGalleryExtensions( - listToCommaDelimited(conf.general.galleryExtensions) - ); - setExcludes(conf.general.excludes); - setImageExcludes(conf.general.imageExcludes); - setCustomPerformerImageLocation( - conf.general.customPerformerImageLocation ?? "" - ); - setStashBoxes( - conf.general.stashBoxes.map((box, i) => ({ - name: box?.name ?? undefined, - endpoint: box.endpoint, - api_key: box.api_key, - index: i, - })) ?? [] - ); - } - }, [data, error]); - - function commaDelimitedToList(value: string | undefined) { - if (value) { - return value.split(",").map((s) => s.trim()); - } - } - - function listToCommaDelimited(value: string[] | undefined) { - if (value) { - return value.join(", "); - } - } - - 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); - } - } - - async function onSave() { - try { - const result = await updateGeneralConfig(); - // eslint-disable-next-line no-console - console.log(result); - Toast.success({ - content: intl.formatMessage( - { id: "toast.updated_entity" }, - { - entity: intl - .formatMessage({ id: "configuration" }) - .toLocaleLowerCase(), - } - ), - }); - } catch (e) { - Toast.error(e); - } - } - - 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

{error.message}

; - if (!data?.configuration || loading) return ; - - return ( - <> -

- -

- - -
Stashes
- setStashes(s)} - /> - - {intl.formatMessage({ - id: "config.general.directory_locations_to_your_content", - })} - -
- - -
- -
- ) => - setDatabasePath(e.currentTarget.value) - } - /> - - {intl.formatMessage({ id: "config.general.sqlite_location" })} - -
- - -
- -
- ) => - setGeneratedPath(e.currentTarget.value) - } - /> - - {intl.formatMessage({ - id: "config.general.generated_files_location", - })} - -
- - -
- -
- ) => - setMetadataPath(e.currentTarget.value) - } - /> - - {intl.formatMessage({ - id: "config.general.metadata_path.description", - })} - -
- - -
- -
- ) => - setCachePath(e.currentTarget.value) - } - /> - - {intl.formatMessage({ id: "config.general.cache_location" })} - -
- - -
- -
- ) => - setVideoExtensions(e.currentTarget.value) - } - /> - - {intl.formatMessage({ id: "config.general.video_ext_desc" })} - -
- - -
- -
- ) => - setImageExtensions(e.currentTarget.value) - } - /> - - {intl.formatMessage({ id: "config.general.image_ext_desc" })} - -
- - -
- {intl.formatMessage({ id: "config.general.gallery_ext_head" })} -
- ) => - setGalleryExtensions(e.currentTarget.value) - } - /> - - {intl.formatMessage({ id: "config.general.gallery_ext_desc" })} - -
- - -
- {intl.formatMessage({ - id: "config.general.excluded_video_patterns_head", - })} -
- - - {intl.formatMessage({ - id: "config.general.excluded_video_patterns_desc", - })} - - - - -
- - -
- {intl.formatMessage({ - id: "config.general.excluded_image_gallery_patterns_head", - })} -
- - - {intl.formatMessage({ - id: "config.general.excluded_image_gallery_patterns_desc", - })} - - - - -
- - - - setCreateGalleriesFromFolders(!createGalleriesFromFolders) - } - /> - - {intl.formatMessage({ - id: "config.general.create_galleries_from_folders_desc", - })} - - -
- -
- - -

{intl.formatMessage({ id: "config.general.hashing" })}

- - setCalculateMD5(!calculateMD5)} - /> - - {intl.formatMessage({ - id: "config.general.calculate_md5_and_ohash_desc", - })} - - - - -
- {intl.formatMessage({ - id: "config.general.generated_file_naming_hash_head", - })} -
- - ) => - setVideoFileNamingAlgorithm( - translateNamingHash(e.currentTarget.value) - ) - } - > - {namingHashAlgorithms.map((q) => ( - - ))} - - - - {intl.formatMessage({ - id: "config.general.generated_file_naming_hash_desc", - })} - -
-
- -
- - -

{intl.formatMessage({ id: "config.general.video_head" })}

- -
- {intl.formatMessage({ - id: "config.general.maximum_transcode_size_head", - })} -
- ) => - setMaxTranscodeSize(translateQuality(event.currentTarget.value)) - } - value={resolutionToString(maxTranscodeSize)} - > - {transcodeQualities.map((q) => ( - - ))} - - - {intl.formatMessage({ - id: "config.general.maximum_transcode_size_desc", - })} - -
- -
- {intl.formatMessage({ - id: "config.general.maximum_streaming_transcode_size_head", - })} -
- ) => - setMaxStreamingTranscodeSize( - translateQuality(event.currentTarget.value) - ) - } - value={resolutionToString(maxStreamingTranscodeSize)} - > - {transcodeQualities.map((q) => ( - - ))} - - - {intl.formatMessage({ - id: "config.general.maximum_streaming_transcode_size_desc", - })} - -
-
- -
- - -

- {intl.formatMessage({ id: "config.general.parallel_scan_head" })} -

- - -
- {intl.formatMessage({ - id: - "config.general.number_of_parallel_task_for_scan_generation_head", - })} -
- ) => - setParallelTasks( - Number.parseInt(e.currentTarget.value || "0", 10) - ) - } - /> - - {intl.formatMessage({ - id: - "config.general.number_of_parallel_task_for_scan_generation_desc", - })} - -
-
- -
- - -

- {intl.formatMessage({ id: "config.general.preview_generation" })} -

- - -
- {intl.formatMessage({ - id: "dialogs.scene_gen.preview_preset_head", - })} -
- ) => - setPreviewPreset(e.currentTarget.value) - } - > - {Object.keys(GQL.PreviewPreset).map((p) => ( - - ))} - - - {intl.formatMessage({ - id: "dialogs.scene_gen.preview_preset_desc", - })} - -
- - - setPreviewAudio(!previewAudio)} - /> - - {intl.formatMessage({ - id: "config.general.include_audio_desc", - })} - - - - -
- {intl.formatMessage({ - id: "dialogs.scene_gen.preview_seg_count_head", - })} -
- ) => - setPreviewSegments( - Number.parseInt(e.currentTarget.value || "0", 10) - ) - } - /> - - {intl.formatMessage({ - id: "dialogs.scene_gen.preview_seg_count_desc", - })} - -
- - -
- {intl.formatMessage({ - id: "dialogs.scene_gen.preview_seg_duration_head", - })} -
- ) => - setPreviewSegmentDuration( - Number.parseFloat(e.currentTarget.value || "0") - ) - } - /> - - {intl.formatMessage({ - id: "dialogs.scene_gen.preview_seg_duration_desc", - })} - -
- - -
- {intl.formatMessage({ - id: "dialogs.scene_gen.preview_exclude_start_time_head", - })} -
- ) => - setPreviewExcludeStart(e.currentTarget.value) - } - /> - - {intl.formatMessage({ - id: "dialogs.scene_gen.preview_exclude_start_time_desc", - })} - -
- - -
- {intl.formatMessage({ - id: "dialogs.scene_gen.preview_exclude_end_time_head", - })} -
- ) => - setPreviewExcludeEnd(e.currentTarget.value) - } - /> - - {intl.formatMessage({ - id: "dialogs.scene_gen.preview_exclude_end_time_desc", - })} - -
-
- -
- - -

{intl.formatMessage({ id: "images" })}

- - - setWriteImageThumbnails(!writeImageThumbnails)} - /> - - {intl.formatMessage({ - id: "config.ui.images.options.write_image_thumbnails.description", - })} - - -
- -
- - -

{intl.formatMessage({ id: "performers" })}

- -
- {intl.formatMessage({ - id: "config.ui.performers.options.image_location.heading", - })} -
- ) => { - setCustomPerformerImageLocation(e.currentTarget.value); - }} - /> - - {intl.formatMessage({ - id: "config.ui.performers.options.image_location.description", - })} - -
-
-
- - -

- {intl.formatMessage({ - id: "config.general.auth.stash-box_integration", - })} -

- -
- -
- - -

- {intl.formatMessage({ id: "config.general.auth.authentication" })} -

- -
{intl.formatMessage({ id: "config.general.auth.username" })}
- ) => - setUsername(e.currentTarget.value) - } - /> - - {intl.formatMessage({ id: "config.general.auth.username_desc" })} - -
- -
{intl.formatMessage({ id: "config.general.auth.password" })}
- ) => - setPassword(e.currentTarget.value) - } - /> - - {intl.formatMessage({ id: "config.general.auth.password_desc" })} - -
- - -
{intl.formatMessage({ id: "config.general.auth.api_key" })}
- - - - - - - - - {intl.formatMessage({ id: "config.general.auth.api_key_desc" })} - -
- - -
- {intl.formatMessage({ - id: "config.general.auth.maximum_session_age", - })} -
- ) => - setMaxSessionAge( - Number.parseInt(e.currentTarget.value || "0", 10) - ) - } - /> - - {intl.formatMessage({ - id: "config.general.auth.maximum_session_age_desc", - })} - -
-
- - -
- {intl.formatMessage({ id: "config.general.auth.trusted_proxies" })} -
- setTrustedProxies(value)} - defaultNewValue="" - /> - - {intl.formatMessage({ - id: "config.general.auth.trusted_proxies_desc", - })} - -
- -
- -

{intl.formatMessage({ id: "config.general.logging" })}

- -
{intl.formatMessage({ id: "config.general.auth.log_file" })}
- ) => - setLogFile(e.currentTarget.value) - } - /> - - {intl.formatMessage({ id: "config.general.auth.log_file_desc" })} - -
- - - setLogOut(!logOut)} - /> - - {intl.formatMessage({ - id: "config.general.auth.log_to_terminal_desc", - })} - - - - -
{intl.formatMessage({ id: "config.logs.log_level" })}
- ) => - setLogLevel(event.currentTarget.value) - } - value={logLevel} - > - {["Trace", "Debug", "Info", "Warning", "Error"].map((o) => ( - - ))} - -
- - - setLogAccess(!logAccess)} - /> - - {intl.formatMessage({ id: "config.general.auth.log_http_desc" })} - - - -
- - - - ); -}; diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/CheckboxGroup.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/CheckboxGroup.tsx index 1a2a156d8..6dd0d52e5 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/CheckboxGroup.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/CheckboxGroup.tsx @@ -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 = ({ return ( <> - {items.map(({ id, label }) => ( - ( + { - 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 = ({ .map((item) => item.id) .filter( (itemId) => - generateId(itemId) !== target.id && + generateId(itemId) !== generateId(id) && checkedIds.includes(itemId) ) ); diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index c93ef8f87..27bf5cc69 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -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( - allMenuItems.map((item) => item.id) + + const { interface: iface, saveInterface, loading, error } = React.useContext( + SettingStateContext ); - const [noBrowser, setNoBrowserFlag] = useState(false); - const [soundOnPreview, setSoundOnPreview] = useState(true); - const [wallShowTitle, setWallShowTitle] = useState(true); - const [wallPlayback, setWallPlayback] = useState("video"); - const [maximumLoopDuration, setMaximumLoopDuration] = useState(0); - const [autostartVideo, setAutostartVideo] = useState(false); - const [ - autostartVideoOnPlaySelected, - setAutostartVideoOnPlaySelected, - ] = useState(true); - const [continuePlaylistDefault, setContinuePlaylistDefault] = useState(false); - const [slideshowDelay, setSlideshowDelay] = useState(0); - const [showStudioAsText, setShowStudioAsText] = useState(false); - const [css, setCSS] = useState(); - const [cssEnabled, setCSSEnabled] = useState(false); - const [language, setLanguage] = useState("en"); - const [handyKey, setHandyKey] = useState(); - const [funscriptOffset, setFunscriptOffset] = useState(0); - const [deleteFileDefault, setDeleteFileDefault] = useState(false); - const [deleteGeneratedDefault, setDeleteGeneratedDefault] = useState( - true - ); - const [ - disableDropdownCreate, - setDisableDropdownCreate, - ] = useState({}); - - 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

{error.message}

; if (loading) return ; return ( <> -

{intl.formatMessage({ id: "config.ui.title" })}

- -
{intl.formatMessage({ id: "config.ui.language.heading" })}
- ) => - setLanguage(e.currentTarget.value) - } + + saveInterface({ language: v })} > @@ -181,76 +55,61 @@ export const SettingsInterfacePanel: React.FC = () => { - -
- -
{intl.formatMessage({ id: "config.ui.menu_items.heading" })}
- - - {intl.formatMessage({ id: "config.ui.menu_items.description" })} - -
+ -
+
+
+
+

+ {intl.formatMessage({ + id: "config.ui.menu_items.heading", + })} +

+
+ {intl.formatMessage({ id: "config.ui.menu_items.description" })} +
+
+
+
+ saveInterface({ menuItems: v })} + /> +
+ -

- {intl.formatMessage({ - id: "config.ui.desktop_integration.desktop_integration", - })} -

- - + 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 })} /> - - {intl.formatMessage({ - id: "config.ui.desktop_integration.skip_opening_browser_on_startup", - })} - - -
+ - -
{intl.formatMessage({ id: "config.ui.scene_wall.heading" })}
- + setWallShowTitle(!wallShowTitle)} + headingID="config.ui.scene_wall.options.display_title" + checked={iface.wallShowTitle ?? undefined} + onChange={(v) => saveInterface({ wallShowTitle: v })} /> - setSoundOnPreview(!soundOnPreview)} + headingID="config.ui.scene_wall.options.toggle_sound" + checked={iface.soundOnPreview ?? undefined} + onChange={(v) => saveInterface({ soundOnPreview: v })} /> - -
- {intl.formatMessage({ id: "config.ui.preview_type.heading" })} -
-
- ) => - setWallPlayback(e.currentTarget.value) - } + + saveInterface({ wallPlayback: v })} > - - - {intl.formatMessage({ id: "config.ui.preview_type.description" })} - -
+ + - -
{intl.formatMessage({ id: "config.ui.scene_list.heading" })}
- + { - setShowStudioAsText(!showStudioAsText); + headingID="config.ui.scene_list.options.show_studio_as_text" + checked={iface.showStudioAsText ?? undefined} + onChange={(v) => saveInterface({ showStudioAsText: v })} + /> + + + + saveInterface({ autostartVideo: v })} + /> + saveInterface({ autostartVideoOnPlaySelected: v })} + /> + + saveInterface({ continuePlaylistDefault: v })} + /> + + + 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) => ( + setValue(duration ?? 0)} + /> + )} + renderValue={(v) => { + return {DurationUtils.secondsToString(v ?? 0)}; }} /> -
+ - -
{intl.formatMessage({ id: "config.ui.scene_player.heading" })}
- - { - setAutostartVideo(!autostartVideo); - }} - /> - - - { - setAutostartVideoOnPlaySelected(!autostartVideoOnPlaySelected); - }} - /> - - {intl.formatMessage({ - id: - "config.ui.scene_player.options.auto_start_video_on_play_selected.description", - })} - - - - - { - setContinuePlaylistDefault(!continuePlaylistDefault); - }} - /> - - {intl.formatMessage({ - id: - "config.ui.scene_player.options.continue_playlist_default.description", - })} - - - - -
- {intl.formatMessage({ id: "config.ui.max_loop_duration.heading" })} -
- setMaximumLoopDuration(duration ?? 0)} - /> - - {intl.formatMessage({ - id: "config.ui.max_loop_duration.description", - })} - -
-
- - -
- {intl.formatMessage({ id: "config.ui.slideshow_delay.heading" })} -
- ) => { - setSlideshowDelay( - Number.parseInt(e.currentTarget.value, 10) * SECONDS_TO_MS - ); - }} + + saveInterface({ slideshowDelay: v })} /> - - {intl.formatMessage({ id: "config.ui.slideshow_delay.description" })} - -
+ - -
{intl.formatMessage({ id: "config.ui.editing.heading" })}
- - -
- {intl.formatMessage({ - id: "config.ui.editing.disable_dropdown_create.heading", - })} -
- +
+
+
+

+ {intl.formatMessage({ + id: "config.ui.editing.disable_dropdown_create.heading", + })} +

+
+ {intl.formatMessage({ + id: "config.ui.editing.disable_dropdown_create.description", + })} +
+
+
+
+ { - setDisableDropdownCreate({ - ...disableDropdownCreate, - performer: !disableDropdownCreate.performer ?? true, - }); - }} + headingID="performer" + checked={iface.disableDropdownCreate?.performer ?? undefined} + onChange={(v) => + saveInterface({ + disableDropdownCreate: { + ...iface.disableDropdownCreate, + performer: v, + }, + }) + } /> - - { - setDisableDropdownCreate({ - ...disableDropdownCreate, - studio: !disableDropdownCreate.studio ?? true, - }); - }} + headingID="studio" + checked={iface.disableDropdownCreate?.studio ?? undefined} + onChange={(v) => + saveInterface({ + disableDropdownCreate: { + ...iface.disableDropdownCreate, + studio: v, + }, + }) + } /> - - { - setDisableDropdownCreate({ - ...disableDropdownCreate, - tag: !disableDropdownCreate.tag ?? true, - }); - }} + headingID="tag" + checked={iface.disableDropdownCreate?.tag ?? undefined} + onChange={(v) => + saveInterface({ + disableDropdownCreate: { + ...iface.disableDropdownCreate, + tag: v, + }, + }) + } /> - - {intl.formatMessage({ - id: "config.ui.editing.disable_dropdown_create.description", - })} - - - +
+ - -
{intl.formatMessage({ id: "config.ui.custom_css.heading" })}
- + saveInterface({ cssEnabled: v })} + /> + + id="custom-css" - checked={cssEnabled} - label={intl.formatMessage({ - id: "config.ui.custom_css.option_label", - })} - onChange={() => { - setCSSEnabled(!cssEnabled); + headingID="config.ui.custom_css.heading" + subHeadingID="config.ui.custom_css.description" + value={iface.css ?? undefined} + onChange={(v) => saveInterface({ css: v })} + renderField={(value, setValue) => ( + ) => + setValue(e.currentTarget.value) + } + rows={16} + className="text-input code" + /> + )} + renderValue={() => { + return <>; }} /> + - ) => - setCSS(e.currentTarget.value) - } - rows={16} - className="col col-sm-6 text-input code" + + saveInterface({ handyKey: v })} /> - - {intl.formatMessage({ id: "config.ui.custom_css.description" })} - -
- - -
- {intl.formatMessage({ id: "config.ui.handy_connection_key.heading" })} -
- ) => { - setHandyKey(e.currentTarget.value); - }} + saveInterface({ funscriptOffset: v })} /> - - {intl.formatMessage({ - id: "config.ui.handy_connection_key.description", - })} - -
- -
- {intl.formatMessage({ id: "config.ui.funscript_offset.heading" })} -
- ) => { - setFunscriptOffset(Number.parseInt(e.currentTarget.value, 10)); - }} - /> - - {intl.formatMessage({ id: "config.ui.funscript_offset.description" })} - -
- - -
- {intl.formatMessage({ id: "config.ui.delete_options.heading" })} -
- { - setDeleteFileDefault(!deleteFileDefault); - }} - /> - { - setDeleteGeneratedDefault(!deleteGeneratedDefault); - }} - /> - - {intl.formatMessage({ - id: "config.ui.delete_options.description", - })} - -
- -
- + ); }; diff --git a/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx b/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx new file mode 100644 index 000000000..55aaa6f48 --- /dev/null +++ b/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx @@ -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

{error.message}

; + if (loading) return ; + + return ( + <> + saveGeneral({ stashes: v })} + /> + + + + saveGeneral({ videoExtensions: commaDelimitedToList(v) }) + } + /> + + + saveGeneral({ imageExtensions: commaDelimitedToList(v) }) + } + /> + + + saveGeneral({ galleryExtensions: commaDelimitedToList(v) }) + } + /> + + + + + {intl.formatMessage({ + id: "config.general.excluded_video_patterns_desc", + })} + + + + + } + value={general.excludes ?? undefined} + onChange={(v) => saveGeneral({ excludes: v })} + defaultNewValue="sample\.mp4$" + /> + + + {intl.formatMessage({ + id: "config.general.excluded_image_gallery_patterns_desc", + })} + + + + + } + value={general.imageExcludes ?? undefined} + onChange={(v) => saveGeneral({ imageExcludes: v })} + defaultNewValue="sample\.jpg$" + /> + + + + saveGeneral({ createGalleriesFromFolders: v })} + /> + + saveGeneral({ writeImageThumbnails: v })} + /> + + + + { + saveDefaults({ deleteFile: v }); + }} + /> + { + saveDefaults({ deleteGenerated: v }); + }} + /> + + + ); +}; diff --git a/ui/v2.5/src/components/Settings/SettingsLogsPanel.tsx b/ui/v2.5/src/components/Settings/SettingsLogsPanel.tsx index 31da22419..42e59a769 100644 --- a/ui/v2.5/src/components/Settings/SettingsLogsPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsLogsPanel.tsx @@ -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 ( <> -

{intl.formatMessage({ id: "config.categories.logs" })}

- - - {intl.formatMessage({ id: "config.logs.log_level" })} - - setLogLevel(event.currentTarget.value)} + + setLogLevel(v)} > {logLevels.map((level) => ( ))} - - + + +
{maybeRenderError} {filteredLogEntries.map((logEntry) => ( diff --git a/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx b/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx index 012a6a594..afb38b3f3 100644 --- a/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx @@ -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,91 +19,101 @@ export const SettingsPluginsPanel: React.FC = () => { await mutateReloadPlugins().catch((e) => Toast.error(e)); } - function renderLink(url?: string) { - if (url) { + const pluginElements = useMemo(() => { + function renderLink(url?: string) { + if (url) { + return ( + + ); + } + } + + function renderPlugins() { + const elements = (data?.plugins ?? []).map((plugin) => ( + + {renderPluginHooks(plugin.hooks ?? undefined)} + + )); + + return
{elements}
; + } + + function renderPluginHooks( + hooks?: Pick[] + ) { + if (!hooks || hooks.length === 0) { + return; + } + return ( - +
+
+
+ +
+ {hooks.map((h) => ( +
+
{h.name}
+ +
    + {h.hooks?.map((hh) => ( +
  • + {hh} +
  • + ))} +
+
+ {h.description} +
+ ))} +
+
+
); } - } - function renderPlugins() { - const elements = (data?.plugins ?? []).map((plugin) => ( -
-

- {plugin.name} {plugin.version ? `(${plugin.version})` : undefined}{" "} - {renderLink(plugin.url ?? undefined)} -

- {plugin.description ? ( - {plugin.description} - ) : undefined} - {renderPluginHooks(plugin.hooks ?? undefined)} -
-
- )); - - return
{elements}
; - } - - function renderPluginHooks( - hooks?: Pick[] - ) { - if (!hooks || hooks.length === 0) { - return; - } - - return ( -
-
- -
- {hooks.map((h) => ( -
-
{h.name}
- -
    - {h.hooks?.map((hh) => ( -
  • - {hh} -
  • - ))} -
-
- {h.description} -
- ))} -
- ); - } + return renderPlugins(); + }, [data?.plugins, intl]); if (loading) return ; return ( <> -

- -

-
- {renderPlugins()} - + + + + + {pluginElements} + ); }; diff --git a/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx b/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx index ecdaa3820..e751dfb4d 100644 --- a/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx @@ -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( - undefined - ); - const [scraperCDPPath, setScraperCDPPath] = useState( - undefined - ); - const [scraperCertCheck, setScraperCertCheck] = useState(true); - const [excludeTagPatterns, setExcludeTagPatterns] = useState([]); - - 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,110 +306,76 @@ export const SettingsScrapingPanel: React.FC = () => { } } - if (loadingScenes || loadingGalleries || loadingPerformers || loadingMovies) + if (error) return

{error.message}

; + if ( + loading || + loadingScenes || + loadingGalleries || + loadingPerformers || + loadingMovies + ) return ; return ( <> - -

{intl.formatMessage({ id: "config.general.scraping" })}

- -
- {intl.formatMessage({ id: "config.general.scraper_user_agent" })} -
- ) => - setScraperUserAgent(e.currentTarget.value) - } - /> - - {intl.formatMessage({ - id: "config.general.scraper_user_agent_desc", - })} - -
+ saveGeneral({ stashBoxes: v })} + /> - -
- {intl.formatMessage({ id: "config.general.chrome_cdp_path" })} -
- ) => - setScraperCDPPath(e.currentTarget.value) - } - /> - - {intl.formatMessage({ id: "config.general.chrome_cdp_path_desc" })} - -
- - - setScraperCertCheck(!scraperCertCheck)} - /> - - {intl.formatMessage({ - id: "config.general.check_for_insecure_certificates_desc", - })} - - -
- - -
- {intl.formatMessage({ - id: "config.scraping.excluded_tag_patterns_head", - })} -
- + saveScraping({ scraperUserAgent: v })} /> - - {intl.formatMessage({ - id: "config.scraping.excluded_tag_patterns_desc", - })} - -
-
+ saveScraping({ scraperCDPPath: v })} + /> -

{intl.formatMessage({ id: "config.scraping.scrapers" })}

+ saveScraping({ scraperCertCheck: v })} + /> -
- -
+ saveScraping({ excludeTagPatterns: v })} + /> + -
- {renderSceneScrapers()} - {renderGalleryScrapers()} - {renderPerformerScrapers()} - {renderMovieScrapers()} -
+ +
+ +
-
- - +
+ {renderSceneScrapers()} + {renderGalleryScrapers()} + {renderPerformerScrapers()} + {renderMovieScrapers()} +
+
); }; diff --git a/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx new file mode 100644 index 000000000..ee1cd3bbe --- /dev/null +++ b/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx @@ -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 = ({ + value, + setValue, +}) => { + const intl = useIntl(); + + function set(v: Partial) { + setValue({ + ...value, + ...v, + }); + } + + const { username, password } = value; + + return ( +
+ +
{intl.formatMessage({ id: "config.general.auth.username" })}
+ ) => + set({ username: e.currentTarget.value }) + } + /> + + {intl.formatMessage({ id: "config.general.auth.username_desc" })} + +
+ +
{intl.formatMessage({ id: "config.general.auth.password" })}
+ ) => + set({ password: e.currentTarget.value }) + } + /> + + {intl.formatMessage({ id: "config.general.auth.password_desc" })} + +
+
+ ); +}; + +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

{error.message}

; + if (loading) return ; + + return ( + <> + + + 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) => ( + + )} + renderValue={(v) => { + if (v?.username && v?.password) + return {v?.username ?? ""}; + return <>; + }} + /> + +
+
+

{intl.formatMessage({ id: "config.general.auth.api_key" })}

+ +
{apiKey}
+ +
+ {intl.formatMessage({ id: "config.general.auth.api_key_desc" })} +
+
+
+ + +
+
+ + saveGeneral({ maxSessionAge: v })} + /> + + saveGeneral({ trustedProxies: v })} + /> +
+ + ); +}; diff --git a/ui/v2.5/src/components/Settings/SettingsDLNAPanel.tsx b/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx similarity index 67% rename from ui/v2.5/src/components/Settings/SettingsDLNAPanel.tsx rename to ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx index 6051f6429..2bc7c9761 100644 --- a/ui/v2.5/src/components/Settings/SettingsDLNAPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx @@ -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( undefined @@ -34,64 +35,15 @@ export const SettingsDLNAPanel: React.FC = () => { const [ipEntry, setIPEntry] = useState(""); const [tempIP, setTempIP] = useState(); - 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 ; - - // 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

{error.message}

; + if (loading || configLoading) return ; 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 ( - + * } + )} + defaultNewValue="*" + value={dlna.whitelistedIPs ?? undefined} + onChange={(v) => saveDLNA({ whitelistedIPs: v })} + /> + + ); }; @@ -532,17 +434,15 @@ export const SettingsDLNAPanel: React.FC = () => { - -
{intl.formatMessage({ id: "actions_name" })}
- - + + {renderEnableButton()} {renderTempCancelButton()} {renderAllowedIPs()} - +
{intl.formatMessage({ id: "config.dlna.recent_ip_addresses" })}
@@ -553,18 +453,9 @@ export const SettingsDLNAPanel: React.FC = () => {
-
+ -
- - onSave(values)} - enableReinitialize - > - - +
); }; diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx new file mode 100644 index 000000000..20dc853a8 --- /dev/null +++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx @@ -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

{error.message}

; + if (loading) return ; + + return ( + <> + + saveGeneral({ databasePath: v })} + /> + + saveGeneral({ generatedPath: v })} + /> + + saveGeneral({ metadataPath: v })} + /> + + saveGeneral({ cachePath: v })} + /> + + saveGeneral({ customPerformerImageLocation: v })} + /> + + + + saveGeneral({ calculateMD5: v })} + /> + + + saveGeneral({ videoFileNamingAlgorithm: translateNamingHash(v) }) + } + > + {namingHashAlgorithms.map((q) => ( + + ))} + + + + + + saveGeneral({ maxTranscodeSize: translateQuality(v) }) + } + value={resolutionToString(general.maxTranscodeSize ?? undefined)} + > + {transcodeQualities.map((q) => ( + + ))} + + + + saveGeneral({ maxStreamingTranscodeSize: translateQuality(v) }) + } + value={resolutionToString( + general.maxStreamingTranscodeSize ?? undefined + )} + > + {transcodeQualities.map((q) => ( + + ))} + + + + + saveGeneral({ parallelTasks: v })} + /> + + + + + saveGeneral({ + previewPreset: (v as GQL.PreviewPreset) ?? undefined, + }) + } + > + {Object.keys(GQL.PreviewPreset).map((p) => ( + + ))} + + + saveGeneral({ previewAudio: v })} + /> + + + 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) => ( + + )} + renderValue={() => { + return <>; + }} + /> + + + + saveGeneral({ logFile: v })} + /> + + saveGeneral({ logOut: v })} + /> + + saveGeneral({ logLevel: v })} + value={general.logLevel ?? undefined} + > + {["Trace", "Debug", "Info", "Warning", "Error"].map((o) => ( + + ))} + + + saveGeneral({ logAccess: v })} + /> + + + ); +}; diff --git a/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx b/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx index 39549a48e..973123589 100644 --- a/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx @@ -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 ( <> -

- -

+ + + + + } + /> - - - - - - - - - - - + + + + } + /> + ); }; diff --git a/ui/v2.5/src/components/Settings/StashBoxConfiguration.tsx b/ui/v2.5/src/components/Settings/StashBoxConfiguration.tsx index 493e62263..5adcabd29 100644 --- a/ui/v2.5/src/components/Settings/StashBoxConfiguration.tsx +++ b/ui/v2.5/src/components/Settings/StashBoxConfiguration.tsx @@ -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 = ({ - instance, - onSave, - onDelete, - isMulti, -}) => { +export const StashBoxModal: React.FC = ({ value, close }) => { const intl = useIntl(); - const handleInput = (key: string, value: string) => { - const newObj = { - ...instance, - [key]: value, - }; - onSave(newObj); - }; return ( - - - 0} - onInput={(e: React.ChangeEvent) => - handleInput("name", e.currentTarget.value) - } - /> - 0} - onInput={(e: React.ChangeEvent) => - handleInput("endpoint", e.currentTarget.value.trim()) - } - /> - 0} - onInput={(e: React.ChangeEvent) => - handleInput("api_key", e.currentTarget.value.trim()) - } - /> - - - - - - ); -}; + + headingID="config.stashbox.title" + value={value} + renderField={(v, setValue) => ( + <> + +
+ {intl.formatMessage({ + id: "config.stashbox.name", + })} +
+ 0} + onChange={(e: React.ChangeEvent) => + setValue({ ...v!, name: e.currentTarget.value }) + } + /> +
-interface IStashBoxConfigurationProps { - boxes: IStashBoxInstance[]; - saveBoxes: (boxes: IStashBoxInstance[]) => void; -} + +
+ {intl.formatMessage({ + id: "config.stashbox.graphql_endpoint", + })} +
+ 0} + onChange={(e: React.ChangeEvent) => + setValue({ ...v!, endpoint: e.currentTarget.value.trim() }) + } + /> +
-export interface IStashBoxInstance { - name?: string; - endpoint?: string; - api_key?: string; - index: number; -} - -export const StashBoxConfiguration: React.FC = ({ - boxes, - saveBoxes, -}) => { - const intl = useIntl(); - const [index, setIndex] = useState(1000); - - 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); - }; - - return ( - -
{intl.formatMessage({ id: "config.stashbox.title" })}
- {boxes.length > 0 && ( -
-
- {intl.formatMessage({ id: "config.stashbox.name" })} -
-
- {intl.formatMessage({ id: "config.stashbox.endpoint" })} -
-
- {intl.formatMessage({ id: "config.general.auth.api_key" })} -
-
+ +
+ {intl.formatMessage({ + id: "config.stashbox.api_key", + })} +
+ 0} + onChange={(e: React.ChangeEvent) => + setValue({ ...v!, api_key: e.currentTarget.value.trim() }) + } + /> +
+ )} - {boxes.map((instance) => ( - 1} - /> - ))} - - - {intl.formatMessage({ id: "config.stashbox.description" })} - -
+ close={close} + /> + ); +}; + +interface IStashBoxSetting { + value: GQL.StashBoxInput[]; + onChange: (v: GQL.StashBoxInput[]) => void; +} + +export const StashBoxSetting: React.FC = ({ + value, + onChange, +}) => { + const [isCreating, setIsCreating] = useState(false); + const [editingIndex, setEditingIndex] = useState(); + + function onEdit(index: number) { + setEditingIndex(index); + } + + function onDelete(index: number) { + onChange(value.filter((v, i) => i !== index)); + } + + function onNew() { + setIsCreating(true); + } + + return ( + + {isCreating ? ( + { + if (v) onChange([...value, v]); + setIsCreating(false); + }} + /> + ) : undefined} + + {editingIndex !== undefined ? ( + { + 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 +
+
+

{b.name ?? `#${index}`}

+
{b.endpoint ?? ""}
+
+
+ + +
+
+ ))} +
+
+
+ +
+
+ ); }; diff --git a/ui/v2.5/src/components/Settings/StashConfiguration.tsx b/ui/v2.5/src/components/Settings/StashConfiguration.tsx index 35cd6ff44..04b484b1e 100644 --- a/ui/v2.5/src/components/Settings/StashConfiguration.tsx +++ b/ui/v2.5/src/components/Settings/StashConfiguration.tsx @@ -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 = ({ index, stash, onSave, onDelete }) => { +const Stash: React.FC = ({ + 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 = ({ index, stash, onSave, onDelete }) => { onSave(newObj); }; - const intl = useIntl(); const classAdd = index % 2 === 1 ? "bg-dark" : ""; return ( - - + + {stash.path} - - handleInput("excludeVideo", !stash.excludeVideo)} - /> + + {/* NOTE - language is opposite to meaning: + internally exclude flags, displayed as include */} +
+
+ +
+ handleInput("excludeVideo", !v)} + /> +
- - handleInput("excludeImage", !stash.excludeImage)} - /> + +
+
+ +
+ handleInput("excludeImage", !v)} + /> +
- - + + + + + + + onEdit()}> + + + onDelete()}> + + + +
); @@ -68,51 +97,75 @@ const StashConfiguration: React.FC = ({ stashes, setStashes, }) => { - const [isDisplayingDialog, setIsDisplayingDialog] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [editingIndex, setEditingIndex] = useState(); + + 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 ; - } return ( <> - {maybeRenderDialog()} - + {isCreating ? ( + { + if (v) + setStashes([ + ...stashes, + { + path: v, + excludeVideo: false, + excludeImage: false, + }, + ]); + setIsCreating(false); + }} + /> + ) : undefined} + + {editingIndex !== undefined ? ( + { + if (v) + setStashes( + stashes.map((vv, index) => { + if (index === editingIndex) { + return { + ...vv, + path: v, + }; + } + return vv; + }) + ); + setEditingIndex(undefined); + }} + /> + ) : undefined} + +
{stashes.length > 0 && ( - -
+ +
-
- +
+
-
- +
+
)} @@ -121,20 +174,34 @@ const StashConfiguration: React.FC = ({ index={index} stash={stash} onSave={(s) => handleSave(index, s)} - onDelete={() => handleDelete(index)} + onEdit={() => onEdit(index)} + onDelete={() => onDelete(index)} key={stash.path} /> ))} - - +
); }; +interface IStashSetting { + value: GQL.StashConfigInput[]; + onChange: (v: GQL.StashConfigInput[]) => void; +} + +export const StashSetting: React.FC = ({ value, onChange }) => { + return ( + + onChange(v)} /> + + ); +}; + export default StashConfiguration; diff --git a/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx index 498dbb94f..2aa9908bd 100644 --- a/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx @@ -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 = ({ options, setOptions: setOptionsState, }) => { - const intl = useIntl(); - function setOptions(input: Partial) { setOptionsState({ ...options, ...input }); } return ( - - + setOptions({ dryRun: !options.dryRun })} + headingID="config.tasks.only_dry_run" + onChange={(v) => setOptions({ dryRun: v })} /> - + ); }; @@ -213,19 +214,19 @@ export const DataManagementTasks: React.FC = ({ {renderImportDialog()} {renderCleanDialog()} - -
-
{intl.formatMessage({ id: "config.tasks.maintenance" })}
- +
+ + + + + + + } + subHeadingID="config.tasks.cleanup_desc" > - setCleanOptions(o)} - /> - + + setCleanOptions(o)} + />
- + -
- - -
{intl.formatMessage({ id: "metadata" })}
-
- + + - + + + - + - + + + - + - -
-
+ + + + -
- - -
{intl.formatMessage({ id: "actions.backup" })}
-
- - [origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS] - - ), - } - )} + + + [origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS] + + ), + } + )} + > + - + + + - + - -
-
+ + + + -
- - -
{intl.formatMessage({ id: "config.tasks.migrations" })}
- -
- + + - -
-
+ + + + ); }; diff --git a/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx b/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx index c26386c70..60292f9d6 100644 --- a/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx @@ -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 = ({ }) => { const intl = useIntl(); - const [previewOptionsOpen, setPreviewOptionsOpen] = useState(false); - const previewOptions: GQL.GeneratePreviewOptionsInput = options.previewOptions ?? {}; @@ -24,275 +25,110 @@ export const GenerateOptions: React.FC = ({ setOptionsState({ ...options, ...input }); } - function setPreviewOptions(input: Partial) { - setOptions({ - previewOptions: { - ...previewOptions, - ...input, - }, - }); - } - return ( - - - setOptions({ previews: !options.previews })} - /> -
-
- - setOptions({ imagePreviews: !options.imagePreviews }) - } - className="ml-2 flex-grow" - /> -
-
+ <> + setOptions({ previews: v })} + /> + setOptions({ imagePreviews: v })} + /> - - - - - - -
- {intl.formatMessage({ - id: "dialogs.scene_gen.preview_preset_head", - })} -
- - setPreviewOptions({ - previewPreset: e.currentTarget.value as GQL.PreviewPreset, - }) - } - > - {Object.keys(GQL.PreviewPreset).map((p) => ( - - ))} - - - {intl.formatMessage({ - id: "dialogs.scene_gen.preview_preset_desc", - })} - -
+ + 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) => ( + + )} + renderValue={() => { + return <>; + }} + /> - -
- {intl.formatMessage({ - id: "dialogs.scene_gen.preview_seg_count_head", - })} -
- - setPreviewOptions({ - previewSegments: Number.parseInt( - e.currentTarget.value, - 10 - ), - }) - } - /> - - {intl.formatMessage({ - id: "dialogs.scene_gen.preview_seg_count_desc", - })} - -
+ setOptions({ sprites: v })} + /> + setOptions({ markers: v })} + /> + + setOptions({ + markerImagePreviews: v, + }) + } + /> + setOptions({ markerScreenshots: v })} + /> - -
- {intl.formatMessage({ - id: "dialogs.scene_gen.preview_seg_duration_head", - })} -
- - setPreviewOptions({ - previewSegmentDuration: Number.parseFloat( - e.currentTarget.value - ), - }) - } - /> - - {intl.formatMessage({ - id: "dialogs.scene_gen.preview_seg_duration_desc", - })} - -
+ setOptions({ transcodes: v })} + /> + setOptions({ phashes: v })} + /> - -
- {intl.formatMessage({ - id: "dialogs.scene_gen.preview_exclude_start_time_head", - })} -
- - setPreviewOptions({ - previewExcludeStart: e.currentTarget.value, - }) - } - /> - - {intl.formatMessage({ - id: "dialogs.scene_gen.preview_exclude_start_time_desc", - })} - -
- - -
- {intl.formatMessage({ - id: "dialogs.scene_gen.preview_exclude_end_time_head", - })} -
- - setPreviewOptions({ - previewExcludeEnd: e.currentTarget.value, - }) - } - /> - - {intl.formatMessage({ - id: "dialogs.scene_gen.preview_exclude_end_time_desc", - })} - -
-
-
-
-
- - - setOptions({ sprites: !options.sprites })} - /> - - setOptions({ markers: !options.markers })} - /> -
-
- - - setOptions({ - markerImagePreviews: !options.markerImagePreviews, - }) - } - className="ml-2 flex-grow" - /> - - setOptions({ markerScreenshots: !options.markerScreenshots }) - } - className="ml-2 flex-grow" - /> - -
-
- - - setOptions({ transcodes: !options.transcodes })} - /> - setOptions({ phashes: !options.phashes })} - /> - - - - - setOptions({ - interactiveHeatmapsSpeeds: !options.interactiveHeatmapsSpeeds, - }) - } - /> - - -
- - setOptions({ overwrite: !options.overwrite })} - /> - -
-
+ setOptions({ interactiveHeatmapsSpeeds: v })} + /> + setOptions({ overwrite: v })} + /> + ); }; diff --git a/ui/v2.5/src/components/Settings/Tasks/JobTable.tsx b/ui/v2.5/src/components/Settings/Tasks/JobTable.tsx index 8c9f5fd50..ba6a04b80 100644 --- a/ui/v2.5/src/components/Settings/Tasks/JobTable.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/JobTable.tsx @@ -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 = ({ 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 ( -
+
    + {!queue?.length ? ( + + {intl.formatMessage({ id: "config.tasks.empty_queue" })} + + ) : undefined} {(queue ?? []).map((j) => ( ))}
-
+ ); }; diff --git a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx index 93a4a9ff4..79f88b1a3 100644 --- a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx @@ -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 = ({ options, setOptions: setOptionsState, }) => { - const intl = useIntl(); - const { performers, studios, tags } = options; const wildcard = ["*"]; - function toggle(v?: GQL.Maybe) { - if (!v?.length) { + function set(v?: boolean) { + if (v) { return wildcard; } return []; @@ -43,26 +44,26 @@ const AutoTagOptions: React.FC = ({ } return ( - - + setOptions({ performers: toggle(performers) })} + headingID="performers" + onChange={(v) => setOptions({ performers: set(v) })} /> - setOptions({ studios: toggle(studios) })} + headingID="studios" + onChange={(v) => setOptions({ studios: set(v) })} /> - setOptions({ tags: toggle(tags) })} + headingID="tags" + onChange={(v) => setOptions({ tags: set(v) })} /> - + ); }; @@ -279,96 +280,123 @@ export const LibraryTasks: React.FC = () => { {renderAutoTagDialog()} {maybeRenderIdentifyDialog()} - -
{intl.formatMessage({ id: "library" })}
+ + + + + + + + ), + subHeadingID: "config.tasks.scan_for_content_desc", + }} + topLevel={ + <> + -
- setDialogOpen({ scan: true })} + > + … + + + } + collapsible + > + + + + + + + + + + + + } + subHeadingID="config.tasks.identify.description" + > + + … + + + - - + + + + + + + + ), + subHeadingID: "config.tasks.auto_tag_based_on_filenames", + }} + topLevel={ + <> + + + + } + collapsible + > + setAutoTagOptions(o)} + /> + + - - - - - - setAutoTagOptions(o)} - /> - - - - -
-
- -
- - -
{intl.formatMessage({ id: "config.tasks.generated_content" })}
- -
- - + + + + + + + + ), + subHeadingID: "config.tasks.generate_desc", + }} + topLevel={ - -
-
+ } + collapsible + > + + + ); }; diff --git a/ui/v2.5/src/components/Settings/Tasks/PluginTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/PluginTasks.tsx index 409ffb51c..d1d9ab9e9 100644 --- a/ui/v2.5/src/components/Settings/Tasks/PluginTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/PluginTasks.tsx @@ -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; type PluginTask = Pick; @@ -25,19 +26,21 @@ export const PluginTasks: React.FC = () => { ); return ( - -
{intl.formatMessage({ id: "config.tasks.plugin_tasks" })}
+ {taskPlugins.map((o) => { return ( - -
{o.name}
-
- {renderPluginTasks(o, o.tasks ?? [])} -
-
+ + {renderPluginTasks(o, o.tasks ?? [])} + ); })} -
+ ); } @@ -48,7 +51,11 @@ export const PluginTasks: React.FC = () => { return pluginTasks.map((o) => { return ( - + - + ); }); } diff --git a/ui/v2.5/src/components/Settings/Tasks/ScanOptions.tsx b/ui/v2.5/src/components/Settings/Tasks/ScanOptions.tsx index 49be42779..0e01cfaa0 100644 --- a/ui/v2.5/src/components/Settings/Tasks/ScanOptions.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/ScanOptions.tsx @@ -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 = ({ options, setOptions: setOptionsState, }) => { - const intl = useIntl(); - const { useFileMetadata, stripFileExtension, @@ -29,80 +26,55 @@ export const ScanOptions: React.FC = ({ } return ( - - + - setOptions({ scanGeneratePreviews: !scanGeneratePreviews }) - } + onChange={(v) => setOptions({ scanGeneratePreviews: v })} /> -
-
- - setOptions({ - scanGenerateImagePreviews: !scanGenerateImagePreviews, - }) - } - className="ml-2 flex-grow" - /> -
- setOptions({ scanGenerateImagePreviews: v })} + /> + + - setOptions({ scanGenerateSprites: !scanGenerateSprites }) - } + onChange={(v) => setOptions({ scanGenerateSprites: v })} /> - - setOptions({ scanGeneratePhashes: !scanGeneratePhashes }) - } + headingID="config.tasks.generate_phashes_during_scan" + tooltipID="config.tasks.generate_phashes_during_scan_tooltip" + onChange={(v) => setOptions({ scanGeneratePhashes: v })} /> - - setOptions({ scanGenerateThumbnails: !scanGenerateThumbnails }) - } + headingID="config.tasks.generate_thumbnails_during_scan" + onChange={(v) => setOptions({ scanGenerateThumbnails: v })} /> - setOptions({ stripFileExtension: !stripFileExtension })} + headingID="config.tasks.dont_include_file_extension_as_part_of_the_title" + onChange={(v) => setOptions({ stripFileExtension: v })} /> - setOptions({ useFileMetadata: !useFileMetadata })} + headingID="config.tasks.set_name_date_details_from_metadata_if_present" + onChange={(v) => setOptions({ useFileMetadata: v })} /> -
+ ); }; diff --git a/ui/v2.5/src/components/Settings/Tasks/SettingsTasksPanel.tsx b/ui/v2.5/src/components/Settings/Tasks/SettingsTasksPanel.tsx index 65c47b76e..df1942414 100644 --- a/ui/v2.5/src/components/Settings/Tasks/SettingsTasksPanel.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/SettingsTasksPanel.tsx @@ -19,18 +19,19 @@ export const SettingsTasksPanel: React.FC = () => { } return ( - <> -

{intl.formatMessage({ id: "config.tasks.job_queue" })}

+
+
+

{intl.formatMessage({ id: "config.tasks.job_queue" })}

+ +
- - -
- - -
- -
- - +
+ +
+ +
+ +
+
); }; diff --git a/ui/v2.5/src/components/Settings/Tasks/Task.tsx b/ui/v2.5/src/components/Settings/Tasks/Task.tsx deleted file mode 100644 index 2b516ae87..000000000 --- a/ui/v2.5/src/components/Settings/Tasks/Task.tsx +++ /dev/null @@ -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> = ({ - children, - headingID, - description, -}) => ( -
- {headingID ? ( -
- -
- ) : undefined} - {children} - {description ? ( - {description} - ) : undefined} -
-); diff --git a/ui/v2.5/src/components/Settings/context.tsx b/ui/v2.5/src/components/Settings/context.tsx new file mode 100644 index 000000000..300e130b5 --- /dev/null +++ b/ui/v2.5/src/components/Settings/context.tsx @@ -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) => void; + saveInterface: (input: Partial) => void; + saveDefaults: (input: Partial) => void; + saveScraping: (input: Partial) => void; + saveDLNA: (input: Partial) => void; +} + +export const SettingStateContext = React.createContext({ + 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({}); + const [pendingGeneral, setPendingGeneral] = useState< + GQL.ConfigGeneralInput | undefined + >(); + const [updateGeneralConfig] = useConfigureGeneral(); + + const [iface, setIface] = useState({}); + const [pendingInterface, setPendingInterface] = useState< + GQL.ConfigInterfaceInput | undefined + >(); + const [updateInterfaceConfig] = useConfigureInterface(); + + const [defaults, setDefaults] = useState({}); + const [pendingDefaults, setPendingDefaults] = useState< + GQL.ConfigDefaultSettingsInput | undefined + >(); + const [updateDefaultsConfig] = useConfigureDefaults(); + + const [scraping, setScraping] = useState({}); + const [pendingScraping, setPendingScraping] = useState< + GQL.ConfigScrapingInput | undefined + >(); + const [updateScrapingConfig] = useConfigureScraping(); + + const [dlna, setDLNA] = useState({}); + 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) { + 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) { + 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) { + 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) { + 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) { + if (!dlna) { + return; + } + + setDLNA({ + ...dlna, + ...input, + }); + + setPendingDLNA((current) => { + if (!current) { + return input; + } + return { + ...current, + ...input, + }; + }); + } + + function maybeRenderLoadingIndicator() { + if ( + pendingGeneral || + pendingInterface || + pendingDefaults || + pendingScraping || + pendingDLNA + ) { + return ( +
+ + Loading... + +
+ ); + } + + if (updateSuccess) { + return ( +
+ +
+ ); + } + } + + return ( + + {maybeRenderLoadingIndicator()} + {children} + + ); +}; diff --git a/ui/v2.5/src/components/Settings/styles.scss b/ui/v2.5/src/components/Settings/styles.scss index 810ddf881..d40bda81c 100644 --- a/ui/v2.5/src/components/Settings/styles.scss +++ b/ui/v2.5/src/components/Settings/styles.scss @@ -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; +} diff --git a/ui/v2.5/src/components/Setup/Setup.tsx b/ui/v2.5/src/components/Setup/Setup.tsx index 46f8d4f58..066c24047 100644 --- a/ui/v2.5/src/components/Setup/Setup.tsx +++ b/ui/v2.5/src/components/Setup/Setup.tsx @@ -570,7 +570,7 @@ export const Setup: React.FC = () => {
- + diff --git a/ui/v2.5/src/components/Shared/FolderSelect/FolderSelectDialog.tsx b/ui/v2.5/src/components/Shared/FolderSelect/FolderSelectDialog.tsx index 88e017866..d82b4bc73 100644 --- a/ui/v2.5/src/components/Shared/FolderSelect/FolderSelectDialog.tsx +++ b/ui/v2.5/src/components/Shared/FolderSelect/FolderSelectDialog.tsx @@ -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 = (props: IProps) => { - const [currentDirectory, setCurrentDirectory] = useState(""); +export const FolderSelectDialog: React.FC = ({ + defaultValue: currentValue, + onClose, +}) => { + const [currentDirectory, setCurrentDirectory] = useState( + currentValue ?? "" + ); return ( - props.onClose()} title=""> + onClose()} title=""> Select Directory
@@ -22,11 +28,11 @@ export const FolderSelectDialog: React.FC = (props: IProps) => {
- +
diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 0de6a48cc..602c53e71 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -403,7 +403,7 @@ export const PerformerSelect: React.FC = (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 = ( 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( diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index 94ad6c5fe..46f22e588 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -640,7 +640,7 @@ export const PerformerTagger: React.FC = ({ performers }) => {
Please see{" "} el.scrollIntoView({ behavior: "smooth", block: "center" }) } diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index f1f7c870a..ed4d810c6 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -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]), }); diff --git a/ui/v2.5/src/docs/en/Tasks.md b/ui/v2.5/src/docs/en/Tasks.md index c67b10e30..6f30b5fcd 100644 --- a/ui/v2.5/src/docs/en/Tasks.md +++ b/ui/v2.5/src/docs/en/Tasks.md @@ -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. diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index da4629fb4..00db5d624 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -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" }