mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
Settings UI refactor (#2086)
* Full width settings page * Group settings * Make config fields optional * auto save on change * Add settings context * Refactor stash library section * Restructure settings * Refactor tasks page * Add collapse buttons for setting groups * Add collapse buttons in library * Add loading indicator * Simplify task options. Add details to manual * Add manual links to tasks page * Add help tooltips * Refactor about page * Refactor log page * Refactor tools panel * Refactor plugin page * Refactor task queue * Improve disabled styling
This commit is contained in:
@@ -61,7 +61,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
|
|||||||
cssEnabled
|
cssEnabled
|
||||||
language
|
language
|
||||||
slideshowDelay
|
slideshowDelay
|
||||||
disabledDropdownCreate {
|
disableDropdownCreate {
|
||||||
performer
|
performer
|
||||||
tag
|
tag
|
||||||
studio
|
studio
|
||||||
|
|||||||
@@ -44,13 +44,13 @@ input ConfigGeneralInput {
|
|||||||
"""Path to cache"""
|
"""Path to cache"""
|
||||||
cachePath: String
|
cachePath: String
|
||||||
"""Whether to calculate MD5 checksums for scene video files"""
|
"""Whether to calculate MD5 checksums for scene video files"""
|
||||||
calculateMD5: Boolean!
|
calculateMD5: Boolean
|
||||||
"""Hash algorithm to use for generated file naming"""
|
"""Hash algorithm to use for generated file naming"""
|
||||||
videoFileNamingAlgorithm: HashAlgorithm!
|
videoFileNamingAlgorithm: HashAlgorithm
|
||||||
"""Number of parallel tasks to start during scan/generate"""
|
"""Number of parallel tasks to start during scan/generate"""
|
||||||
parallelTasks: Int
|
parallelTasks: Int
|
||||||
"""Include audio stream in previews"""
|
"""Include audio stream in previews"""
|
||||||
previewAudio: Boolean!
|
previewAudio: Boolean
|
||||||
"""Number of segments in a preview file"""
|
"""Number of segments in a preview file"""
|
||||||
previewSegments: Int
|
previewSegments: Int
|
||||||
"""Preview segment duration, in seconds"""
|
"""Preview segment duration, in seconds"""
|
||||||
@@ -78,13 +78,13 @@ input ConfigGeneralInput {
|
|||||||
"""Name of the log file"""
|
"""Name of the log file"""
|
||||||
logFile: String
|
logFile: String
|
||||||
"""Whether to also output to stderr"""
|
"""Whether to also output to stderr"""
|
||||||
logOut: Boolean!
|
logOut: Boolean
|
||||||
"""Minimum log level"""
|
"""Minimum log level"""
|
||||||
logLevel: String!
|
logLevel: String
|
||||||
"""Whether to log http access"""
|
"""Whether to log http access"""
|
||||||
logAccess: Boolean!
|
logAccess: Boolean
|
||||||
"""True if galleries should be created from folders with images"""
|
"""True if galleries should be created from folders with images"""
|
||||||
createGalleriesFromFolders: Boolean!
|
createGalleriesFromFolders: Boolean
|
||||||
"""Array of video file extensions"""
|
"""Array of video file extensions"""
|
||||||
videoExtensions: [String!]
|
videoExtensions: [String!]
|
||||||
"""Array of image file extensions"""
|
"""Array of image file extensions"""
|
||||||
@@ -104,7 +104,7 @@ input ConfigGeneralInput {
|
|||||||
"""Whether the scraper should check for invalid certificates"""
|
"""Whether the scraper should check for invalid certificates"""
|
||||||
scraperCertCheck: Boolean @deprecated(reason: "use mutation ConfigureScraping(input: ConfigScrapingInput) instead")
|
scraperCertCheck: Boolean @deprecated(reason: "use mutation ConfigureScraping(input: ConfigScrapingInput) instead")
|
||||||
"""Stash-box instances used for tagging"""
|
"""Stash-box instances used for tagging"""
|
||||||
stashBoxes: [StashBoxInput!]!
|
stashBoxes: [StashBoxInput!]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigGeneralResult {
|
type ConfigGeneralResult {
|
||||||
@@ -282,7 +282,8 @@ type ConfigInterfaceResult {
|
|||||||
slideshowDelay: Int
|
slideshowDelay: Int
|
||||||
|
|
||||||
"""Fields are true if creating via dropdown menus are disabled"""
|
"""Fields are true if creating via dropdown menus are disabled"""
|
||||||
disabledDropdownCreate: ConfigDisableDropdownCreate!
|
disableDropdownCreate: ConfigDisableDropdownCreate!
|
||||||
|
disabledDropdownCreate: ConfigDisableDropdownCreate! @deprecated(reason: "Use disableDropdownCreate")
|
||||||
|
|
||||||
"""Handy Connection Key"""
|
"""Handy Connection Key"""
|
||||||
handyKey: String
|
handyKey: String
|
||||||
@@ -316,7 +317,7 @@ input ConfigScrapingInput {
|
|||||||
"""Scraper CDP path. Path to chrome executable or remote address"""
|
"""Scraper CDP path. Path to chrome executable or remote address"""
|
||||||
scraperCDPPath: String
|
scraperCDPPath: String
|
||||||
"""Whether the scraper should check for invalid certificates"""
|
"""Whether the scraper should check for invalid certificates"""
|
||||||
scraperCertCheck: Boolean!
|
scraperCertCheck: Boolean
|
||||||
"""Tags blacklist during scraping"""
|
"""Tags blacklist during scraping"""
|
||||||
excludeTagPatterns: [String!]
|
excludeTagPatterns: [String!]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,26 +110,34 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
|
|||||||
c.Set(config.Cache, input.CachePath)
|
c.Set(config.Cache, input.CachePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !input.CalculateMd5 && input.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {
|
if input.VideoFileNamingAlgorithm != nil && *input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() {
|
||||||
|
calculateMD5 := c.IsCalculateMD5()
|
||||||
|
if input.CalculateMd5 != nil {
|
||||||
|
calculateMD5 = *input.CalculateMd5
|
||||||
|
}
|
||||||
|
if !calculateMD5 && *input.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {
|
||||||
return makeConfigGeneralResult(), errors.New("calculateMD5 must be true if using MD5")
|
return makeConfigGeneralResult(), errors.New("calculateMD5 must be true if using MD5")
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() {
|
|
||||||
// validate changing VideoFileNamingAlgorithm
|
// 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
|
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 {
|
if input.ParallelTasks != nil {
|
||||||
c.Set(config.ParallelTasks, *input.ParallelTasks)
|
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 {
|
if input.PreviewSegments != nil {
|
||||||
c.Set(config.PreviewSegments, *input.PreviewSegments)
|
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.LogFile, input.LogFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set(config.LogOut, input.LogOut)
|
if input.LogOut != nil {
|
||||||
c.Set(config.LogAccess, input.LogAccess)
|
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)
|
c.Set(config.LogLevel, input.LogLevel)
|
||||||
logger.SetLogLevel(input.LogLevel)
|
logger.SetLogLevel(*input.LogLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.Excludes != nil {
|
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.GalleryExtensions, input.GalleryExtensions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.CreateGalleriesFromFolders != nil {
|
||||||
c.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders)
|
c.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders)
|
||||||
|
}
|
||||||
|
|
||||||
if input.CustomPerformerImageLocation != nil {
|
if input.CustomPerformerImageLocation != nil {
|
||||||
c.Set(config.CustomPerformerImageLocation, *input.CustomPerformerImageLocation)
|
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)
|
c.Set(config.SlideshowDelay, *input.SlideshowDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
css := ""
|
|
||||||
|
|
||||||
if input.CSS != nil {
|
if input.CSS != nil {
|
||||||
css = *input.CSS
|
c.SetCSS(*input.CSS)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.SetCSS(css)
|
|
||||||
|
|
||||||
setBool(config.CSSEnabled, input.CSSEnabled)
|
setBool(config.CSSEnabled, input.CSSEnabled)
|
||||||
|
|
||||||
if input.DisableDropdownCreate != nil {
|
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.DLNAServerName, *input.ServerName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.WhitelistedIPs != nil {
|
||||||
c.Set(config.DLNADefaultIPWhitelist, input.WhitelistedIPs)
|
c.Set(config.DLNADefaultIPWhitelist, input.WhitelistedIPs)
|
||||||
|
}
|
||||||
|
|
||||||
currentDLNAEnabled := c.GetDLNADefaultEnabled()
|
currentDLNAEnabled := c.GetDLNADefaultEnabled()
|
||||||
if input.Enabled != nil && *input.Enabled != currentDLNAEnabled {
|
if input.Enabled != nil && *input.Enabled != currentDLNAEnabled {
|
||||||
@@ -349,7 +362,9 @@ func (r *mutationResolver) ConfigureDlna(ctx context.Context, input models.Confi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.Interfaces != nil {
|
||||||
c.Set(config.DLNAInterfaces, input.Interfaces)
|
c.Set(config.DLNAInterfaces, input.Interfaces)
|
||||||
|
}
|
||||||
|
|
||||||
if err := c.Write(); err != nil {
|
if err := c.Write(); err != nil {
|
||||||
return makeConfigDLNAResult(), err
|
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.ScraperExcludeTagPatterns, input.ExcludeTagPatterns)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.ScraperCertCheck != nil {
|
||||||
c.Set(config.ScraperCertCheck, input.ScraperCertCheck)
|
c.Set(config.ScraperCertCheck, input.ScraperCertCheck)
|
||||||
|
}
|
||||||
|
|
||||||
if refreshScraperCache {
|
if refreshScraperCache {
|
||||||
manager.GetInstance().RefreshScraperCache()
|
manager.GetInstance().RefreshScraperCache()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
|||||||
handyKey := config.GetHandyKey()
|
handyKey := config.GetHandyKey()
|
||||||
scriptOffset := config.GetFunscriptOffset()
|
scriptOffset := config.GetFunscriptOffset()
|
||||||
|
|
||||||
|
// FIXME - misnamed output field means we have redundant fields
|
||||||
|
disableDropdownCreate := config.GetDisableDropdownCreate()
|
||||||
|
|
||||||
return &models.ConfigInterfaceResult{
|
return &models.ConfigInterfaceResult{
|
||||||
MenuItems: menuItems,
|
MenuItems: menuItems,
|
||||||
SoundOnPreview: &soundOnPreview,
|
SoundOnPreview: &soundOnPreview,
|
||||||
@@ -136,7 +139,11 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
|||||||
CSSEnabled: &cssEnabled,
|
CSSEnabled: &cssEnabled,
|
||||||
Language: &language,
|
Language: &language,
|
||||||
SlideshowDelay: &slideshowDelay,
|
SlideshowDelay: &slideshowDelay,
|
||||||
DisabledDropdownCreate: config.GetDisableDropdownCreate(),
|
|
||||||
|
// FIXME - see above
|
||||||
|
DisabledDropdownCreate: disableDropdownCreate,
|
||||||
|
DisableDropdownCreate: disableDropdownCreate,
|
||||||
|
|
||||||
HandyKey: &handyKey,
|
HandyKey: &handyKey,
|
||||||
FunscriptOffset: &scriptOffset,
|
FunscriptOffset: &scriptOffset,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { Migrate } from "./components/Setup/Migrate";
|
|||||||
import * as GQL from "./core/generated-graphql";
|
import * as GQL from "./core/generated-graphql";
|
||||||
import { LoadingIndicator, TITLE_SUFFIX } from "./components/Shared";
|
import { LoadingIndicator, TITLE_SUFFIX } from "./components/Shared";
|
||||||
import { ConfigurationProvider } from "./hooks/Config";
|
import { ConfigurationProvider } from "./hooks/Config";
|
||||||
|
import { ManualProvider } from "./components/Help/Manual";
|
||||||
|
|
||||||
initPolyfills();
|
initPolyfills();
|
||||||
|
|
||||||
@@ -146,12 +147,14 @@ export const App: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<LightboxProvider>
|
<LightboxProvider>
|
||||||
|
<ManualProvider>
|
||||||
<Helmet
|
<Helmet
|
||||||
titleTemplate={`%s ${TITLE_SUFFIX}`}
|
titleTemplate={`%s ${TITLE_SUFFIX}`}
|
||||||
defaultTitle="Stash"
|
defaultTitle="Stash"
|
||||||
/>
|
/>
|
||||||
{maybeRenderNavbar()}
|
{maybeRenderNavbar()}
|
||||||
<div className="main container-fluid">{renderContent()}</div>
|
<div className="main container-fluid">{renderContent()}</div>
|
||||||
|
</ManualProvider>
|
||||||
</LightboxProvider>
|
</LightboxProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ConfigurationProvider>
|
</ConfigurationProvider>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* Add forward jump 10 second button to video player. ([#1973](https://github.com/stashapp/stash/pull/1973))
|
* Add forward jump 10 second button to video player. ([#1973](https://github.com/stashapp/stash/pull/1973))
|
||||||
|
|
||||||
### 🎨 Improvements
|
### 🎨 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))
|
* 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))
|
* 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))
|
* Prefer right-most Studio match in the file path when autotagging. ([#2057](https://github.com/stashapp/stash/pull/2057))
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ConfigurationContext } from "src/hooks/Config";
|
|||||||
import { Manual } from "../Help/Manual";
|
import { Manual } from "../Help/Manual";
|
||||||
import { withoutTypename } from "src/utils";
|
import { withoutTypename } from "src/utils";
|
||||||
import { GenerateOptions } from "../Settings/Tasks/GenerateOptions";
|
import { GenerateOptions } from "../Settings/Tasks/GenerateOptions";
|
||||||
|
import { SettingSection } from "../Settings/SettingSection";
|
||||||
|
|
||||||
interface ISceneGenerateDialog {
|
interface ISceneGenerateDialog {
|
||||||
selectedIds?: string[];
|
selectedIds?: string[];
|
||||||
@@ -276,7 +277,9 @@ export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
|
|||||||
>
|
>
|
||||||
<Form>
|
<Form>
|
||||||
{selectionStatus}
|
{selectionStatus}
|
||||||
|
<SettingSection>
|
||||||
<GenerateOptions options={options} setOptions={setOptions} />
|
<GenerateOptions options={options} setOptions={setOptions} />
|
||||||
|
</SettingSection>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { Modal, Container, Row, Col, Nav, Tab } from "react-bootstrap";
|
||||||
import Introduction from "src/docs/en/Introduction.md";
|
import Introduction from "src/docs/en/Introduction.md";
|
||||||
import Tasks from "src/docs/en/Tasks.md";
|
import Tasks from "src/docs/en/Tasks.md";
|
||||||
@@ -155,6 +155,12 @@ export const Manual: React.FC<IManualProps> = ({
|
|||||||
defaultActiveTab ?? content[0].key
|
defaultActiveTab ?? content[0].key
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultActiveTab) {
|
||||||
|
setActiveTab(defaultActiveTab);
|
||||||
|
}
|
||||||
|
}, [defaultActiveTab]);
|
||||||
|
|
||||||
// links to other manual pages are specified as "/help/page.md"
|
// links to other manual pages are specified as "/help/page.md"
|
||||||
// intercept clicks to these pages and set the tab accordingly
|
// intercept clicks to these pages and set the tab accordingly
|
||||||
function interceptLinkClick(
|
function interceptLinkClick(
|
||||||
@@ -226,3 +232,63 @@ export const Manual: React.FC<IManualProps> = ({
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface IManualContextState {
|
||||||
|
openManual: (tab?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManualStateContext = React.createContext<IManualContextState>({
|
||||||
|
openManual: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ManualProvider: React.FC = ({ children }) => {
|
||||||
|
const [showManual, setShowManual] = useState(false);
|
||||||
|
const [manualLink, setManualLink] = useState<string | undefined>();
|
||||||
|
|
||||||
|
function openManual(tab?: string) {
|
||||||
|
setManualLink(tab);
|
||||||
|
setShowManual(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (manualLink) setManualLink(undefined);
|
||||||
|
}, [manualLink]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ManualStateContext.Provider
|
||||||
|
value={{
|
||||||
|
openManual,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Manual
|
||||||
|
show={showManual}
|
||||||
|
onClose={() => setShowManual(false)}
|
||||||
|
defaultActiveTab={manualLink}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</ManualStateContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IManualLink {
|
||||||
|
tab: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManualLink: React.FC<PropsWithChildren<IManualLink>> = ({
|
||||||
|
tab,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const { openManual } = React.useContext(ManualStateContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`/help/${tab}.md`}
|
||||||
|
onClick={(e) => {
|
||||||
|
openManual(`${tab}.md`);
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import Mousetrap from "mousetrap";
|
|||||||
import { SessionUtils } from "src/utils";
|
import { SessionUtils } from "src/utils";
|
||||||
import { Icon } from "src/components/Shared";
|
import { Icon } from "src/components/Shared";
|
||||||
import { ConfigurationContext } from "src/hooks/Config";
|
import { ConfigurationContext } from "src/hooks/Config";
|
||||||
import { Manual } from "./Help/Manual";
|
import { ManualStateContext } from "./Help/Manual";
|
||||||
import { SettingsButton } from "./SettingsButton";
|
import { SettingsButton } from "./SettingsButton";
|
||||||
|
|
||||||
interface IMenuItem {
|
interface IMenuItem {
|
||||||
@@ -141,12 +141,12 @@ export const MainNavbar: React.FC = () => {
|
|||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { configuration, loading } = React.useContext(ConfigurationContext);
|
const { configuration, loading } = React.useContext(ConfigurationContext);
|
||||||
|
const { openManual } = React.useContext(ManualStateContext);
|
||||||
|
|
||||||
// Show all menu items by default, unless config says otherwise
|
// Show all menu items by default, unless config says otherwise
|
||||||
const [menuItems, setMenuItems] = useState<IMenuItem[]>(allMenuItems);
|
const [menuItems, setMenuItems] = useState<IMenuItem[]>(allMenuItems);
|
||||||
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [showManual, setShowManual] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const iCfg = configuration?.interface;
|
const iCfg = configuration?.interface;
|
||||||
@@ -203,7 +203,7 @@ export const MainNavbar: React.FC = () => {
|
|||||||
|
|
||||||
// set up hotkeys
|
// set up hotkeys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Mousetrap.bind("?", () => setShowManual(!showManual));
|
Mousetrap.bind("?", () => openManual());
|
||||||
Mousetrap.bind("g z", () => goto("/settings"));
|
Mousetrap.bind("g z", () => goto("/settings"));
|
||||||
|
|
||||||
menuItems.forEach((item) =>
|
menuItems.forEach((item) =>
|
||||||
@@ -267,7 +267,7 @@ export const MainNavbar: React.FC = () => {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
<Button
|
<Button
|
||||||
className="nav-utility minimal"
|
className="nav-utility minimal"
|
||||||
onClick={() => setShowManual(true)}
|
onClick={() => openManual()}
|
||||||
title="Help"
|
title="Help"
|
||||||
>
|
>
|
||||||
<Icon icon="question-circle" />
|
<Icon icon="question-circle" />
|
||||||
@@ -279,7 +279,6 @@ export const MainNavbar: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Manual show={showManual} onClose={() => setShowManual(false)} />
|
|
||||||
<Navbar
|
<Navbar
|
||||||
collapseOnSelect
|
collapseOnSelect
|
||||||
fixed="top"
|
fixed="top"
|
||||||
|
|||||||
133
ui/v2.5/src/components/Settings/GeneratePreviewOptions.tsx
Normal file
133
ui/v2.5/src/components/Settings/GeneratePreviewOptions.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useIntl } from "react-intl";
|
||||||
|
import { Form } from "react-bootstrap";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
|
||||||
|
export type VideoPreviewSettingsInput = Pick<
|
||||||
|
GQL.ConfigGeneralInput,
|
||||||
|
| "previewSegments"
|
||||||
|
| "previewSegmentDuration"
|
||||||
|
| "previewExcludeStart"
|
||||||
|
| "previewExcludeEnd"
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface IVideoPreviewInput {
|
||||||
|
value: VideoPreviewSettingsInput;
|
||||||
|
setValue: (v: VideoPreviewSettingsInput) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VideoPreviewInput: React.FC<IVideoPreviewInput> = ({
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
function set(v: Partial<VideoPreviewSettingsInput>) {
|
||||||
|
setValue({
|
||||||
|
...value,
|
||||||
|
...v,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
previewSegments,
|
||||||
|
previewSegmentDuration,
|
||||||
|
previewExcludeStart,
|
||||||
|
previewExcludeEnd,
|
||||||
|
} = value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Form.Group id="preview-segments">
|
||||||
|
<h6>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.scene_gen.preview_seg_count_head",
|
||||||
|
})}
|
||||||
|
</h6>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
type="number"
|
||||||
|
value={previewSegments?.toString() ?? 0}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
set({
|
||||||
|
previewSegments: Number.parseInt(
|
||||||
|
e.currentTarget.value || "0",
|
||||||
|
10
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.scene_gen.preview_seg_count_desc",
|
||||||
|
})}
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group id="preview-segment-duration">
|
||||||
|
<h6>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.scene_gen.preview_seg_duration_head",
|
||||||
|
})}
|
||||||
|
</h6>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
type="number"
|
||||||
|
value={previewSegmentDuration?.toString() ?? 0}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
set({
|
||||||
|
previewSegmentDuration: Number.parseFloat(
|
||||||
|
e.currentTarget.value || "0"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.scene_gen.preview_seg_duration_desc",
|
||||||
|
})}
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group id="preview-exclude-start">
|
||||||
|
<h6>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.scene_gen.preview_exclude_start_time_head",
|
||||||
|
})}
|
||||||
|
</h6>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
value={previewExcludeStart ?? ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
set({ previewExcludeStart: e.currentTarget.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.scene_gen.preview_exclude_start_time_desc",
|
||||||
|
})}
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group id="preview-exclude-start">
|
||||||
|
<h6>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.scene_gen.preview_exclude_end_time_head",
|
||||||
|
})}
|
||||||
|
</h6>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
value={previewExcludeEnd ?? ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
set({ previewExcludeEnd: e.currentTarget.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.scene_gen.preview_exclude_end_time_desc",
|
||||||
|
})}
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
467
ui/v2.5/src/components/Settings/Inputs.tsx
Normal file
467
ui/v2.5/src/components/Settings/Inputs.tsx
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button, Collapse, Form, Modal, ModalProps } from "react-bootstrap";
|
||||||
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
|
import { PropsWithChildren } from "react-router/node_modules/@types/react";
|
||||||
|
import { Icon } from "../Shared";
|
||||||
|
import { StringListInput } from "../Shared/StringListInput";
|
||||||
|
|
||||||
|
interface ISetting {
|
||||||
|
id?: string;
|
||||||
|
className?: string;
|
||||||
|
heading?: React.ReactNode;
|
||||||
|
headingID?: string;
|
||||||
|
subHeadingID?: string;
|
||||||
|
subHeading?: React.ReactNode;
|
||||||
|
tooltipID?: string;
|
||||||
|
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Setting: React.FC<PropsWithChildren<ISetting>> = ({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
heading,
|
||||||
|
headingID,
|
||||||
|
subHeadingID,
|
||||||
|
subHeading,
|
||||||
|
children,
|
||||||
|
tooltipID,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
function renderHeading() {
|
||||||
|
if (headingID) {
|
||||||
|
return intl.formatMessage({ id: headingID });
|
||||||
|
}
|
||||||
|
return heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubHeading() {
|
||||||
|
if (subHeadingID) {
|
||||||
|
return (
|
||||||
|
<div className="sub-heading">
|
||||||
|
{intl.formatMessage({ id: subHeadingID })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (subHeading) {
|
||||||
|
return <div className="sub-heading">{subHeading}</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltip = tooltipID ? intl.formatMessage({ id: tooltipID }) : undefined;
|
||||||
|
const disabledClassName = disabled ? "disabled" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`setting ${className ?? ""} ${disabledClassName}`}
|
||||||
|
id={id}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 title={tooltip}>{renderHeading()}</h3>
|
||||||
|
{renderSubHeading()}
|
||||||
|
</div>
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ISettingGroup {
|
||||||
|
settingProps?: ISetting;
|
||||||
|
topLevel?: JSX.Element;
|
||||||
|
collapsible?: boolean;
|
||||||
|
collapsedDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingGroup: React.FC<PropsWithChildren<ISettingGroup>> = ({
|
||||||
|
settingProps,
|
||||||
|
topLevel,
|
||||||
|
collapsible,
|
||||||
|
collapsedDefault,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(!collapsedDefault);
|
||||||
|
|
||||||
|
function renderCollapseButton() {
|
||||||
|
if (!collapsible) return;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="setting-group-collapse-button"
|
||||||
|
variant="minimal"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
<Icon className="fa-fw" icon={open ? "chevron-up" : "chevron-down"} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDivClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
|
if (!collapsible) return;
|
||||||
|
|
||||||
|
// ensure button was not clicked
|
||||||
|
let target: HTMLElement | null = e.target as HTMLElement;
|
||||||
|
while (target && target !== e.currentTarget) {
|
||||||
|
if (
|
||||||
|
target.nodeName.toLowerCase() === "button" ||
|
||||||
|
target.nodeName.toLowerCase() === "a"
|
||||||
|
) {
|
||||||
|
// button clicked, swallow event
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target = target.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(!open);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`setting-group ${collapsible ? "collapsible" : ""}`}>
|
||||||
|
<Setting {...settingProps} onClick={onDivClick}>
|
||||||
|
{topLevel}
|
||||||
|
{renderCollapseButton()}
|
||||||
|
</Setting>
|
||||||
|
<Collapse in={open}>
|
||||||
|
<div className="collapsible-section">{children}</div>
|
||||||
|
</Collapse>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IBooleanSetting extends ISetting {
|
||||||
|
id: string;
|
||||||
|
checked?: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BooleanSetting: React.FC<IBooleanSetting> = (props) => {
|
||||||
|
const { id, disabled, checked, onChange, ...settingProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Setting {...settingProps} disabled={disabled}>
|
||||||
|
<Form.Switch
|
||||||
|
id={id}
|
||||||
|
disabled={disabled}
|
||||||
|
checked={checked ?? false}
|
||||||
|
onChange={() => onChange(!checked)}
|
||||||
|
/>
|
||||||
|
</Setting>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ISelectSetting extends ISetting {
|
||||||
|
value?: string | number | string[] | undefined;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectSetting: React.FC<PropsWithChildren<ISelectSetting>> = ({
|
||||||
|
id,
|
||||||
|
headingID,
|
||||||
|
subHeadingID,
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Setting headingID={headingID} subHeadingID={subHeadingID} id={id}>
|
||||||
|
<Form.Control
|
||||||
|
className="input-control"
|
||||||
|
as="select"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Form.Control>
|
||||||
|
</Setting>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IDialogSetting<T> extends ISetting {
|
||||||
|
buttonText?: string;
|
||||||
|
buttonTextID?: string;
|
||||||
|
value?: T;
|
||||||
|
renderValue?: (v: T | undefined) => JSX.Element;
|
||||||
|
onChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
headingID,
|
||||||
|
subHeadingID,
|
||||||
|
subHeading,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
renderValue,
|
||||||
|
buttonText,
|
||||||
|
buttonTextID,
|
||||||
|
disabled,
|
||||||
|
} = props;
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const disabledClassName = disabled ? "disabled" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`setting ${className ?? ""} ${disabledClassName}`} id={id}>
|
||||||
|
<div>
|
||||||
|
<h3>{headingID ? intl.formatMessage({ id: headingID }) : undefined}</h3>
|
||||||
|
|
||||||
|
<div className="value">
|
||||||
|
{renderValue ? renderValue(value) : undefined}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subHeadingID ? (
|
||||||
|
<div className="sub-heading">
|
||||||
|
{intl.formatMessage({ id: subHeadingID })}
|
||||||
|
</div>
|
||||||
|
) : subHeading ? (
|
||||||
|
<div className="sub-heading">{subHeading}</div>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button onClick={() => onChange()} disabled={disabled}>
|
||||||
|
{buttonText ? (
|
||||||
|
buttonText
|
||||||
|
) : (
|
||||||
|
<FormattedMessage id={buttonTextID ?? "actions.edit"} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ISettingModal<T> {
|
||||||
|
heading?: string;
|
||||||
|
headingID?: string;
|
||||||
|
subHeadingID?: string;
|
||||||
|
subHeading?: React.ReactNode;
|
||||||
|
value: T | undefined;
|
||||||
|
close: (v?: T) => void;
|
||||||
|
renderField: (value: T | undefined, setValue: (v?: T) => void) => JSX.Element;
|
||||||
|
modalProps?: ModalProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingModal = <T extends {}>(props: ISettingModal<T>) => {
|
||||||
|
const {
|
||||||
|
heading,
|
||||||
|
headingID,
|
||||||
|
subHeading,
|
||||||
|
subHeadingID,
|
||||||
|
value,
|
||||||
|
close,
|
||||||
|
renderField,
|
||||||
|
modalProps,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
const [currentValue, setCurrentValue] = useState<T | undefined>(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show onHide={() => close()} id="setting-dialog" {...modalProps}>
|
||||||
|
<Form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
close(currentValue);
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
{headingID ? <FormattedMessage id={headingID} /> : heading}
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{renderField(currentValue, setCurrentValue)}
|
||||||
|
{subHeadingID ? (
|
||||||
|
<div className="sub-heading">
|
||||||
|
{intl.formatMessage({ id: subHeadingID })}
|
||||||
|
</div>
|
||||||
|
) : subHeading ? (
|
||||||
|
<div className="sub-heading">{subHeading}</div>
|
||||||
|
) : undefined}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={() => close()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => close(currentValue)}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IModalSetting<T> extends ISetting {
|
||||||
|
value: T | undefined;
|
||||||
|
buttonText?: string;
|
||||||
|
buttonTextID?: string;
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
renderField: (value: T | undefined, setValue: (v?: T) => void) => JSX.Element;
|
||||||
|
renderValue?: (v: T | undefined) => JSX.Element;
|
||||||
|
modalProps?: ModalProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModalSetting = <T extends {}>(props: IModalSetting<T>) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
headingID,
|
||||||
|
subHeadingID,
|
||||||
|
subHeading,
|
||||||
|
onChange,
|
||||||
|
renderField,
|
||||||
|
renderValue,
|
||||||
|
buttonText,
|
||||||
|
buttonTextID,
|
||||||
|
modalProps,
|
||||||
|
disabled,
|
||||||
|
} = props;
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showModal ? (
|
||||||
|
<SettingModal<T>
|
||||||
|
headingID={headingID}
|
||||||
|
subHeadingID={subHeadingID}
|
||||||
|
subHeading={subHeading}
|
||||||
|
value={value}
|
||||||
|
renderField={renderField}
|
||||||
|
close={(v) => {
|
||||||
|
if (v !== undefined) onChange(v);
|
||||||
|
setShowModal(false);
|
||||||
|
}}
|
||||||
|
{...modalProps}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
<ChangeButtonSetting<T>
|
||||||
|
id={id}
|
||||||
|
className={className}
|
||||||
|
disabled={disabled}
|
||||||
|
buttonText={buttonText}
|
||||||
|
buttonTextID={buttonTextID}
|
||||||
|
headingID={headingID}
|
||||||
|
subHeadingID={subHeadingID}
|
||||||
|
subHeading={subHeading}
|
||||||
|
value={value}
|
||||||
|
onChange={() => setShowModal(true)}
|
||||||
|
renderValue={renderValue}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStringSetting extends ISetting {
|
||||||
|
value: string | undefined;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StringSetting: React.FC<IStringSetting> = (props) => {
|
||||||
|
return (
|
||||||
|
<ModalSetting<string>
|
||||||
|
{...props}
|
||||||
|
renderField={(value, setValue) => (
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setValue(e.currentTarget.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderValue={(value) => <span>{value}</span>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface INumberSetting extends ISetting {
|
||||||
|
value: number | undefined;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NumberSetting: React.FC<INumberSetting> = (props) => {
|
||||||
|
return (
|
||||||
|
<ModalSetting<number>
|
||||||
|
{...props}
|
||||||
|
renderField={(value, setValue) => (
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
type="number"
|
||||||
|
value={value ?? 0}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setValue(Number.parseInt(e.currentTarget.value || "0", 10))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderValue={(value) => <span>{value}</span>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IStringListSetting extends ISetting {
|
||||||
|
value: string[] | undefined;
|
||||||
|
defaultNewValue?: string;
|
||||||
|
onChange: (v: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StringListSetting: React.FC<IStringListSetting> = (props) => {
|
||||||
|
return (
|
||||||
|
<ModalSetting<string[]>
|
||||||
|
{...props}
|
||||||
|
renderField={(value, setValue) => (
|
||||||
|
<StringListInput
|
||||||
|
value={value ?? []}
|
||||||
|
setValue={setValue}
|
||||||
|
defaultNewValue={props.defaultNewValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderValue={(value) => (
|
||||||
|
<div>
|
||||||
|
{value?.map((v, i) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<div key={i}>{v}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IConstantSetting<T> extends ISetting {
|
||||||
|
value?: T;
|
||||||
|
renderValue?: (v: T | undefined) => JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConstantSetting = <T extends {}>(props: IConstantSetting<T>) => {
|
||||||
|
const { id, headingID, subHeading, subHeadingID, renderValue, value } = props;
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="setting" id={id}>
|
||||||
|
<div>
|
||||||
|
<h3>{headingID ? intl.formatMessage({ id: headingID }) : undefined}</h3>
|
||||||
|
|
||||||
|
<div className="value">{renderValue ? renderValue(value) : value}</div>
|
||||||
|
|
||||||
|
{subHeadingID ? (
|
||||||
|
<div className="sub-heading">
|
||||||
|
{intl.formatMessage({ id: subHeadingID })}
|
||||||
|
</div>
|
||||||
|
) : subHeading ? (
|
||||||
|
<div className="sub-heading">{subHeading}</div>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
31
ui/v2.5/src/components/Settings/SettingSection.tsx
Normal file
31
ui/v2.5/src/components/Settings/SettingSection.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card } from "react-bootstrap";
|
||||||
|
import { useIntl } from "react-intl";
|
||||||
|
import { PropsWithChildren } from "react-router/node_modules/@types/react";
|
||||||
|
|
||||||
|
interface ISettingGroup {
|
||||||
|
id?: string;
|
||||||
|
headingID?: string;
|
||||||
|
subHeadingID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingSection: React.FC<PropsWithChildren<ISettingGroup>> = ({
|
||||||
|
id,
|
||||||
|
children,
|
||||||
|
headingID,
|
||||||
|
subHeadingID,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="setting-section" id={id}>
|
||||||
|
<h1>{headingID ? intl.formatMessage({ id: headingID }) : undefined}</h1>
|
||||||
|
{subHeadingID ? (
|
||||||
|
<div className="sub-heading">
|
||||||
|
{intl.formatMessage({ id: subHeadingID })}
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
<Card>{children}</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import queryString from "query-string";
|
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 { useHistory, useLocation } from "react-router-dom";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { TITLE_SUFFIX } from "src/components/Shared";
|
import { TITLE_SUFFIX } from "src/components/Shared";
|
||||||
import { SettingsAboutPanel } from "./SettingsAboutPanel";
|
import { SettingsAboutPanel } from "./SettingsAboutPanel";
|
||||||
import { SettingsConfigurationPanel } from "./SettingsConfigurationPanel";
|
import { SettingsConfigurationPanel } from "./SettingsSystemPanel";
|
||||||
import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel";
|
import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel";
|
||||||
import { SettingsLogsPanel } from "./SettingsLogsPanel";
|
import { SettingsLogsPanel } from "./SettingsLogsPanel";
|
||||||
import { SettingsTasksPanel } from "./Tasks/SettingsTasksPanel";
|
import { SettingsTasksPanel } from "./Tasks/SettingsTasksPanel";
|
||||||
import { SettingsPluginsPanel } from "./SettingsPluginsPanel";
|
import { SettingsPluginsPanel } from "./SettingsPluginsPanel";
|
||||||
import { SettingsScrapingPanel } from "./SettingsScrapingPanel";
|
import { SettingsScrapingPanel } from "./SettingsScrapingPanel";
|
||||||
import { SettingsToolsPanel } from "./SettingsToolsPanel";
|
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 = () => {
|
export const Settings: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -27,22 +30,26 @@ export const Settings: React.FC = () => {
|
|||||||
id: "settings",
|
id: "settings",
|
||||||
})} ${TITLE_SUFFIX}`;
|
})} ${TITLE_SUFFIX}`;
|
||||||
return (
|
return (
|
||||||
<Card className="col col-lg-9 mx-auto">
|
|
||||||
<Helmet
|
|
||||||
defaultTitle={title_template}
|
|
||||||
titleTemplate={`%s | ${title_template}`}
|
|
||||||
/>
|
|
||||||
<Tab.Container
|
<Tab.Container
|
||||||
activeKey={defaultTab}
|
activeKey={defaultTab}
|
||||||
id="configuration-tabs"
|
id="configuration-tabs"
|
||||||
onSelect={(tab) => tab && onSelect(tab)}
|
onSelect={(tab) => tab && onSelect(tab)}
|
||||||
>
|
>
|
||||||
|
<Helmet
|
||||||
|
defaultTitle={title_template}
|
||||||
|
titleTemplate={`%s | ${title_template}`}
|
||||||
|
/>
|
||||||
<Row>
|
<Row>
|
||||||
<Col sm={3} md={2}>
|
<Col id="settings-menu-container" sm={3} md={3} xl={2}>
|
||||||
<Nav variant="pills" className="flex-column">
|
<Nav variant="pills" className="flex-column">
|
||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
<Nav.Link eventKey="configuration">
|
<Nav.Link eventKey="tasks">
|
||||||
<FormattedMessage id="configuration" />
|
<FormattedMessage id="config.categories.tasks" />
|
||||||
|
</Nav.Link>
|
||||||
|
</Nav.Item>
|
||||||
|
<Nav.Item>
|
||||||
|
<Nav.Link eventKey="library">
|
||||||
|
<FormattedMessage id="library" />
|
||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
@@ -51,21 +58,23 @@ export const Settings: React.FC = () => {
|
|||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
<Nav.Link eventKey="tasks">
|
<Nav.Link eventKey="security">
|
||||||
<FormattedMessage id="config.categories.tasks" />
|
<FormattedMessage id="config.categories.security" />
|
||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
<Nav.Link eventKey="dlna">DLNA</Nav.Link>
|
<Nav.Link eventKey="metadata-providers">
|
||||||
</Nav.Item>
|
<FormattedMessage id="config.categories.metadata_providers" />
|
||||||
<Nav.Item>
|
|
||||||
<Nav.Link eventKey="tools">
|
|
||||||
<FormattedMessage id="config.categories.tools" />
|
|
||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
<Nav.Link eventKey="scraping">
|
<Nav.Link eventKey="services">
|
||||||
<FormattedMessage id="config.categories.scraping" />
|
<FormattedMessage id="config.categories.services" />
|
||||||
|
</Nav.Link>
|
||||||
|
</Nav.Item>
|
||||||
|
<Nav.Item>
|
||||||
|
<Nav.Link eventKey="system">
|
||||||
|
<FormattedMessage id="config.categories.system" />
|
||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
@@ -78,6 +87,11 @@ export const Settings: React.FC = () => {
|
|||||||
<FormattedMessage id="config.categories.logs" />
|
<FormattedMessage id="config.categories.logs" />
|
||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
|
<Nav.Item>
|
||||||
|
<Nav.Link eventKey="tools">
|
||||||
|
<FormattedMessage id="config.categories.tools" />
|
||||||
|
</Nav.Link>
|
||||||
|
</Nav.Item>
|
||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
<Nav.Link eventKey="about">
|
<Nav.Link eventKey="about">
|
||||||
<FormattedMessage id="config.categories.about" />
|
<FormattedMessage id="config.categories.about" />
|
||||||
@@ -86,26 +100,38 @@ export const Settings: React.FC = () => {
|
|||||||
<hr className="d-sm-none" />
|
<hr className="d-sm-none" />
|
||||||
</Nav>
|
</Nav>
|
||||||
</Col>
|
</Col>
|
||||||
<Col sm={9} md={10}>
|
<Col
|
||||||
<Tab.Content>
|
id="settings-container"
|
||||||
<Tab.Pane eventKey="configuration">
|
sm={{ offset: 3 }}
|
||||||
<SettingsConfigurationPanel />
|
md={{ offset: 3 }}
|
||||||
|
xl={{ offset: 2 }}
|
||||||
|
>
|
||||||
|
<SettingsContext>
|
||||||
|
<Tab.Content className="mx-auto">
|
||||||
|
<Tab.Pane eventKey="library">
|
||||||
|
<SettingsLibraryPanel />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
<Tab.Pane eventKey="interface">
|
<Tab.Pane eventKey="interface">
|
||||||
<SettingsInterfacePanel />
|
<SettingsInterfacePanel />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
|
<Tab.Pane eventKey="security">
|
||||||
|
<SettingsSecurityPanel />
|
||||||
|
</Tab.Pane>
|
||||||
<Tab.Pane eventKey="tasks">
|
<Tab.Pane eventKey="tasks">
|
||||||
<SettingsTasksPanel />
|
<SettingsTasksPanel />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
<Tab.Pane eventKey="dlna" unmountOnExit>
|
<Tab.Pane eventKey="services" unmountOnExit>
|
||||||
<SettingsDLNAPanel />
|
<SettingsServicesPanel />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
<Tab.Pane eventKey="tools" unmountOnExit>
|
<Tab.Pane eventKey="tools" unmountOnExit>
|
||||||
<SettingsToolsPanel />
|
<SettingsToolsPanel />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
<Tab.Pane eventKey="scraping" unmountOnExit>
|
<Tab.Pane eventKey="metadata-providers" unmountOnExit>
|
||||||
<SettingsScrapingPanel />
|
<SettingsScrapingPanel />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
|
<Tab.Pane eventKey="system">
|
||||||
|
<SettingsConfigurationPanel />
|
||||||
|
</Tab.Pane>
|
||||||
<Tab.Pane eventKey="plugins" unmountOnExit>
|
<Tab.Pane eventKey="plugins" unmountOnExit>
|
||||||
<SettingsPluginsPanel />
|
<SettingsPluginsPanel />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
@@ -116,9 +142,9 @@ export const Settings: React.FC = () => {
|
|||||||
<SettingsAboutPanel />
|
<SettingsAboutPanel />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
</Tab.Content>
|
</Tab.Content>
|
||||||
|
</SettingsContext>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Tab.Container>
|
</Tab.Container>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button, Table } from "react-bootstrap";
|
import { Button } from "react-bootstrap";
|
||||||
import { useIntl } from "react-intl";
|
import { useIntl } from "react-intl";
|
||||||
import { LoadingIndicator } from "src/components/Shared";
|
|
||||||
import { useLatestVersion } from "src/core/StashService";
|
import { useLatestVersion } from "src/core/StashService";
|
||||||
|
import { ConstantSetting, Setting, SettingGroup } from "./Inputs";
|
||||||
|
import { SettingSection } from "./SettingSection";
|
||||||
|
|
||||||
export const SettingsAboutPanel: React.FC = () => {
|
export const SettingsAboutPanel: React.FC = () => {
|
||||||
const gitHash = import.meta.env.VITE_APP_GITHASH;
|
const gitHash = import.meta.env.VITE_APP_GITHASH;
|
||||||
@@ -19,95 +20,73 @@ export const SettingsAboutPanel: React.FC = () => {
|
|||||||
networkStatus,
|
networkStatus,
|
||||||
} = useLatestVersion();
|
} = useLatestVersion();
|
||||||
|
|
||||||
function maybeRenderTag() {
|
const hasNew = dataLatest && gitHash !== dataLatest.latestversion.shorthash;
|
||||||
if (!stashVersion) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
<td>{intl.formatMessage({ id: "config.about.version" })}:</td>
|
|
||||||
<td>{stashVersion}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeRenderLatestVersion() {
|
|
||||||
if (
|
|
||||||
!dataLatest?.latestversion.shorthash ||
|
|
||||||
!dataLatest?.latestversion.url
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gitHash !== dataLatest.latestversion.shorthash) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<strong>
|
<SettingSection headingID="config.about.version">
|
||||||
{dataLatest.latestversion.shorthash}{" "}
|
<SettingGroup
|
||||||
{intl.formatMessage({ id: "config.about.new_version_notice" })}{" "}
|
settingProps={{
|
||||||
</strong>
|
heading: stashVersion,
|
||||||
<a href={dataLatest.latestversion.url}>
|
}}
|
||||||
{intl.formatMessage({ id: "actions.download" })}
|
>
|
||||||
</a>
|
<ConstantSetting
|
||||||
</>
|
headingID="config.about.build_hash"
|
||||||
);
|
value={gitHash}
|
||||||
}
|
/>
|
||||||
|
<ConstantSetting
|
||||||
|
headingID="config.about.build_time"
|
||||||
|
value={buildTime}
|
||||||
|
/>
|
||||||
|
</SettingGroup>
|
||||||
|
|
||||||
return <>{dataLatest.latestversion.shorthash}</>;
|
<SettingGroup
|
||||||
}
|
settingProps={{
|
||||||
|
headingID: "config.about.latest_version",
|
||||||
function renderLatestVersion() {
|
}}
|
||||||
return (
|
>
|
||||||
<Table>
|
{errorLatest ? (
|
||||||
<tbody>
|
<Setting heading={errorLatest.message} />
|
||||||
<tr>
|
) : !dataLatest || loadingLatest || networkStatus === 4 ? (
|
||||||
<td>
|
<Setting headingID="loading.generic" />
|
||||||
|
) : (
|
||||||
|
<div className="setting">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
{intl.formatMessage({
|
{intl.formatMessage({
|
||||||
id: "config.about.latest_version_build_hash",
|
id: "config.about.latest_version_build_hash",
|
||||||
})}{" "}
|
})}
|
||||||
</td>
|
</h3>
|
||||||
<td>{maybeRenderLatestVersion()} </td>
|
<div className="value">
|
||||||
</tr>
|
{dataLatest.latestversion.shorthash}{" "}
|
||||||
<tr>
|
{hasNew
|
||||||
<td>
|
? intl.formatMessage({
|
||||||
|
id: "config.about.new_version_notice",
|
||||||
|
})
|
||||||
|
: undefined}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href={dataLatest.latestversion.url}>
|
||||||
|
<Button>
|
||||||
|
{intl.formatMessage({ id: "actions.download" })}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
<Button onClick={() => refetch()}>
|
<Button onClick={() => refetch()}>
|
||||||
{intl.formatMessage({
|
{intl.formatMessage({
|
||||||
id: "config.about.check_for_new_version",
|
id: "config.about.check_for_new_version",
|
||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
)}
|
||||||
</Table>
|
</SettingGroup>
|
||||||
);
|
</SettingSection>
|
||||||
}
|
|
||||||
|
|
||||||
function renderVersion() {
|
<SettingSection headingID="config.categories.about">
|
||||||
return (
|
<div className="setting">
|
||||||
<>
|
<div>
|
||||||
<Table>
|
<p>
|
||||||
<tbody>
|
|
||||||
{maybeRenderTag()}
|
|
||||||
<tr>
|
|
||||||
<td>{intl.formatMessage({ id: "config.about.build_hash" })}</td>
|
|
||||||
<td>{gitHash}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>{intl.formatMessage({ id: "config.about.build_time" })}</td>
|
|
||||||
<td>{buildTime}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h4>{intl.formatMessage({ id: "config.categories.about" })}</h4>
|
|
||||||
<Table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "config.about.stash_home" },
|
{ id: "config.about.stash_home" },
|
||||||
{
|
{
|
||||||
@@ -122,10 +101,8 @@ export const SettingsAboutPanel: React.FC = () => {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</td>
|
</p>
|
||||||
</tr>
|
<p>
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "config.about.stash_wiki" },
|
{ id: "config.about.stash_wiki" },
|
||||||
{
|
{
|
||||||
@@ -140,10 +117,8 @@ export const SettingsAboutPanel: React.FC = () => {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</td>
|
</p>
|
||||||
</tr>
|
<p>
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "config.about.stash_discord" },
|
{ id: "config.about.stash_discord" },
|
||||||
{
|
{
|
||||||
@@ -158,10 +133,8 @@ export const SettingsAboutPanel: React.FC = () => {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</td>
|
</p>
|
||||||
</tr>
|
<p>
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: "config.about.stash_open_collective" },
|
{ id: "config.about.stash_open_collective" },
|
||||||
{
|
{
|
||||||
@@ -176,17 +149,11 @@ export const SettingsAboutPanel: React.FC = () => {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</td>
|
</p>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
<div />
|
||||||
</Table>
|
</div>
|
||||||
{errorLatest && <span>{errorLatest.message}</span>}
|
</SettingSection>
|
||||||
{renderVersion()}
|
|
||||||
{!dataLatest || loadingLatest || networkStatus === 4 ? (
|
|
||||||
<LoadingIndicator inline />
|
|
||||||
) : (
|
|
||||||
renderLatestVersion()
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Form } from "react-bootstrap";
|
import { BooleanSetting } from "../Inputs";
|
||||||
|
|
||||||
interface IItem {
|
interface IItem {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
headingID: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ICheckboxGroupProps {
|
interface ICheckboxGroupProps {
|
||||||
@@ -25,22 +25,20 @@ export const CheckboxGroup: React.FC<ICheckboxGroupProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{items.map(({ id, label }) => (
|
{items.map(({ id, headingID }) => (
|
||||||
<Form.Check
|
<BooleanSetting
|
||||||
key={id}
|
key={id}
|
||||||
type="checkbox"
|
|
||||||
id={generateId(id)}
|
id={generateId(id)}
|
||||||
label={label}
|
headingID={headingID}
|
||||||
checked={checkedIds.includes(id)}
|
checked={checkedIds.includes(id)}
|
||||||
onChange={(event) => {
|
onChange={(v) => {
|
||||||
const target = event.currentTarget;
|
if (v) {
|
||||||
if (target.checked) {
|
|
||||||
onChange?.(
|
onChange?.(
|
||||||
items
|
items
|
||||||
.map((item) => item.id)
|
.map((item) => item.id)
|
||||||
.filter(
|
.filter(
|
||||||
(itemId) =>
|
(itemId) =>
|
||||||
generateId(itemId) === target.id ||
|
generateId(itemId) === generateId(id) ||
|
||||||
checkedIds.includes(itemId)
|
checkedIds.includes(itemId)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -50,7 +48,7 @@ export const CheckboxGroup: React.FC<ICheckboxGroupProps> = ({
|
|||||||
.map((item) => item.id)
|
.map((item) => item.id)
|
||||||
.filter(
|
.filter(
|
||||||
(itemId) =>
|
(itemId) =>
|
||||||
generateId(itemId) !== target.id &&
|
generateId(itemId) !== generateId(id) &&
|
||||||
checkedIds.includes(itemId)
|
checkedIds.includes(itemId)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,174 +1,48 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
import { Button, Form } from "react-bootstrap";
|
import { Form } from "react-bootstrap";
|
||||||
import { useIntl } from "react-intl";
|
import { useIntl } from "react-intl";
|
||||||
import { DurationInput, LoadingIndicator } from "src/components/Shared";
|
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 { 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 = [
|
const allMenuItems = [
|
||||||
{ id: "scenes", label: "Scenes" },
|
{ id: "scenes", headingID: "scenes" },
|
||||||
{ id: "images", label: "Images" },
|
{ id: "images", headingID: "images" },
|
||||||
{ id: "movies", label: "Movies" },
|
{ id: "movies", headingID: "movies" },
|
||||||
{ id: "markers", label: "Markers" },
|
{ id: "markers", headingID: "markers" },
|
||||||
{ id: "galleries", label: "Galleries" },
|
{ id: "galleries", headingID: "galleries" },
|
||||||
{ id: "performers", label: "Performers" },
|
{ id: "performers", headingID: "performers" },
|
||||||
{ id: "studios", label: "Studios" },
|
{ id: "studios", headingID: "studios" },
|
||||||
{ id: "tags", label: "Tags" },
|
{ id: "tags", headingID: "tags" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SECONDS_TO_MS = 1000;
|
|
||||||
|
|
||||||
export const SettingsInterfacePanel: React.FC = () => {
|
export const SettingsInterfacePanel: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const Toast = useToast();
|
|
||||||
const { data: config, error, loading } = useConfiguration();
|
const { interface: iface, saveInterface, loading, error } = React.useContext(
|
||||||
const [menuItemIds, setMenuItemIds] = useState<string[]>(
|
SettingStateContext
|
||||||
allMenuItems.map((item) => item.id)
|
|
||||||
);
|
);
|
||||||
const [noBrowser, setNoBrowserFlag] = useState<boolean>(false);
|
|
||||||
const [soundOnPreview, setSoundOnPreview] = useState<boolean>(true);
|
|
||||||
const [wallShowTitle, setWallShowTitle] = useState<boolean>(true);
|
|
||||||
const [wallPlayback, setWallPlayback] = useState<string>("video");
|
|
||||||
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
|
|
||||||
const [autostartVideo, setAutostartVideo] = useState<boolean>(false);
|
|
||||||
const [
|
|
||||||
autostartVideoOnPlaySelected,
|
|
||||||
setAutostartVideoOnPlaySelected,
|
|
||||||
] = useState(true);
|
|
||||||
const [continuePlaylistDefault, setContinuePlaylistDefault] = useState(false);
|
|
||||||
const [slideshowDelay, setSlideshowDelay] = useState<number>(0);
|
|
||||||
const [showStudioAsText, setShowStudioAsText] = useState<boolean>(false);
|
|
||||||
const [css, setCSS] = useState<string>();
|
|
||||||
const [cssEnabled, setCSSEnabled] = useState<boolean>(false);
|
|
||||||
const [language, setLanguage] = useState<string>("en");
|
|
||||||
const [handyKey, setHandyKey] = useState<string>();
|
|
||||||
const [funscriptOffset, setFunscriptOffset] = useState<number>(0);
|
|
||||||
const [deleteFileDefault, setDeleteFileDefault] = useState<boolean>(false);
|
|
||||||
const [deleteGeneratedDefault, setDeleteGeneratedDefault] = useState<boolean>(
|
|
||||||
true
|
|
||||||
);
|
|
||||||
const [
|
|
||||||
disableDropdownCreate,
|
|
||||||
setDisableDropdownCreate,
|
|
||||||
] = useState<GQL.ConfigDisableDropdownCreateInput>({});
|
|
||||||
|
|
||||||
const [updateInterfaceConfig] = useConfigureInterface({
|
|
||||||
menuItems: menuItemIds,
|
|
||||||
soundOnPreview,
|
|
||||||
wallShowTitle,
|
|
||||||
wallPlayback,
|
|
||||||
maximumLoopDuration,
|
|
||||||
noBrowser,
|
|
||||||
autostartVideo,
|
|
||||||
autostartVideoOnPlaySelected,
|
|
||||||
continuePlaylistDefault,
|
|
||||||
showStudioAsText,
|
|
||||||
css,
|
|
||||||
cssEnabled,
|
|
||||||
language,
|
|
||||||
slideshowDelay,
|
|
||||||
handyKey,
|
|
||||||
funscriptOffset,
|
|
||||||
disableDropdownCreate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [updateDefaultsConfig] = useConfigureDefaults();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (config) {
|
|
||||||
const { interface: iCfg, defaults } = config.configuration;
|
|
||||||
setMenuItemIds(iCfg.menuItems ?? allMenuItems.map((item) => item.id));
|
|
||||||
setSoundOnPreview(iCfg.soundOnPreview ?? true);
|
|
||||||
setWallShowTitle(iCfg.wallShowTitle ?? true);
|
|
||||||
setWallPlayback(iCfg.wallPlayback ?? "video");
|
|
||||||
setMaximumLoopDuration(iCfg.maximumLoopDuration ?? 0);
|
|
||||||
setNoBrowserFlag(iCfg?.noBrowser ?? false);
|
|
||||||
setAutostartVideo(iCfg.autostartVideo ?? false);
|
|
||||||
setAutostartVideoOnPlaySelected(
|
|
||||||
iCfg.autostartVideoOnPlaySelected ?? true
|
|
||||||
);
|
|
||||||
setContinuePlaylistDefault(iCfg.continuePlaylistDefault ?? false);
|
|
||||||
setShowStudioAsText(iCfg.showStudioAsText ?? false);
|
|
||||||
setCSS(iCfg.css ?? "");
|
|
||||||
setCSSEnabled(iCfg.cssEnabled ?? false);
|
|
||||||
setLanguage(iCfg.language ?? "en-US");
|
|
||||||
setSlideshowDelay(iCfg.slideshowDelay ?? 5000);
|
|
||||||
setHandyKey(iCfg.handyKey ?? "");
|
|
||||||
setFunscriptOffset(iCfg.funscriptOffset ?? 0);
|
|
||||||
setDisableDropdownCreate({
|
|
||||||
performer: iCfg.disabledDropdownCreate.performer,
|
|
||||||
studio: iCfg.disabledDropdownCreate.studio,
|
|
||||||
tag: iCfg.disabledDropdownCreate.tag,
|
|
||||||
});
|
|
||||||
|
|
||||||
setDeleteFileDefault(defaults.deleteFile ?? false);
|
|
||||||
setDeleteGeneratedDefault(defaults.deleteGenerated ?? true);
|
|
||||||
}
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
async function onSave() {
|
|
||||||
const prevCSS = config?.configuration.interface.css;
|
|
||||||
const prevCSSenabled = config?.configuration.interface.cssEnabled;
|
|
||||||
try {
|
|
||||||
if (config?.configuration.defaults) {
|
|
||||||
await updateDefaultsConfig({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
...withoutTypename(config?.configuration.defaults),
|
|
||||||
deleteFile: deleteFileDefault,
|
|
||||||
deleteGenerated: deleteGeneratedDefault,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const result = await updateInterfaceConfig();
|
|
||||||
|
|
||||||
// Force refetch of custom css if it was changed
|
|
||||||
if (
|
|
||||||
prevCSS !== result.data?.configureInterface.css ||
|
|
||||||
prevCSSenabled !== result.data?.configureInterface.cssEnabled
|
|
||||||
) {
|
|
||||||
await fetch("/css", { cache: "reload" });
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
Toast.success({
|
|
||||||
content: intl.formatMessage(
|
|
||||||
{ id: "toast.updated_entity" },
|
|
||||||
{
|
|
||||||
entity: intl
|
|
||||||
.formatMessage({ id: "configuration" })
|
|
||||||
.toLocaleLowerCase(),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
Toast.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) return <h1>{error.message}</h1>;
|
if (error) return <h1>{error.message}</h1>;
|
||||||
if (loading) return <LoadingIndicator />;
|
if (loading) return <LoadingIndicator />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4>{intl.formatMessage({ id: "config.ui.title" })}</h4>
|
<SettingSection headingID="config.ui.basic_settings">
|
||||||
<Form.Group controlId="language">
|
<SelectSetting
|
||||||
<h5>{intl.formatMessage({ id: "config.ui.language.heading" })}</h5>
|
id="language"
|
||||||
<Form.Control
|
headingID="config.ui.language.heading"
|
||||||
as="select"
|
value={iface.language ?? undefined}
|
||||||
className="col-4 input-control"
|
onChange={(v) => saveInterface({ language: v })}
|
||||||
value={language}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
|
||||||
setLanguage(e.currentTarget.value)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<option value="en-US">English (United States)</option>
|
<option value="en-US">English (United States)</option>
|
||||||
<option value="en-GB">English (United Kingdom)</option>
|
<option value="en-GB">English (United Kingdom)</option>
|
||||||
@@ -181,76 +55,61 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||||||
<option value="sv-SE">Swedish (Sweden)</option>
|
<option value="sv-SE">Swedish (Sweden)</option>
|
||||||
<option value="zh-TW">繁體中文 (台灣)</option>
|
<option value="zh-TW">繁體中文 (台灣)</option>
|
||||||
<option value="zh-CN">简体中文 (中国)</option>
|
<option value="zh-CN">简体中文 (中国)</option>
|
||||||
</Form.Control>
|
</SelectSetting>
|
||||||
</Form.Group>
|
|
||||||
<Form.Group>
|
<div className="setting-group">
|
||||||
<h5>{intl.formatMessage({ id: "config.ui.menu_items.heading" })}</h5>
|
<div className="setting">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "config.ui.menu_items.heading",
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
<div className="sub-heading">
|
||||||
|
{intl.formatMessage({ id: "config.ui.menu_items.description" })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
<CheckboxGroup
|
<CheckboxGroup
|
||||||
groupId="menu-items"
|
groupId="menu-items"
|
||||||
items={allMenuItems}
|
items={allMenuItems}
|
||||||
checkedIds={menuItemIds}
|
checkedIds={iface.menuItems ?? undefined}
|
||||||
onChange={setMenuItemIds}
|
onChange={(v) => saveInterface({ menuItems: v })}
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
</div>
|
||||||
{intl.formatMessage({ id: "config.ui.menu_items.description" })}
|
</SettingSection>
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<hr />
|
<SettingSection headingID="config.ui.desktop_integration.desktop_integration">
|
||||||
|
<BooleanSetting
|
||||||
<h4>
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "config.ui.desktop_integration.desktop_integration",
|
|
||||||
})}
|
|
||||||
</h4>
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Check
|
|
||||||
id="skip-browser"
|
id="skip-browser"
|
||||||
checked={noBrowser}
|
headingID="config.ui.desktop_integration.skip_opening_browser"
|
||||||
label={intl.formatMessage({
|
subHeadingID="config.ui.desktop_integration.skip_opening_browser_on_startup"
|
||||||
id: "config.ui.desktop_integration.skip_opening_browser",
|
checked={iface.noBrowser ?? undefined}
|
||||||
})}
|
onChange={(v) => saveInterface({ noBrowser: v })}
|
||||||
onChange={() => setNoBrowserFlag(!noBrowser)}
|
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
</SettingSection>
|
||||||
{intl.formatMessage({
|
|
||||||
id: "config.ui.desktop_integration.skip_opening_browser_on_startup",
|
|
||||||
})}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<Form.Group>
|
<SettingSection headingID="config.ui.scene_wall.heading">
|
||||||
<h5>{intl.formatMessage({ id: "config.ui.scene_wall.heading" })}</h5>
|
<BooleanSetting
|
||||||
<Form.Check
|
|
||||||
id="wall-show-title"
|
id="wall-show-title"
|
||||||
checked={wallShowTitle}
|
headingID="config.ui.scene_wall.options.display_title"
|
||||||
label={intl.formatMessage({
|
checked={iface.wallShowTitle ?? undefined}
|
||||||
id: "config.ui.scene_wall.options.display_title",
|
onChange={(v) => saveInterface({ wallShowTitle: v })}
|
||||||
})}
|
|
||||||
onChange={() => setWallShowTitle(!wallShowTitle)}
|
|
||||||
/>
|
/>
|
||||||
<Form.Check
|
<BooleanSetting
|
||||||
id="wall-sound-enabled"
|
id="wall-sound-enabled"
|
||||||
checked={soundOnPreview}
|
headingID="config.ui.scene_wall.options.toggle_sound"
|
||||||
label={intl.formatMessage({
|
checked={iface.soundOnPreview ?? undefined}
|
||||||
id: "config.ui.scene_wall.options.toggle_sound",
|
onChange={(v) => saveInterface({ soundOnPreview: v })}
|
||||||
})}
|
|
||||||
onChange={() => setSoundOnPreview(!soundOnPreview)}
|
|
||||||
/>
|
/>
|
||||||
<Form.Label htmlFor="wall-preview">
|
|
||||||
<h6>
|
<SelectSetting
|
||||||
{intl.formatMessage({ id: "config.ui.preview_type.heading" })}
|
id="wall-preview"
|
||||||
</h6>
|
headingID="config.ui.preview_type.heading"
|
||||||
</Form.Label>
|
subHeadingID="config.ui.preview_type.description"
|
||||||
<Form.Control
|
value={iface.wallPlayback ?? undefined}
|
||||||
as="select"
|
onChange={(v) => saveInterface({ wallPlayback: v })}
|
||||||
name="wall-preview"
|
|
||||||
className="col-4 input-control"
|
|
||||||
value={wallPlayback}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
|
||||||
setWallPlayback(e.currentTarget.value)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<option value="video">
|
<option value="video">
|
||||||
{intl.formatMessage({ id: "config.ui.preview_type.options.video" })}
|
{intl.formatMessage({ id: "config.ui.preview_type.options.video" })}
|
||||||
@@ -265,269 +124,172 @@ export const SettingsInterfacePanel: React.FC = () => {
|
|||||||
id: "config.ui.preview_type.options.static",
|
id: "config.ui.preview_type.options.static",
|
||||||
})}
|
})}
|
||||||
</option>
|
</option>
|
||||||
</Form.Control>
|
</SelectSetting>
|
||||||
<Form.Text className="text-muted">
|
</SettingSection>
|
||||||
{intl.formatMessage({ id: "config.ui.preview_type.description" })}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group>
|
<SettingSection headingID="config.ui.scene_list.heading">
|
||||||
<h5>{intl.formatMessage({ id: "config.ui.scene_list.heading" })}</h5>
|
<BooleanSetting
|
||||||
<Form.Check
|
|
||||||
id="show-text-studios"
|
id="show-text-studios"
|
||||||
checked={showStudioAsText}
|
headingID="config.ui.scene_list.options.show_studio_as_text"
|
||||||
label={intl.formatMessage({
|
checked={iface.showStudioAsText ?? undefined}
|
||||||
id: "config.ui.scene_list.options.show_studio_as_text",
|
onChange={(v) => saveInterface({ showStudioAsText: v })}
|
||||||
})}
|
|
||||||
onChange={() => {
|
|
||||||
setShowStudioAsText(!showStudioAsText);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</SettingSection>
|
||||||
|
|
||||||
<Form.Group>
|
<SettingSection headingID="config.ui.scene_player.heading">
|
||||||
<h5>{intl.formatMessage({ id: "config.ui.scene_player.heading" })}</h5>
|
<BooleanSetting
|
||||||
<Form.Group>
|
|
||||||
<Form.Check
|
|
||||||
id="auto-start-video"
|
id="auto-start-video"
|
||||||
checked={autostartVideo}
|
headingID="config.ui.scene_player.options.auto_start_video"
|
||||||
label={intl.formatMessage({
|
checked={iface.autostartVideo ?? undefined}
|
||||||
id: "config.ui.scene_player.options.auto_start_video",
|
onChange={(v) => saveInterface({ autostartVideo: v })}
|
||||||
})}
|
|
||||||
onChange={() => {
|
|
||||||
setAutostartVideo(!autostartVideo);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
<BooleanSetting
|
||||||
<Form.Group id="auto-start-video-on-play-selected">
|
id="auto-start-video-on-play-selected"
|
||||||
<Form.Check
|
headingID="config.ui.scene_player.options.auto_start_video_on_play_selected.heading"
|
||||||
checked={autostartVideoOnPlaySelected}
|
subHeadingID="config.ui.scene_player.options.auto_start_video_on_play_selected.description"
|
||||||
label={intl.formatMessage({
|
checked={iface.autostartVideoOnPlaySelected ?? undefined}
|
||||||
id:
|
onChange={(v) => saveInterface({ autostartVideoOnPlaySelected: v })}
|
||||||
"config.ui.scene_player.options.auto_start_video_on_play_selected.heading",
|
|
||||||
})}
|
|
||||||
onChange={() => {
|
|
||||||
setAutostartVideoOnPlaySelected(!autostartVideoOnPlaySelected);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage({
|
|
||||||
id:
|
|
||||||
"config.ui.scene_player.options.auto_start_video_on_play_selected.description",
|
|
||||||
})}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group id="continue-playlist-default">
|
<BooleanSetting
|
||||||
<Form.Check
|
id="continue-playlist-default"
|
||||||
checked={continuePlaylistDefault}
|
headingID="config.ui.scene_player.options.continue_playlist_default.heading"
|
||||||
label={intl.formatMessage({
|
subHeadingID="config.ui.scene_player.options.continue_playlist_default.description"
|
||||||
id:
|
checked={iface.continuePlaylistDefault ?? undefined}
|
||||||
"config.ui.scene_player.options.continue_playlist_default.heading",
|
onChange={(v) => saveInterface({ continuePlaylistDefault: v })}
|
||||||
})}
|
|
||||||
onChange={() => {
|
|
||||||
setContinuePlaylistDefault(!continuePlaylistDefault);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage({
|
|
||||||
id:
|
|
||||||
"config.ui.scene_player.options.continue_playlist_default.description",
|
|
||||||
})}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group id="max-loop-duration">
|
<ModalSetting<number>
|
||||||
<h6>
|
id="max-loop-duration"
|
||||||
{intl.formatMessage({ id: "config.ui.max_loop_duration.heading" })}
|
headingID="config.ui.max_loop_duration.heading"
|
||||||
</h6>
|
subHeadingID="config.ui.max_loop_duration.description"
|
||||||
|
value={iface.maximumLoopDuration ?? undefined}
|
||||||
|
onChange={(v) => saveInterface({ maximumLoopDuration: v })}
|
||||||
|
renderField={(value, setValue) => (
|
||||||
<DurationInput
|
<DurationInput
|
||||||
className="row col col-4"
|
numericValue={value}
|
||||||
numericValue={maximumLoopDuration}
|
onValueChange={(duration) => setValue(duration ?? 0)}
|
||||||
onValueChange={(duration) => setMaximumLoopDuration(duration ?? 0)}
|
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
)}
|
||||||
{intl.formatMessage({
|
renderValue={(v) => {
|
||||||
id: "config.ui.max_loop_duration.description",
|
return <span>{DurationUtils.secondsToString(v ?? 0)}</span>;
|
||||||
})}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group id="slideshow-delay">
|
|
||||||
<h5>
|
|
||||||
{intl.formatMessage({ id: "config.ui.slideshow_delay.heading" })}
|
|
||||||
</h5>
|
|
||||||
<Form.Control
|
|
||||||
className="col col-sm-6 text-input"
|
|
||||||
type="number"
|
|
||||||
value={slideshowDelay / SECONDS_TO_MS}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setSlideshowDelay(
|
|
||||||
Number.parseInt(e.currentTarget.value, 10) * SECONDS_TO_MS
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
</SettingSection>
|
||||||
{intl.formatMessage({ id: "config.ui.slideshow_delay.description" })}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group>
|
<SettingSection headingID="config.ui.images.heading">
|
||||||
<h5>{intl.formatMessage({ id: "config.ui.editing.heading" })}</h5>
|
<NumberSetting
|
||||||
|
headingID="config.ui.slideshow_delay.heading"
|
||||||
|
subHeadingID="config.ui.slideshow_delay.description"
|
||||||
|
value={iface.slideshowDelay ?? undefined}
|
||||||
|
onChange={(v) => saveInterface({ slideshowDelay: v })}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
<Form.Group>
|
<SettingSection headingID="config.ui.editing.heading">
|
||||||
<h6>
|
<div className="setting-group">
|
||||||
|
<div className="setting">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
{intl.formatMessage({
|
{intl.formatMessage({
|
||||||
id: "config.ui.editing.disable_dropdown_create.heading",
|
id: "config.ui.editing.disable_dropdown_create.heading",
|
||||||
})}
|
})}
|
||||||
</h6>
|
</h3>
|
||||||
<Form.Check
|
<div className="sub-heading">
|
||||||
id="disableDropdownCreate_performer"
|
|
||||||
checked={disableDropdownCreate.performer ?? false}
|
|
||||||
label={intl.formatMessage({
|
|
||||||
id: "performer",
|
|
||||||
})}
|
|
||||||
onChange={() => {
|
|
||||||
setDisableDropdownCreate({
|
|
||||||
...disableDropdownCreate,
|
|
||||||
performer: !disableDropdownCreate.performer ?? true,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form.Check
|
|
||||||
id="disableDropdownCreate_studio"
|
|
||||||
checked={disableDropdownCreate.studio ?? false}
|
|
||||||
label={intl.formatMessage({
|
|
||||||
id: "studio",
|
|
||||||
})}
|
|
||||||
onChange={() => {
|
|
||||||
setDisableDropdownCreate({
|
|
||||||
...disableDropdownCreate,
|
|
||||||
studio: !disableDropdownCreate.studio ?? true,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form.Check
|
|
||||||
id="disableDropdownCreate_tag"
|
|
||||||
checked={disableDropdownCreate.tag ?? false}
|
|
||||||
label={intl.formatMessage({
|
|
||||||
id: "tag",
|
|
||||||
})}
|
|
||||||
onChange={() => {
|
|
||||||
setDisableDropdownCreate({
|
|
||||||
...disableDropdownCreate,
|
|
||||||
tag: !disableDropdownCreate.tag ?? true,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage({
|
{intl.formatMessage({
|
||||||
id: "config.ui.editing.disable_dropdown_create.description",
|
id: "config.ui.editing.disable_dropdown_create.description",
|
||||||
})}
|
})}
|
||||||
</Form.Text>
|
</div>
|
||||||
</Form.Group>
|
</div>
|
||||||
</Form.Group>
|
<div />
|
||||||
|
</div>
|
||||||
|
<BooleanSetting
|
||||||
|
id="disableDropdownCreate_performer"
|
||||||
|
headingID="performer"
|
||||||
|
checked={iface.disableDropdownCreate?.performer ?? undefined}
|
||||||
|
onChange={(v) =>
|
||||||
|
saveInterface({
|
||||||
|
disableDropdownCreate: {
|
||||||
|
...iface.disableDropdownCreate,
|
||||||
|
performer: v,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<BooleanSetting
|
||||||
|
id="disableDropdownCreate_studio"
|
||||||
|
headingID="studio"
|
||||||
|
checked={iface.disableDropdownCreate?.studio ?? undefined}
|
||||||
|
onChange={(v) =>
|
||||||
|
saveInterface({
|
||||||
|
disableDropdownCreate: {
|
||||||
|
...iface.disableDropdownCreate,
|
||||||
|
studio: v,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<BooleanSetting
|
||||||
|
id="disableDropdownCreate_tag"
|
||||||
|
headingID="tag"
|
||||||
|
checked={iface.disableDropdownCreate?.tag ?? undefined}
|
||||||
|
onChange={(v) =>
|
||||||
|
saveInterface({
|
||||||
|
disableDropdownCreate: {
|
||||||
|
...iface.disableDropdownCreate,
|
||||||
|
tag: v,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
<Form.Group>
|
<SettingSection headingID="config.ui.custom_css.heading">
|
||||||
<h5>{intl.formatMessage({ id: "config.ui.custom_css.heading" })}</h5>
|
<BooleanSetting
|
||||||
<Form.Check
|
id="custom-css-enabled"
|
||||||
id="custom-css"
|
headingID="config.ui.custom_css.option_label"
|
||||||
checked={cssEnabled}
|
checked={iface.cssEnabled ?? undefined}
|
||||||
label={intl.formatMessage({
|
onChange={(v) => saveInterface({ cssEnabled: v })}
|
||||||
id: "config.ui.custom_css.option_label",
|
|
||||||
})}
|
|
||||||
onChange={() => {
|
|
||||||
setCSSEnabled(!cssEnabled);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ModalSetting<string>
|
||||||
|
id="custom-css"
|
||||||
|
headingID="config.ui.custom_css.heading"
|
||||||
|
subHeadingID="config.ui.custom_css.description"
|
||||||
|
value={iface.css ?? undefined}
|
||||||
|
onChange={(v) => saveInterface({ css: v })}
|
||||||
|
renderField={(value, setValue) => (
|
||||||
<Form.Control
|
<Form.Control
|
||||||
as="textarea"
|
as="textarea"
|
||||||
value={css}
|
value={value}
|
||||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||||
setCSS(e.currentTarget.value)
|
setValue(e.currentTarget.value)
|
||||||
}
|
}
|
||||||
rows={16}
|
rows={16}
|
||||||
className="col col-sm-6 text-input code"
|
className="text-input code"
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
)}
|
||||||
{intl.formatMessage({ id: "config.ui.custom_css.description" })}
|
renderValue={() => {
|
||||||
</Form.Text>
|
return <></>;
|
||||||
</Form.Group>
|
}}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
<Form.Group>
|
<SettingSection headingID="config.ui.interactive_options">
|
||||||
<h5>
|
<StringSetting
|
||||||
{intl.formatMessage({ id: "config.ui.handy_connection_key.heading" })}
|
headingID="config.ui.handy_connection_key.heading"
|
||||||
</h5>
|
subHeadingID="config.ui.handy_connection_key.description"
|
||||||
<Form.Control
|
value={iface.handyKey ?? undefined}
|
||||||
className="col col-sm-6 text-input"
|
onChange={(v) => saveInterface({ handyKey: v })}
|
||||||
value={handyKey}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setHandyKey(e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
<NumberSetting
|
||||||
{intl.formatMessage({
|
headingID="config.ui.funscript_offset.heading"
|
||||||
id: "config.ui.handy_connection_key.description",
|
subHeadingID="config.ui.funscript_offset.description"
|
||||||
})}
|
value={iface.funscriptOffset ?? undefined}
|
||||||
</Form.Text>
|
onChange={(v) => saveInterface({ funscriptOffset: v })}
|
||||||
</Form.Group>
|
|
||||||
<Form.Group>
|
|
||||||
<h5>
|
|
||||||
{intl.formatMessage({ id: "config.ui.funscript_offset.heading" })}
|
|
||||||
</h5>
|
|
||||||
<Form.Control
|
|
||||||
className="col col-sm-6 text-input"
|
|
||||||
type="number"
|
|
||||||
value={funscriptOffset}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setFunscriptOffset(Number.parseInt(e.currentTarget.value, 10));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
</SettingSection>
|
||||||
{intl.formatMessage({ id: "config.ui.funscript_offset.description" })}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group>
|
|
||||||
<h5>
|
|
||||||
{intl.formatMessage({ id: "config.ui.delete_options.heading" })}
|
|
||||||
</h5>
|
|
||||||
<Form.Check
|
|
||||||
id="delete-file-default"
|
|
||||||
checked={deleteFileDefault}
|
|
||||||
label={intl.formatMessage({
|
|
||||||
id: "config.ui.delete_options.options.delete_file",
|
|
||||||
})}
|
|
||||||
onChange={() => {
|
|
||||||
setDeleteFileDefault(!deleteFileDefault);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Form.Check
|
|
||||||
id="delete-generated-default"
|
|
||||||
checked={deleteGeneratedDefault}
|
|
||||||
label={intl.formatMessage({
|
|
||||||
id:
|
|
||||||
"config.ui.delete_options.options.delete_generated_supporting_files",
|
|
||||||
})}
|
|
||||||
onChange={() => {
|
|
||||||
setDeleteGeneratedDefault(!deleteGeneratedDefault);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "config.ui.delete_options.description",
|
|
||||||
})}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
<Button variant="primary" onClick={() => onSave()}>
|
|
||||||
{intl.formatMessage({ id: "actions.save" })}
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
159
ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx
Normal file
159
ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Icon, LoadingIndicator } from "src/components/Shared";
|
||||||
|
import { StashSetting } from "./StashConfiguration";
|
||||||
|
import { SettingSection } from "./SettingSection";
|
||||||
|
import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs";
|
||||||
|
import { SettingStateContext } from "./context";
|
||||||
|
import { useIntl } from "react-intl";
|
||||||
|
|
||||||
|
export const SettingsLibraryPanel: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const {
|
||||||
|
general,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
saveGeneral,
|
||||||
|
defaults,
|
||||||
|
saveDefaults,
|
||||||
|
} = React.useContext(SettingStateContext);
|
||||||
|
|
||||||
|
function commaDelimitedToList(value: string | undefined) {
|
||||||
|
if (value) {
|
||||||
|
return value.split(",").map((s) => s.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listToCommaDelimited(value: string[] | undefined) {
|
||||||
|
if (value) {
|
||||||
|
return value.join(", ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <h1>{error.message}</h1>;
|
||||||
|
if (loading) return <LoadingIndicator />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StashSetting
|
||||||
|
value={general.stashes ?? []}
|
||||||
|
onChange={(v) => saveGeneral({ stashes: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingSection headingID="config.library.media_content_extensions">
|
||||||
|
<StringSetting
|
||||||
|
id="video-extensions"
|
||||||
|
headingID="config.general.video_ext_head"
|
||||||
|
subHeadingID="config.general.video_ext_desc"
|
||||||
|
value={listToCommaDelimited(general.videoExtensions ?? undefined)}
|
||||||
|
onChange={(v) =>
|
||||||
|
saveGeneral({ videoExtensions: commaDelimitedToList(v) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StringSetting
|
||||||
|
id="image-extensions"
|
||||||
|
headingID="config.general.image_ext_head"
|
||||||
|
subHeadingID="config.general.image_ext_desc"
|
||||||
|
value={listToCommaDelimited(general.imageExtensions ?? undefined)}
|
||||||
|
onChange={(v) =>
|
||||||
|
saveGeneral({ imageExtensions: commaDelimitedToList(v) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StringSetting
|
||||||
|
id="gallery-extensions"
|
||||||
|
headingID="config.general.gallery_ext_head"
|
||||||
|
subHeadingID="config.general.gallery_ext_desc"
|
||||||
|
value={listToCommaDelimited(general.galleryExtensions ?? undefined)}
|
||||||
|
onChange={(v) =>
|
||||||
|
saveGeneral({ galleryExtensions: commaDelimitedToList(v) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<SettingSection headingID="config.library.exclusions">
|
||||||
|
<StringListSetting
|
||||||
|
id="excluded-video-patterns"
|
||||||
|
headingID="config.general.excluded_video_patterns_head"
|
||||||
|
subHeading={
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "config.general.excluded_video_patterns_desc",
|
||||||
|
})}
|
||||||
|
<a
|
||||||
|
href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Icon icon="question-circle" />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={general.excludes ?? undefined}
|
||||||
|
onChange={(v) => saveGeneral({ excludes: v })}
|
||||||
|
defaultNewValue="sample\.mp4$"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StringListSetting
|
||||||
|
id="excluded-image-gallery-patterns"
|
||||||
|
headingID="config.general.excluded_image_gallery_patterns_head"
|
||||||
|
subHeading={
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "config.general.excluded_image_gallery_patterns_desc",
|
||||||
|
})}
|
||||||
|
<a
|
||||||
|
href="https://github.com/stashapp/stash/wiki/Exclude-file-configuration"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Icon icon="question-circle" />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={general.imageExcludes ?? undefined}
|
||||||
|
onChange={(v) => saveGeneral({ imageExcludes: v })}
|
||||||
|
defaultNewValue="sample\.jpg$"
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<SettingSection headingID="config.library.gallery_and_image_options">
|
||||||
|
<BooleanSetting
|
||||||
|
id="create-galleries-from-folders"
|
||||||
|
headingID="config.general.create_galleries_from_folders_label"
|
||||||
|
subHeadingID="config.general.create_galleries_from_folders_desc"
|
||||||
|
checked={general.createGalleriesFromFolders ?? false}
|
||||||
|
onChange={(v) => saveGeneral({ createGalleriesFromFolders: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BooleanSetting
|
||||||
|
id="write-image-thumbnails"
|
||||||
|
headingID="config.ui.images.options.write_image_thumbnails.heading"
|
||||||
|
subHeadingID="config.ui.images.options.write_image_thumbnails.description"
|
||||||
|
checked={general.writeImageThumbnails ?? false}
|
||||||
|
onChange={(v) => saveGeneral({ writeImageThumbnails: v })}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<SettingSection headingID="config.ui.delete_options.heading">
|
||||||
|
<BooleanSetting
|
||||||
|
id="delete-file-default"
|
||||||
|
headingID="config.ui.delete_options.options.delete_file"
|
||||||
|
checked={defaults.deleteFile ?? undefined}
|
||||||
|
onChange={(v) => {
|
||||||
|
saveDefaults({ deleteFile: v });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<BooleanSetting
|
||||||
|
id="delete-generated-default"
|
||||||
|
headingID="config.ui.delete_options.options.delete_generated_supporting_files"
|
||||||
|
subHeadingID="config.ui.delete_options.description"
|
||||||
|
checked={defaults.deleteGenerated ?? undefined}
|
||||||
|
onChange={(v) => {
|
||||||
|
saveDefaults({ deleteGenerated: v });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect, useReducer, useState } from "react";
|
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 * as GQL from "src/core/generated-graphql";
|
||||||
import { useLogs, useLoggingSubscribe } from "src/core/StashService";
|
import { useLogs, useLoggingSubscribe } from "src/core/StashService";
|
||||||
|
import { SelectSetting } from "./Inputs";
|
||||||
|
import { SettingSection } from "./SettingSection";
|
||||||
|
|
||||||
function convertTime(logEntry: GQL.LogEntryDataFragment) {
|
function convertTime(logEntry: GQL.LogEntryDataFragment) {
|
||||||
function pad(val: number) {
|
function pad(val: number) {
|
||||||
@@ -75,7 +75,6 @@ const logReducer = (existingEntries: LogEntry[], newEntries: LogEntry[]) => [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const SettingsLogsPanel: React.FC = () => {
|
export const SettingsLogsPanel: React.FC = () => {
|
||||||
const intl = useIntl();
|
|
||||||
const { data, error } = useLoggingSubscribe();
|
const { data, error } = useLoggingSubscribe();
|
||||||
const { data: existingData } = useLogs();
|
const { data: existingData } = useLogs();
|
||||||
const [currentData, dispatchLogUpdate] = useReducer(logReducer, []);
|
const [currentData, dispatchLogUpdate] = useReducer(logReducer, []);
|
||||||
@@ -108,24 +107,21 @@ export const SettingsLogsPanel: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4>{intl.formatMessage({ id: "config.categories.logs" })}</h4>
|
<SettingSection headingID="config.categories.logs">
|
||||||
<Form.Row id="log-level">
|
<SelectSetting
|
||||||
<Form.Label className="col-6 col-sm-2">
|
id="log-level"
|
||||||
{intl.formatMessage({ id: "config.logs.log_level" })}
|
headingID="config.logs.log_level"
|
||||||
</Form.Label>
|
value={logLevel}
|
||||||
<Form.Control
|
onChange={(v) => setLogLevel(v)}
|
||||||
className="col-6 col-sm-2 input-control"
|
|
||||||
as="select"
|
|
||||||
defaultValue={logLevel}
|
|
||||||
onChange={(event) => setLogLevel(event.currentTarget.value)}
|
|
||||||
>
|
>
|
||||||
{logLevels.map((level) => (
|
{logLevels.map((level) => (
|
||||||
<option key={level} value={level}>
|
<option key={level} value={level}>
|
||||||
{level}
|
{level}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Form.Control>
|
</SelectSetting>
|
||||||
</Form.Row>
|
</SettingSection>
|
||||||
|
|
||||||
<div className="logs">
|
<div className="logs">
|
||||||
{maybeRenderError}
|
{maybeRenderError}
|
||||||
{filteredLogEntries.map((logEntry) => (
|
{filteredLogEntries.map((logEntry) => (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Button } from "react-bootstrap";
|
import { Button } from "react-bootstrap";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
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 { useToast } from "src/hooks";
|
||||||
import { TextUtils } from "src/utils";
|
import { TextUtils } from "src/utils";
|
||||||
import { CollapseButton, Icon, LoadingIndicator } from "src/components/Shared";
|
import { CollapseButton, Icon, LoadingIndicator } from "src/components/Shared";
|
||||||
|
import { SettingSection } from "./SettingSection";
|
||||||
|
import { Setting, SettingGroup } from "./Inputs";
|
||||||
|
|
||||||
export const SettingsPluginsPanel: React.FC = () => {
|
export const SettingsPluginsPanel: React.FC = () => {
|
||||||
const Toast = useToast();
|
const Toast = useToast();
|
||||||
@@ -17,6 +19,7 @@ export const SettingsPluginsPanel: React.FC = () => {
|
|||||||
await mutateReloadPlugins().catch((e) => Toast.error(e));
|
await mutateReloadPlugins().catch((e) => Toast.error(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pluginElements = useMemo(() => {
|
||||||
function renderLink(url?: string) {
|
function renderLink(url?: string) {
|
||||||
if (url) {
|
if (url) {
|
||||||
return (
|
return (
|
||||||
@@ -36,17 +39,18 @@ export const SettingsPluginsPanel: React.FC = () => {
|
|||||||
|
|
||||||
function renderPlugins() {
|
function renderPlugins() {
|
||||||
const elements = (data?.plugins ?? []).map((plugin) => (
|
const elements = (data?.plugins ?? []).map((plugin) => (
|
||||||
<div key={plugin.id}>
|
<SettingGroup
|
||||||
<h4>
|
key={plugin.id}
|
||||||
{plugin.name} {plugin.version ? `(${plugin.version})` : undefined}{" "}
|
settingProps={{
|
||||||
{renderLink(plugin.url ?? undefined)}
|
heading: `${plugin.name} ${
|
||||||
</h4>
|
plugin.version ? `(${plugin.version})` : undefined
|
||||||
{plugin.description ? (
|
}`,
|
||||||
<small className="text-muted">{plugin.description}</small>
|
subHeading: plugin.description,
|
||||||
) : undefined}
|
}}
|
||||||
|
topLevel={renderLink(plugin.url ?? undefined)}
|
||||||
|
>
|
||||||
{renderPluginHooks(plugin.hooks ?? undefined)}
|
{renderPluginHooks(plugin.hooks ?? undefined)}
|
||||||
<hr />
|
</SettingGroup>
|
||||||
</div>
|
|
||||||
));
|
));
|
||||||
|
|
||||||
return <div>{elements}</div>;
|
return <div>{elements}</div>;
|
||||||
@@ -60,15 +64,18 @@ export const SettingsPluginsPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2">
|
<div className="setting">
|
||||||
|
<div>
|
||||||
<h5>
|
<h5>
|
||||||
<FormattedMessage id="config.plugins.hooks" />
|
<FormattedMessage id="config.plugins.hooks" />
|
||||||
</h5>
|
</h5>
|
||||||
{hooks.map((h) => (
|
{hooks.map((h) => (
|
||||||
<div key={`${h.name}`} className="mb-3">
|
<div key={`${h.name}`}>
|
||||||
<h6>{h.name}</h6>
|
<h6>{h.name}</h6>
|
||||||
<CollapseButton
|
<CollapseButton
|
||||||
text={intl.formatMessage({ id: "config.plugins.triggers_on" })}
|
text={intl.formatMessage({
|
||||||
|
id: "config.plugins.triggers_on",
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<ul>
|
<ul>
|
||||||
{h.hooks?.map((hh) => (
|
{h.hooks?.map((hh) => (
|
||||||
@@ -82,18 +89,20 @@ export const SettingsPluginsPanel: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return renderPlugins();
|
||||||
|
}, [data?.plugins, intl]);
|
||||||
|
|
||||||
if (loading) return <LoadingIndicator />;
|
if (loading) return <LoadingIndicator />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3>
|
<SettingSection headingID="config.categories.plugins">
|
||||||
<FormattedMessage id="config.categories.plugins" />
|
<Setting headingID="actions.reload_plugins">
|
||||||
</h3>
|
|
||||||
<hr />
|
|
||||||
{renderPlugins()}
|
|
||||||
<Button onClick={() => onReloadPlugins()}>
|
<Button onClick={() => onReloadPlugins()}>
|
||||||
<span className="fa-icon">
|
<span className="fa-icon">
|
||||||
<Icon icon="sync-alt" />
|
<Icon icon="sync-alt" />
|
||||||
@@ -102,6 +111,9 @@ export const SettingsPluginsPanel: React.FC = () => {
|
|||||||
<FormattedMessage id="actions.reload_plugins" />
|
<FormattedMessage id="actions.reload_plugins" />
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
</Setting>
|
||||||
|
{pluginElements}
|
||||||
|
</SettingSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { Button, Form } from "react-bootstrap";
|
import { Button } from "react-bootstrap";
|
||||||
import {
|
import {
|
||||||
mutateReloadScrapers,
|
mutateReloadScrapers,
|
||||||
useListMovieScrapers,
|
useListMovieScrapers,
|
||||||
useListPerformerScrapers,
|
useListPerformerScrapers,
|
||||||
useListSceneScrapers,
|
useListSceneScrapers,
|
||||||
useListGalleryScrapers,
|
useListGalleryScrapers,
|
||||||
useConfiguration,
|
|
||||||
useConfigureScraping,
|
|
||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
import { useToast } from "src/hooks";
|
import { useToast } from "src/hooks";
|
||||||
import { TextUtils } from "src/utils";
|
import { TextUtils } from "src/utils";
|
||||||
import { CollapseButton, Icon, LoadingIndicator } from "src/components/Shared";
|
import { CollapseButton, Icon, LoadingIndicator } from "src/components/Shared";
|
||||||
import { ScrapeType } from "src/core/generated-graphql";
|
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 {
|
interface IURLList {
|
||||||
urls: string[];
|
urls: string[];
|
||||||
@@ -90,58 +91,19 @@ export const SettingsScrapingPanel: React.FC = () => {
|
|||||||
loading: loadingMovies,
|
loading: loadingMovies,
|
||||||
} = useListMovieScrapers();
|
} = useListMovieScrapers();
|
||||||
|
|
||||||
const [scraperUserAgent, setScraperUserAgent] = useState<string | undefined>(
|
const {
|
||||||
undefined
|
general,
|
||||||
);
|
scraping,
|
||||||
const [scraperCDPPath, setScraperCDPPath] = useState<string | undefined>(
|
loading,
|
||||||
undefined
|
error,
|
||||||
);
|
saveGeneral,
|
||||||
const [scraperCertCheck, setScraperCertCheck] = useState<boolean>(true);
|
saveScraping,
|
||||||
const [excludeTagPatterns, setExcludeTagPatterns] = useState<string[]>([]);
|
} = React.useContext(SettingStateContext);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
async function onReloadScrapers() {
|
async function onReloadScrapers() {
|
||||||
await mutateReloadScrapers().catch((e) => Toast.error(e));
|
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[]) {
|
function renderPerformerScrapeTypes(types: ScrapeType[]) {
|
||||||
const typeStrings = types
|
const typeStrings = types
|
||||||
.filter((t) => t !== ScrapeType.Fragment)
|
.filter((t) => t !== ScrapeType.Fragment)
|
||||||
@@ -344,88 +306,59 @@ export const SettingsScrapingPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadingScenes || loadingGalleries || loadingPerformers || loadingMovies)
|
if (error) return <h1>{error.message}</h1>;
|
||||||
|
if (
|
||||||
|
loading ||
|
||||||
|
loadingScenes ||
|
||||||
|
loadingGalleries ||
|
||||||
|
loadingPerformers ||
|
||||||
|
loadingMovies
|
||||||
|
)
|
||||||
return <LoadingIndicator />;
|
return <LoadingIndicator />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Group>
|
<StashBoxSetting
|
||||||
<h4>{intl.formatMessage({ id: "config.general.scraping" })}</h4>
|
value={general.stashBoxes ?? []}
|
||||||
<Form.Group id="scraperUserAgent">
|
onChange={(v) => saveGeneral({ stashBoxes: v })}
|
||||||
<h6>
|
|
||||||
{intl.formatMessage({ id: "config.general.scraper_user_agent" })}
|
|
||||||
</h6>
|
|
||||||
<Form.Control
|
|
||||||
className="col col-sm-6 text-input"
|
|
||||||
defaultValue={scraperUserAgent}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setScraperUserAgent(e.currentTarget.value)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "config.general.scraper_user_agent_desc",
|
|
||||||
})}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group id="scraperCDPPath">
|
<SettingSection headingID="config.general.scraping">
|
||||||
<h6>
|
<StringSetting
|
||||||
{intl.formatMessage({ id: "config.general.chrome_cdp_path" })}
|
id="scraperUserAgent"
|
||||||
</h6>
|
headingID="config.general.scraper_user_agent"
|
||||||
<Form.Control
|
subHeadingID="config.general.scraper_user_agent_desc"
|
||||||
className="col col-sm-6 text-input"
|
value={scraping.scraperUserAgent ?? undefined}
|
||||||
defaultValue={scraperCDPPath}
|
onChange={(v) => saveScraping({ scraperUserAgent: v })}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setScraperCDPPath(e.currentTarget.value)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage({ id: "config.general.chrome_cdp_path_desc" })}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group>
|
<StringSetting
|
||||||
<Form.Check
|
id="scraperCDPPath"
|
||||||
id="scaper-cert-check"
|
headingID="config.general.chrome_cdp_path"
|
||||||
checked={scraperCertCheck}
|
subHeadingID="config.general.chrome_cdp_path_desc"
|
||||||
label={intl.formatMessage({
|
value={scraping.scraperCDPPath ?? undefined}
|
||||||
id: "config.general.check_for_insecure_certificates",
|
onChange={(v) => saveScraping({ scraperCDPPath: v })}
|
||||||
})}
|
|
||||||
onChange={() => setScraperCertCheck(!scraperCertCheck)}
|
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "config.general.check_for_insecure_certificates_desc",
|
|
||||||
})}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group>
|
<BooleanSetting
|
||||||
<h6>
|
id="scraper-cert-check"
|
||||||
{intl.formatMessage({
|
headingID="config.general.check_for_insecure_certificates"
|
||||||
id: "config.scraping.excluded_tag_patterns_head",
|
subHeadingID="config.general.check_for_insecure_certificates_desc"
|
||||||
})}
|
checked={scraping.scraperCertCheck ?? undefined}
|
||||||
</h6>
|
onChange={(v) => saveScraping({ scraperCertCheck: v })}
|
||||||
<StringListInput
|
|
||||||
className="w-50"
|
|
||||||
value={excludeTagPatterns}
|
|
||||||
setValue={setExcludeTagPatterns}
|
|
||||||
defaultNewValue="4K"
|
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "config.scraping.excluded_tag_patterns_desc",
|
|
||||||
})}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<hr />
|
<StringListSetting
|
||||||
|
id="excluded-tag-patterns"
|
||||||
|
headingID="config.scraping.excluded_tag_patterns_head"
|
||||||
|
subHeadingID="config.scraping.excluded_tag_patterns_desc"
|
||||||
|
value={scraping.excludeTagPatterns ?? undefined}
|
||||||
|
onChange={(v) => saveScraping({ excludeTagPatterns: v })}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
<h4>{intl.formatMessage({ id: "config.scraping.scrapers" })}</h4>
|
<SettingSection headingID="config.scraping.scrapers">
|
||||||
|
<div className="content">
|
||||||
<div className="mb-3">
|
|
||||||
<Button onClick={() => onReloadScrapers()}>
|
<Button onClick={() => onReloadScrapers()}>
|
||||||
<span className="fa-icon">
|
<span className="fa-icon">
|
||||||
<Icon icon="sync-alt" />
|
<Icon icon="sync-alt" />
|
||||||
@@ -436,18 +369,13 @@ export const SettingsScrapingPanel: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="content">
|
||||||
{renderSceneScrapers()}
|
{renderSceneScrapers()}
|
||||||
{renderGalleryScrapers()}
|
{renderGalleryScrapers()}
|
||||||
{renderPerformerScrapers()}
|
{renderPerformerScrapers()}
|
||||||
{renderMovieScrapers()}
|
{renderMovieScrapers()}
|
||||||
</div>
|
</div>
|
||||||
|
</SettingSection>
|
||||||
<hr />
|
|
||||||
|
|
||||||
<Button variant="primary" onClick={() => onSave()}>
|
|
||||||
<FormattedMessage id="actions.save" />
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
176
ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx
Normal file
176
ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { ModalSetting, NumberSetting, StringListSetting } from "./Inputs";
|
||||||
|
import { SettingSection } from "./SettingSection";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { Button, Form } from "react-bootstrap";
|
||||||
|
import { useIntl } from "react-intl";
|
||||||
|
import { SettingStateContext } from "./context";
|
||||||
|
import { LoadingIndicator } from "../Shared";
|
||||||
|
import { useToast } from "src/hooks";
|
||||||
|
import { useGenerateAPIKey } from "src/core/StashService";
|
||||||
|
|
||||||
|
type AuthenticationSettingsInput = Pick<
|
||||||
|
GQL.ConfigGeneralInput,
|
||||||
|
"username" | "password"
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface IAuthenticationInput {
|
||||||
|
value: AuthenticationSettingsInput;
|
||||||
|
setValue: (v: AuthenticationSettingsInput) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticationInput: React.FC<IAuthenticationInput> = ({
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
function set(v: Partial<AuthenticationSettingsInput>) {
|
||||||
|
setValue({
|
||||||
|
...value,
|
||||||
|
...v,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password } = value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Form.Group id="username">
|
||||||
|
<h6>{intl.formatMessage({ id: "config.general.auth.username" })}</h6>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
value={username ?? ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
set({ username: e.currentTarget.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
{intl.formatMessage({ id: "config.general.auth.username_desc" })}
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group id="password">
|
||||||
|
<h6>{intl.formatMessage({ id: "config.general.auth.password" })}</h6>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
type="password"
|
||||||
|
value={password ?? ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
set({ password: e.currentTarget.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
{intl.formatMessage({ id: "config.general.auth.password_desc" })}
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsSecurityPanel: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const Toast = useToast();
|
||||||
|
|
||||||
|
const { general, apiKey, loading, error, saveGeneral } = React.useContext(
|
||||||
|
SettingStateContext
|
||||||
|
);
|
||||||
|
|
||||||
|
const [generateAPIKey] = useGenerateAPIKey();
|
||||||
|
|
||||||
|
async function onGenerateAPIKey() {
|
||||||
|
try {
|
||||||
|
await generateAPIKey({
|
||||||
|
variables: {
|
||||||
|
input: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClearAPIKey() {
|
||||||
|
try {
|
||||||
|
await generateAPIKey({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
clear: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <h1>{error.message}</h1>;
|
||||||
|
if (loading) return <LoadingIndicator />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingSection headingID="config.general.auth.authentication">
|
||||||
|
<ModalSetting<AuthenticationSettingsInput>
|
||||||
|
id="authentication-settings"
|
||||||
|
headingID="config.general.auth.credentials.heading"
|
||||||
|
subHeadingID="config.general.auth.credentials.description"
|
||||||
|
value={{
|
||||||
|
username: general.username,
|
||||||
|
password: general.password,
|
||||||
|
}}
|
||||||
|
onChange={(v) => saveGeneral(v)}
|
||||||
|
renderField={(value, setValue) => (
|
||||||
|
<AuthenticationInput value={value ?? {}} setValue={setValue} />
|
||||||
|
)}
|
||||||
|
renderValue={(v) => {
|
||||||
|
if (v?.username && v?.password)
|
||||||
|
return <span>{v?.username ?? ""}</span>;
|
||||||
|
return <></>;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="setting" id="apikey">
|
||||||
|
<div>
|
||||||
|
<h3>{intl.formatMessage({ id: "config.general.auth.api_key" })}</h3>
|
||||||
|
|
||||||
|
<div className="value text-break">{apiKey}</div>
|
||||||
|
|
||||||
|
<div className="sub-heading">
|
||||||
|
{intl.formatMessage({ id: "config.general.auth.api_key_desc" })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
disabled={!general.username || !general.password}
|
||||||
|
onClick={() => onGenerateAPIKey()}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "config.general.auth.generate_api_key",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={() => onClearAPIKey()}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "config.general.auth.clear_api_key",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NumberSetting
|
||||||
|
id="maxSessionAge"
|
||||||
|
headingID="config.general.auth.maximum_session_age"
|
||||||
|
subHeadingID="config.general.auth.maximum_session_age_desc"
|
||||||
|
value={general.maxSessionAge ?? undefined}
|
||||||
|
onChange={(v) => saveGeneral({ maxSessionAge: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StringListSetting
|
||||||
|
id="trusted-proxies"
|
||||||
|
headingID="config.general.auth.trusted_proxies"
|
||||||
|
subHeadingID="config.general.auth.trusted_proxies_desc"
|
||||||
|
value={general.trustedProxies ?? undefined}
|
||||||
|
onChange={(v) => saveGeneral({ trustedProxies: v })}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Formik, useFormikContext } from "formik";
|
|
||||||
import { Button, Form } from "react-bootstrap";
|
import { Button, Form } from "react-bootstrap";
|
||||||
import { Prompt } from "react-router-dom";
|
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import * as yup from "yup";
|
|
||||||
import {
|
import {
|
||||||
useConfiguration,
|
|
||||||
useConfigureDLNA,
|
|
||||||
useDisableDLNA,
|
useDisableDLNA,
|
||||||
useDLNAStatus,
|
useDLNAStatus,
|
||||||
useEnableDLNA,
|
useEnableDLNA,
|
||||||
@@ -15,12 +10,18 @@ import {
|
|||||||
} from "src/core/StashService";
|
} from "src/core/StashService";
|
||||||
import { useToast } from "src/hooks";
|
import { useToast } from "src/hooks";
|
||||||
import { DurationInput, Icon, LoadingIndicator, Modal } from "../Shared";
|
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 intl = useIntl();
|
||||||
const Toast = useToast();
|
const Toast = useToast();
|
||||||
|
|
||||||
|
const { dlna, loading: configLoading, error, saveDLNA } = React.useContext(
|
||||||
|
SettingStateContext
|
||||||
|
);
|
||||||
|
|
||||||
// undefined to hide dialog, true for enable, false for disable
|
// undefined to hide dialog, true for enable, false for disable
|
||||||
const [enableDisable, setEnableDisable] = useState<boolean | undefined>(
|
const [enableDisable, setEnableDisable] = useState<boolean | undefined>(
|
||||||
undefined
|
undefined
|
||||||
@@ -34,64 +35,15 @@ export const SettingsDLNAPanel: React.FC = () => {
|
|||||||
const [ipEntry, setIPEntry] = useState<string>("");
|
const [ipEntry, setIPEntry] = useState<string>("");
|
||||||
const [tempIP, setTempIP] = useState<string | undefined>();
|
const [tempIP, setTempIP] = useState<string | undefined>();
|
||||||
|
|
||||||
const { data, refetch: configRefetch } = useConfiguration();
|
|
||||||
const { data: statusData, loading, refetch: statusRefetch } = useDLNAStatus();
|
const { data: statusData, loading, refetch: statusRefetch } = useDLNAStatus();
|
||||||
|
|
||||||
const [updateDLNAConfig] = useConfigureDLNA();
|
|
||||||
|
|
||||||
const [enableDLNA] = useEnableDLNA();
|
const [enableDLNA] = useEnableDLNA();
|
||||||
const [disableDLNA] = useDisableDLNA();
|
const [disableDLNA] = useDisableDLNA();
|
||||||
const [addTempDLANIP] = useAddTempDLNAIP();
|
const [addTempDLANIP] = useAddTempDLNAIP();
|
||||||
const [removeTempDLNAIP] = useRemoveTempDLNAIP();
|
const [removeTempDLNAIP] = useRemoveTempDLNAIP();
|
||||||
|
|
||||||
if (loading) return <LoadingIndicator />;
|
if (error) return <h1>{error.message}</h1>;
|
||||||
|
if (loading || configLoading) return <LoadingIndicator />;
|
||||||
// settings
|
|
||||||
const schema = yup.object({
|
|
||||||
serverName: yup.string(),
|
|
||||||
enabled: yup.boolean().required(),
|
|
||||||
whitelistedIPs: yup.array(yup.string().required()).required(),
|
|
||||||
interfaces: yup.array(yup.string().required()).required(),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IConfigValues {
|
|
||||||
serverName: string;
|
|
||||||
enabled: boolean;
|
|
||||||
whitelistedIPs: string[];
|
|
||||||
interfaces: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialValues: IConfigValues = {
|
|
||||||
serverName: data?.configuration.dlna.serverName ?? "",
|
|
||||||
enabled: data?.configuration.dlna.enabled ?? false,
|
|
||||||
whitelistedIPs: data?.configuration.dlna.whitelistedIPs ?? [],
|
|
||||||
interfaces: data?.configuration.dlna.interfaces ?? [],
|
|
||||||
};
|
|
||||||
|
|
||||||
async function onSave(input: IConfigValues) {
|
|
||||||
try {
|
|
||||||
await updateDLNAConfig({
|
|
||||||
variables: {
|
|
||||||
input,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
configRefetch();
|
|
||||||
Toast.success({
|
|
||||||
content: intl.formatMessage(
|
|
||||||
{ id: "toast.updated_entity" },
|
|
||||||
{
|
|
||||||
entity: intl
|
|
||||||
.formatMessage({ id: "configuration" })
|
|
||||||
.toLocaleLowerCase(),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
Toast.error(e);
|
|
||||||
} finally {
|
|
||||||
statusRefetch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onTempEnable() {
|
async function onTempEnable() {
|
||||||
const input = {
|
const input = {
|
||||||
@@ -185,13 +137,9 @@ export const SettingsDLNAPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEnableButton() {
|
function renderEnableButton() {
|
||||||
if (!data?.configuration.dlna) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if enabled by default, then show the disable temporarily
|
// if enabled by default, then show the disable temporarily
|
||||||
// if disabled by default, then show enable temporarily
|
// if disabled by default, then show enable temporarily
|
||||||
if (data?.configuration.dlna.enabled) {
|
if (dlna.enabled) {
|
||||||
return (
|
return (
|
||||||
<Button onClick={() => setEnableDisable(false)} className="mr-1">
|
<Button onClick={() => setEnableDisable(false)} className="mr-1">
|
||||||
<FormattedMessage id="actions.temp_disable" />
|
<FormattedMessage id="actions.temp_disable" />
|
||||||
@@ -207,12 +155,12 @@ export const SettingsDLNAPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canCancel() {
|
function canCancel() {
|
||||||
if (!statusData || !data) {
|
if (!statusData || !dlna) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { dlnaStatus } = statusData;
|
const { dlnaStatus } = statusData;
|
||||||
const { enabled } = data.configuration.dlna;
|
const { enabled } = dlna;
|
||||||
|
|
||||||
return dlnaStatus.until || dlnaStatus.running !== enabled;
|
return dlnaStatus.until || dlnaStatus.running !== enabled;
|
||||||
}
|
}
|
||||||
@@ -348,7 +296,7 @@ export const SettingsDLNAPanel: React.FC = () => {
|
|||||||
|
|
||||||
const { allowedIPAddresses } = statusData.dlnaStatus;
|
const { allowedIPAddresses } = statusData.dlnaStatus;
|
||||||
return (
|
return (
|
||||||
<Form.Group>
|
<Form.Group className="content">
|
||||||
<h6>
|
<h6>
|
||||||
{intl.formatMessage({ id: "config.dlna.allowed_ip_addresses" })}
|
{intl.formatMessage({ id: "config.dlna.allowed_ip_addresses" })}
|
||||||
</h6>
|
</h6>
|
||||||
@@ -429,93 +377,47 @@ export const SettingsDLNAPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DLNASettingsForm: React.FC = () => {
|
const DLNASettingsForm: React.FC = () => {
|
||||||
const {
|
|
||||||
handleSubmit,
|
|
||||||
values,
|
|
||||||
setFieldValue,
|
|
||||||
dirty,
|
|
||||||
} = useFormikContext<IConfigValues>();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form noValidate onSubmit={handleSubmit}>
|
<>
|
||||||
<Prompt
|
<SettingSection headingID="settings">
|
||||||
when={dirty}
|
<StringSetting
|
||||||
message={intl.formatMessage({ id: "dialogs.unsaved_changes" })}
|
headingID="config.dlna.server_display_name"
|
||||||
/>
|
subHeading={intl.formatMessage(
|
||||||
|
|
||||||
<Form.Group>
|
|
||||||
<h5>{intl.formatMessage({ id: "settings" })}</h5>
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Label>
|
|
||||||
{intl.formatMessage({ id: "config.dlna.server_display_name" })}
|
|
||||||
</Form.Label>
|
|
||||||
<Form.Control
|
|
||||||
className="text-input server-name"
|
|
||||||
value={values.serverName}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setFieldValue("serverName", e.currentTarget.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage(
|
|
||||||
{ id: "config.dlna.server_display_name_desc" },
|
{ id: "config.dlna.server_display_name_desc" },
|
||||||
{ server_name: <code>stash</code> }
|
{ server_name: <code>stash</code> }
|
||||||
)}
|
)}
|
||||||
</Form.Text>
|
value={dlna.serverName ?? undefined}
|
||||||
</Form.Group>
|
onChange={(v) => saveDLNA({ serverName: v })}
|
||||||
<Form.Group>
|
/>
|
||||||
<Form.Check
|
|
||||||
|
<BooleanSetting
|
||||||
id="dlna-enabled-by-default"
|
id="dlna-enabled-by-default"
|
||||||
checked={values.enabled}
|
headingID="config.dlna.enabled_by_default"
|
||||||
label={intl.formatMessage({
|
checked={dlna.enabled ?? undefined}
|
||||||
id: "config.dlna.enabled_by_default",
|
onChange={(v) => saveDLNA({ enabled: v })}
|
||||||
})}
|
|
||||||
onChange={() => setFieldValue("enabled", !values.enabled)}
|
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group>
|
<StringListSetting
|
||||||
<h6>
|
id="dlna-network-interfaces"
|
||||||
{intl.formatMessage({ id: "config.dlna.network_interfaces" })}
|
headingID="config.dlna.network_interfaces"
|
||||||
</h6>
|
subHeadingID="config.dlna.network_interfaces_desc"
|
||||||
<StringListInput
|
value={dlna.interfaces ?? undefined}
|
||||||
value={values.interfaces}
|
onChange={(v) => saveDLNA({ interfaces: v })}
|
||||||
setValue={(value) => setFieldValue("interfaces", value)}
|
|
||||||
defaultNewValue=""
|
|
||||||
className="interfaces-input"
|
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "config.dlna.network_interfaces_desc",
|
|
||||||
})}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group>
|
<StringListSetting
|
||||||
<h6>
|
id="dlna-default-ip-whitelist"
|
||||||
{intl.formatMessage({ id: "config.dlna.default_ip_whitelist" })}
|
headingID="config.dlna.default_ip_whitelist"
|
||||||
</h6>
|
subHeading={intl.formatMessage(
|
||||||
<StringListInput
|
|
||||||
value={values.whitelistedIPs}
|
|
||||||
setValue={(value) => setFieldValue("whitelistedIPs", value)}
|
|
||||||
defaultNewValue="*"
|
|
||||||
className="ip-whitelist-input"
|
|
||||||
/>
|
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage(
|
|
||||||
{ id: "config.dlna.default_ip_whitelist_desc" },
|
{ id: "config.dlna.default_ip_whitelist_desc" },
|
||||||
{ wildcard: <code>*</code> }
|
{ wildcard: <code>*</code> }
|
||||||
)}
|
)}
|
||||||
</Form.Text>
|
defaultNewValue="*"
|
||||||
</Form.Group>
|
value={dlna.whitelistedIPs ?? undefined}
|
||||||
</Form.Group>
|
onChange={(v) => saveDLNA({ whitelistedIPs: v })}
|
||||||
|
/>
|
||||||
<hr />
|
</SettingSection>
|
||||||
|
</>
|
||||||
<Button variant="primary" type="submit" disabled={!dirty}>
|
|
||||||
<FormattedMessage id="actions.save" />
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -532,17 +434,15 @@ export const SettingsDLNAPanel: React.FC = () => {
|
|||||||
</h5>
|
</h5>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<Form.Group>
|
<SettingSection headingID="actions_name">
|
||||||
<h5>{intl.formatMessage({ id: "actions_name" })}</h5>
|
<Form.Group className="content">
|
||||||
|
|
||||||
<Form.Group>
|
|
||||||
{renderEnableButton()}
|
{renderEnableButton()}
|
||||||
{renderTempCancelButton()}
|
{renderTempCancelButton()}
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
{renderAllowedIPs()}
|
{renderAllowedIPs()}
|
||||||
|
|
||||||
<Form.Group>
|
<Form.Group className="content">
|
||||||
<h6>
|
<h6>
|
||||||
{intl.formatMessage({ id: "config.dlna.recent_ip_addresses" })}
|
{intl.formatMessage({ id: "config.dlna.recent_ip_addresses" })}
|
||||||
</h6>
|
</h6>
|
||||||
@@ -553,18 +453,9 @@ export const SettingsDLNAPanel: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
</Form.Group>
|
</SettingSection>
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<Formik
|
|
||||||
initialValues={initialValues}
|
|
||||||
validationSchema={schema}
|
|
||||||
onSubmit={(values) => onSave(values)}
|
|
||||||
enableReinitialize
|
|
||||||
>
|
|
||||||
<DLNASettingsForm />
|
<DLNASettingsForm />
|
||||||
</Formik>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
303
ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx
Normal file
303
ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import React from "react";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { LoadingIndicator } from "src/components/Shared";
|
||||||
|
import { SettingSection } from "./SettingSection";
|
||||||
|
import {
|
||||||
|
BooleanSetting,
|
||||||
|
ModalSetting,
|
||||||
|
NumberSetting,
|
||||||
|
SelectSetting,
|
||||||
|
StringSetting,
|
||||||
|
} from "./Inputs";
|
||||||
|
import { SettingStateContext } from "./context";
|
||||||
|
import {
|
||||||
|
VideoPreviewInput,
|
||||||
|
VideoPreviewSettingsInput,
|
||||||
|
} from "./GeneratePreviewOptions";
|
||||||
|
|
||||||
|
export const SettingsConfigurationPanel: React.FC = () => {
|
||||||
|
const { general, loading, error, saveGeneral } = React.useContext(
|
||||||
|
SettingStateContext
|
||||||
|
);
|
||||||
|
|
||||||
|
const transcodeQualities = [
|
||||||
|
GQL.StreamingResolutionEnum.Low,
|
||||||
|
GQL.StreamingResolutionEnum.Standard,
|
||||||
|
GQL.StreamingResolutionEnum.StandardHd,
|
||||||
|
GQL.StreamingResolutionEnum.FullHd,
|
||||||
|
GQL.StreamingResolutionEnum.FourK,
|
||||||
|
GQL.StreamingResolutionEnum.Original,
|
||||||
|
].map(resolutionToString);
|
||||||
|
|
||||||
|
function resolutionToString(r: GQL.StreamingResolutionEnum | undefined) {
|
||||||
|
switch (r) {
|
||||||
|
case GQL.StreamingResolutionEnum.Low:
|
||||||
|
return "240p";
|
||||||
|
case GQL.StreamingResolutionEnum.Standard:
|
||||||
|
return "480p";
|
||||||
|
case GQL.StreamingResolutionEnum.StandardHd:
|
||||||
|
return "720p";
|
||||||
|
case GQL.StreamingResolutionEnum.FullHd:
|
||||||
|
return "1080p";
|
||||||
|
case GQL.StreamingResolutionEnum.FourK:
|
||||||
|
return "4k";
|
||||||
|
case GQL.StreamingResolutionEnum.Original:
|
||||||
|
return "Original";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Original";
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateQuality(quality: string) {
|
||||||
|
switch (quality) {
|
||||||
|
case "240p":
|
||||||
|
return GQL.StreamingResolutionEnum.Low;
|
||||||
|
case "480p":
|
||||||
|
return GQL.StreamingResolutionEnum.Standard;
|
||||||
|
case "720p":
|
||||||
|
return GQL.StreamingResolutionEnum.StandardHd;
|
||||||
|
case "1080p":
|
||||||
|
return GQL.StreamingResolutionEnum.FullHd;
|
||||||
|
case "4k":
|
||||||
|
return GQL.StreamingResolutionEnum.FourK;
|
||||||
|
case "Original":
|
||||||
|
return GQL.StreamingResolutionEnum.Original;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GQL.StreamingResolutionEnum.Original;
|
||||||
|
}
|
||||||
|
|
||||||
|
const namingHashAlgorithms = [
|
||||||
|
GQL.HashAlgorithm.Md5,
|
||||||
|
GQL.HashAlgorithm.Oshash,
|
||||||
|
].map(namingHashToString);
|
||||||
|
|
||||||
|
function namingHashToString(value: GQL.HashAlgorithm | undefined) {
|
||||||
|
switch (value) {
|
||||||
|
case GQL.HashAlgorithm.Oshash:
|
||||||
|
return "oshash";
|
||||||
|
case GQL.HashAlgorithm.Md5:
|
||||||
|
return "MD5";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "MD5";
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateNamingHash(value: string) {
|
||||||
|
switch (value) {
|
||||||
|
case "oshash":
|
||||||
|
return GQL.HashAlgorithm.Oshash;
|
||||||
|
case "MD5":
|
||||||
|
return GQL.HashAlgorithm.Md5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GQL.HashAlgorithm.Md5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <h1>{error.message}</h1>;
|
||||||
|
if (loading) return <LoadingIndicator />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingSection headingID="config.application_paths.heading">
|
||||||
|
<StringSetting
|
||||||
|
id="database-path"
|
||||||
|
headingID="config.general.db_path_head"
|
||||||
|
subHeadingID="config.general.sqlite_location"
|
||||||
|
value={general.databasePath ?? undefined}
|
||||||
|
onChange={(v) => saveGeneral({ databasePath: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StringSetting
|
||||||
|
id="generated-path"
|
||||||
|
headingID="config.general.generated_path_head"
|
||||||
|
subHeadingID="config.general.generated_files_location"
|
||||||
|
value={general.generatedPath ?? undefined}
|
||||||
|
onChange={(v) => saveGeneral({ generatedPath: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StringSetting
|
||||||
|
id="metadata-path"
|
||||||
|
headingID="config.general.metadata_path.heading"
|
||||||
|
subHeadingID="config.general.metadata_path.description"
|
||||||
|
value={general.metadataPath ?? undefined}
|
||||||
|
onChange={(v) => saveGeneral({ metadataPath: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StringSetting
|
||||||
|
id="cache-path"
|
||||||
|
headingID="config.general.cache_path_head"
|
||||||
|
subHeadingID="config.general.cache_location"
|
||||||
|
value={general.cachePath ?? undefined}
|
||||||
|
onChange={(v) => saveGeneral({ cachePath: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StringSetting
|
||||||
|
id="custom-performer-image-location"
|
||||||
|
headingID="config.ui.performers.options.image_location.heading"
|
||||||
|
subHeadingID="config.ui.performers.options.image_location.description"
|
||||||
|
value={general.customPerformerImageLocation ?? undefined}
|
||||||
|
onChange={(v) => saveGeneral({ customPerformerImageLocation: v })}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<SettingSection headingID="config.general.hashing">
|
||||||
|
<BooleanSetting
|
||||||
|
id="calculate-md5-and-ohash"
|
||||||
|
headingID="config.general.calculate_md5_and_ohash_label"
|
||||||
|
subHeadingID="config.general.calculate_md5_and_ohash_desc"
|
||||||
|
checked={general.calculateMD5 ?? false}
|
||||||
|
onChange={(v) => saveGeneral({ calculateMD5: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectSetting
|
||||||
|
id="generated_file_naming_hash"
|
||||||
|
headingID="config.general.generated_file_naming_hash_head"
|
||||||
|
subHeadingID="config.general.generated_file_naming_hash_desc"
|
||||||
|
value={namingHashToString(
|
||||||
|
general.videoFileNamingAlgorithm ?? undefined
|
||||||
|
)}
|
||||||
|
onChange={(v) =>
|
||||||
|
saveGeneral({ videoFileNamingAlgorithm: translateNamingHash(v) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{namingHashAlgorithms.map((q) => (
|
||||||
|
<option key={q} value={q}>
|
||||||
|
{q}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</SelectSetting>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<SettingSection headingID="config.system.transcoding">
|
||||||
|
<SelectSetting
|
||||||
|
id="transcode-size"
|
||||||
|
headingID="config.general.maximum_transcode_size_head"
|
||||||
|
subHeadingID="config.general.maximum_transcode_size_desc"
|
||||||
|
onChange={(v) =>
|
||||||
|
saveGeneral({ maxTranscodeSize: translateQuality(v) })
|
||||||
|
}
|
||||||
|
value={resolutionToString(general.maxTranscodeSize ?? undefined)}
|
||||||
|
>
|
||||||
|
{transcodeQualities.map((q) => (
|
||||||
|
<option key={q} value={q}>
|
||||||
|
{q}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</SelectSetting>
|
||||||
|
|
||||||
|
<SelectSetting
|
||||||
|
id="streaming-transcode-size"
|
||||||
|
headingID="config.general.maximum_streaming_transcode_size_head"
|
||||||
|
subHeadingID="config.general.maximum_streaming_transcode_size_desc"
|
||||||
|
onChange={(v) =>
|
||||||
|
saveGeneral({ maxStreamingTranscodeSize: translateQuality(v) })
|
||||||
|
}
|
||||||
|
value={resolutionToString(
|
||||||
|
general.maxStreamingTranscodeSize ?? undefined
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{transcodeQualities.map((q) => (
|
||||||
|
<option key={q} value={q}>
|
||||||
|
{q}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</SelectSetting>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<SettingSection headingID="config.general.parallel_scan_head">
|
||||||
|
<NumberSetting
|
||||||
|
id="parallel-tasks"
|
||||||
|
headingID="config.general.number_of_parallel_task_for_scan_generation_head"
|
||||||
|
subHeadingID="config.general.number_of_parallel_task_for_scan_generation_desc"
|
||||||
|
value={general.parallelTasks ?? undefined}
|
||||||
|
onChange={(v) => saveGeneral({ parallelTasks: v })}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<SettingSection headingID="config.general.preview_generation">
|
||||||
|
<SelectSetting
|
||||||
|
id="scene-gen-preview-preset"
|
||||||
|
headingID="dialogs.scene_gen.preview_preset_head"
|
||||||
|
subHeadingID="dialogs.scene_gen.preview_preset_desc"
|
||||||
|
value={general.previewPreset ?? undefined}
|
||||||
|
onChange={(v) =>
|
||||||
|
saveGeneral({
|
||||||
|
previewPreset: (v as GQL.PreviewPreset) ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Object.keys(GQL.PreviewPreset).map((p) => (
|
||||||
|
<option value={p.toLowerCase()} key={p}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</SelectSetting>
|
||||||
|
|
||||||
|
<BooleanSetting
|
||||||
|
id="preview-include-audio"
|
||||||
|
headingID="config.general.include_audio_head"
|
||||||
|
subHeadingID="config.general.include_audio_desc"
|
||||||
|
checked={general.previewAudio ?? false}
|
||||||
|
onChange={(v) => saveGeneral({ previewAudio: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModalSetting<VideoPreviewSettingsInput>
|
||||||
|
id="video-preview-settings"
|
||||||
|
headingID="dialogs.scene_gen.preview_generation_options"
|
||||||
|
value={{
|
||||||
|
previewExcludeEnd: general.previewExcludeEnd,
|
||||||
|
previewExcludeStart: general.previewExcludeStart,
|
||||||
|
previewSegmentDuration: general.previewSegmentDuration,
|
||||||
|
previewSegments: general.previewSegments,
|
||||||
|
}}
|
||||||
|
onChange={(v) => saveGeneral(v)}
|
||||||
|
renderField={(value, setValue) => (
|
||||||
|
<VideoPreviewInput value={value ?? {}} setValue={setValue} />
|
||||||
|
)}
|
||||||
|
renderValue={() => {
|
||||||
|
return <></>;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<SettingSection headingID="config.general.logging">
|
||||||
|
<StringSetting
|
||||||
|
headingID="config.general.auth.log_file"
|
||||||
|
subHeadingID="config.general.auth.log_file_desc"
|
||||||
|
value={general.logFile ?? undefined}
|
||||||
|
onChange={(v) => saveGeneral({ logFile: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BooleanSetting
|
||||||
|
id="log-terminal"
|
||||||
|
headingID="config.general.auth.log_to_terminal"
|
||||||
|
subHeadingID="config.general.auth.log_to_terminal_desc"
|
||||||
|
checked={general.logOut ?? false}
|
||||||
|
onChange={(v) => saveGeneral({ logOut: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectSetting
|
||||||
|
id="log-level"
|
||||||
|
headingID="config.logs.log_level"
|
||||||
|
onChange={(v) => saveGeneral({ logLevel: v })}
|
||||||
|
value={general.logLevel ?? undefined}
|
||||||
|
>
|
||||||
|
{["Trace", "Debug", "Info", "Warning", "Error"].map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</SelectSetting>
|
||||||
|
|
||||||
|
<BooleanSetting
|
||||||
|
id="log-http"
|
||||||
|
headingID="config.general.auth.log_http"
|
||||||
|
subHeadingID="config.general.auth.log_http_desc"
|
||||||
|
checked={general.logAccess ?? false}
|
||||||
|
onChange={(v) => saveGeneral({ logAccess: v })}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,26 +1,34 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Form } from "react-bootstrap";
|
import { Button } from "react-bootstrap";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { Setting } from "./Inputs";
|
||||||
|
import { SettingSection } from "./SettingSection";
|
||||||
|
|
||||||
export const SettingsToolsPanel: React.FC = () => {
|
export const SettingsToolsPanel: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4>
|
<SettingSection headingID="config.tools.scene_tools">
|
||||||
<FormattedMessage id="config.tools.scene_tools" />
|
<Setting
|
||||||
</h4>
|
heading={
|
||||||
|
|
||||||
<Form.Group>
|
|
||||||
<Link to="/sceneFilenameParser">
|
<Link to="/sceneFilenameParser">
|
||||||
|
<Button>
|
||||||
<FormattedMessage id="config.tools.scene_filename_parser.title" />
|
<FormattedMessage id="config.tools.scene_filename_parser.title" />
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Form.Group>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form.Group>
|
<Setting
|
||||||
|
heading={
|
||||||
<Link to="/sceneDuplicateChecker">
|
<Link to="/sceneDuplicateChecker">
|
||||||
|
<Button>
|
||||||
<FormattedMessage id="config.tools.scene_duplicate_checker" />
|
<FormattedMessage id="config.tools.scene_duplicate_checker" />
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Form.Group>
|
}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,142 +1,172 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button, Form, InputGroup } from "react-bootstrap";
|
import { Button, Form } from "react-bootstrap";
|
||||||
import { useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { Icon } from "src/components/Shared";
|
import { SettingSection } from "./SettingSection";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { SettingModal } from "./Inputs";
|
||||||
|
|
||||||
interface IInstanceProps {
|
export interface IStashBoxModal {
|
||||||
instance: IStashBoxInstance;
|
value: GQL.StashBoxInput;
|
||||||
onSave: (instance: IStashBoxInstance) => void;
|
close: (v?: GQL.StashBoxInput) => void;
|
||||||
onDelete: (id: number) => void;
|
|
||||||
isMulti: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Instance: React.FC<IInstanceProps> = ({
|
export const StashBoxModal: React.FC<IStashBoxModal> = ({ value, close }) => {
|
||||||
instance,
|
|
||||||
onSave,
|
|
||||||
onDelete,
|
|
||||||
isMulti,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const handleInput = (key: string, value: string) => {
|
|
||||||
const newObj = {
|
|
||||||
...instance,
|
|
||||||
[key]: value,
|
|
||||||
};
|
|
||||||
onSave(newObj);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Group className="row no-gutters">
|
<SettingModal<GQL.StashBoxInput>
|
||||||
<InputGroup className="col">
|
headingID="config.stashbox.title"
|
||||||
|
value={value}
|
||||||
|
renderField={(v, setValue) => (
|
||||||
|
<>
|
||||||
|
<Form.Group id="stashbox-name">
|
||||||
|
<h6>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "config.stashbox.name",
|
||||||
|
})}
|
||||||
|
</h6>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
placeholder={intl.formatMessage({ id: "config.stashbox.name" })}
|
placeholder={intl.formatMessage({ id: "config.stashbox.name" })}
|
||||||
className="text-input col-3 stash-box-name"
|
className="text-input stash-box-name"
|
||||||
value={instance?.name}
|
value={v?.name}
|
||||||
isValid={!isMulti || (instance?.name?.length ?? 0) > 0}
|
isValid={(v?.name?.length ?? 0) > 0}
|
||||||
onInput={(e: React.ChangeEvent<HTMLInputElement>) =>
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
handleInput("name", e.currentTarget.value)
|
setValue({ ...v!, name: e.currentTarget.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group id="stashbox-name">
|
||||||
|
<h6>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "config.stashbox.graphql_endpoint",
|
||||||
|
})}
|
||||||
|
</h6>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
placeholder={intl.formatMessage({
|
placeholder={intl.formatMessage({
|
||||||
id: "config.stashbox.graphql_endpoint",
|
id: "config.stashbox.graphql_endpoint",
|
||||||
})}
|
})}
|
||||||
className="text-input col-3 stash-box-endpoint"
|
className="text-input stash-box-endpoint"
|
||||||
value={instance?.endpoint}
|
value={v?.endpoint}
|
||||||
isValid={(instance?.endpoint?.length ?? 0) > 0}
|
isValid={(v?.endpoint?.length ?? 0) > 0}
|
||||||
onInput={(e: React.ChangeEvent<HTMLInputElement>) =>
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
handleInput("endpoint", e.currentTarget.value.trim())
|
setValue({ ...v!, endpoint: e.currentTarget.value.trim() })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Form.Control
|
|
||||||
placeholder={intl.formatMessage({ id: "config.stashbox.api_key" })}
|
|
||||||
className="text-input col-3 stash-box-apikey"
|
|
||||||
value={instance?.api_key}
|
|
||||||
isValid={(instance?.api_key?.length ?? 0) > 0}
|
|
||||||
onInput={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
handleInput("api_key", e.currentTarget.value.trim())
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<InputGroup.Append>
|
|
||||||
<Button
|
|
||||||
className=""
|
|
||||||
variant="danger"
|
|
||||||
title={intl.formatMessage({ id: "actions.delete" })}
|
|
||||||
onClick={() => onDelete(instance.index)}
|
|
||||||
>
|
|
||||||
<Icon icon="minus" />
|
|
||||||
</Button>
|
|
||||||
</InputGroup.Append>
|
|
||||||
</InputGroup>
|
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group id="stashbox-name">
|
||||||
|
<h6>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "config.stashbox.api_key",
|
||||||
|
})}
|
||||||
|
</h6>
|
||||||
|
<Form.Control
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: "config.stashbox.api_key",
|
||||||
|
})}
|
||||||
|
className="text-input stash-box-apikey"
|
||||||
|
value={v?.api_key}
|
||||||
|
isValid={(v?.api_key?.length ?? 0) > 0}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setValue({ ...v!, api_key: e.currentTarget.value.trim() })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
close={close}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IStashBoxConfigurationProps {
|
interface IStashBoxSetting {
|
||||||
boxes: IStashBoxInstance[];
|
value: GQL.StashBoxInput[];
|
||||||
saveBoxes: (boxes: IStashBoxInstance[]) => void;
|
onChange: (v: GQL.StashBoxInput[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IStashBoxInstance {
|
export const StashBoxSetting: React.FC<IStashBoxSetting> = ({
|
||||||
name?: string;
|
value,
|
||||||
endpoint?: string;
|
onChange,
|
||||||
api_key?: string;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StashBoxConfiguration: React.FC<IStashBoxConfigurationProps> = ({
|
|
||||||
boxes,
|
|
||||||
saveBoxes,
|
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [index, setIndex] = useState(1000);
|
const [editingIndex, setEditingIndex] = useState<number | undefined>();
|
||||||
|
|
||||||
const handleSave = (instance: IStashBoxInstance) =>
|
function onEdit(index: number) {
|
||||||
saveBoxes(
|
setEditingIndex(index);
|
||||||
boxes.map((box) => (box.index === instance.index ? instance : box))
|
}
|
||||||
);
|
|
||||||
const handleDelete = (id: number) =>
|
function onDelete(index: number) {
|
||||||
saveBoxes(boxes.filter((box) => box.index !== id));
|
onChange(value.filter((v, i) => i !== index));
|
||||||
const handleAdd = () => {
|
}
|
||||||
saveBoxes([...boxes, { index }]);
|
|
||||||
setIndex(index + 1);
|
function onNew() {
|
||||||
};
|
setIsCreating(true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Group>
|
<SettingSection
|
||||||
<h6>{intl.formatMessage({ id: "config.stashbox.title" })}</h6>
|
id="stash-boxes"
|
||||||
{boxes.length > 0 && (
|
headingID="config.stashbox.title"
|
||||||
<div className="row no-gutters">
|
subHeadingID="config.stashbox.description"
|
||||||
<h6 className="col-3 ml-1">
|
|
||||||
{intl.formatMessage({ id: "config.stashbox.name" })}
|
|
||||||
</h6>
|
|
||||||
<h6 className="col-3 ml-1">
|
|
||||||
{intl.formatMessage({ id: "config.stashbox.endpoint" })}
|
|
||||||
</h6>
|
|
||||||
<h6 className="col-3 ml-1">
|
|
||||||
{intl.formatMessage({ id: "config.general.auth.api_key" })}
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{boxes.map((instance) => (
|
|
||||||
<Instance
|
|
||||||
instance={instance}
|
|
||||||
onSave={handleSave}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
key={instance.index}
|
|
||||||
isMulti={boxes.length > 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
className="minimal"
|
|
||||||
title={intl.formatMessage({ id: "config.stashbox.add_instance" })}
|
|
||||||
onClick={handleAdd}
|
|
||||||
>
|
>
|
||||||
<Icon icon="plus" />
|
{isCreating ? (
|
||||||
|
<StashBoxModal
|
||||||
|
value={{
|
||||||
|
endpoint: "",
|
||||||
|
api_key: "",
|
||||||
|
name: "",
|
||||||
|
}}
|
||||||
|
close={(v) => {
|
||||||
|
if (v) onChange([...value, v]);
|
||||||
|
setIsCreating(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{editingIndex !== undefined ? (
|
||||||
|
<StashBoxModal
|
||||||
|
value={value[editingIndex]}
|
||||||
|
close={(v) => {
|
||||||
|
if (v)
|
||||||
|
onChange(
|
||||||
|
value.map((vv, index) => {
|
||||||
|
if (index === editingIndex) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
return vv;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setEditingIndex(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{value.map((b, index) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<div key={index} className="setting">
|
||||||
|
<div>
|
||||||
|
<h3>{b.name ?? `#${index}`}</h3>
|
||||||
|
<div className="value">{b.endpoint ?? ""}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button onClick={() => onEdit(index)}>
|
||||||
|
<FormattedMessage id="actions.edit" />
|
||||||
</Button>
|
</Button>
|
||||||
<Form.Text className="text-muted">
|
<Button variant="danger" onClick={() => onDelete(index)}>
|
||||||
{intl.formatMessage({ id: "config.stashbox.description" })}
|
<FormattedMessage id="actions.delete" />
|
||||||
</Form.Text>
|
</Button>
|
||||||
</Form.Group>
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="setting">
|
||||||
|
<div />
|
||||||
|
<div>
|
||||||
|
<Button onClick={() => onNew()}>
|
||||||
|
<FormattedMessage id="actions.add" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button, Form, Row, Col } from "react-bootstrap";
|
import { Button, Form, Row, Col, Dropdown } from "react-bootstrap";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import { Icon } from "src/components/Shared";
|
import { Icon } from "src/components/Shared";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { FolderSelectDialog } from "../Shared/FolderSelect/FolderSelectDialog";
|
import { FolderSelectDialog } from "../Shared/FolderSelect/FolderSelectDialog";
|
||||||
|
import { BooleanSetting } from "./Inputs";
|
||||||
|
import { SettingSection } from "./SettingSection";
|
||||||
|
|
||||||
interface IStashProps {
|
interface IStashProps {
|
||||||
index: number;
|
index: number;
|
||||||
stash: GQL.StashConfig;
|
stash: GQL.StashConfig;
|
||||||
onSave: (instance: GQL.StashConfig) => void;
|
onSave: (instance: GQL.StashConfig) => void;
|
||||||
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Stash: React.FC<IStashProps> = ({ index, stash, onSave, onDelete }) => {
|
const Stash: React.FC<IStashProps> = ({
|
||||||
|
index,
|
||||||
|
stash,
|
||||||
|
onSave,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const handleInput = (key: string, value: any) => {
|
const handleInput = (key: string, value: any) => {
|
||||||
const newObj = {
|
const newObj = {
|
||||||
@@ -22,38 +31,58 @@ const Stash: React.FC<IStashProps> = ({ index, stash, onSave, onDelete }) => {
|
|||||||
onSave(newObj);
|
onSave(newObj);
|
||||||
};
|
};
|
||||||
|
|
||||||
const intl = useIntl();
|
|
||||||
const classAdd = index % 2 === 1 ? "bg-dark" : "";
|
const classAdd = index % 2 === 1 ? "bg-dark" : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className={`align-items-center ${classAdd}`}>
|
<Row className={`stash-row align-items-center ${classAdd}`}>
|
||||||
<Form.Label column xs={4}>
|
<Form.Label column md={7}>
|
||||||
{stash.path}
|
{stash.path}
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
<Col xs={3}>
|
<Col md={2} xs={4} className="col form-label">
|
||||||
<Form.Check
|
{/* NOTE - language is opposite to meaning:
|
||||||
id="stash-exclude-video"
|
internally exclude flags, displayed as include */}
|
||||||
checked={stash.excludeVideo}
|
<div>
|
||||||
onChange={() => handleInput("excludeVideo", !stash.excludeVideo)}
|
<h6 className="d-md-none">
|
||||||
|
<FormattedMessage id="videos" />
|
||||||
|
</h6>
|
||||||
|
<BooleanSetting
|
||||||
|
id={`stash-exclude-video-${index}`}
|
||||||
|
checked={!stash.excludeVideo}
|
||||||
|
onChange={(v) => handleInput("excludeVideo", !v)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={3}>
|
<Col md={2} xs={4} className="col-form-label">
|
||||||
<Form.Check
|
<div>
|
||||||
id="stash-exclude-image"
|
<h6 className="d-md-none">
|
||||||
checked={stash.excludeImage}
|
<FormattedMessage id="images" />
|
||||||
onChange={() => handleInput("excludeImage", !stash.excludeImage)}
|
</h6>
|
||||||
|
<BooleanSetting
|
||||||
|
id={`stash-exclude-image-${index}`}
|
||||||
|
checked={!stash.excludeImage}
|
||||||
|
onChange={(v) => handleInput("excludeImage", !v)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={2}>
|
<Col className="justify-content-end" xs={4} md={1}>
|
||||||
<Button
|
<Dropdown className="text-right">
|
||||||
size="sm"
|
<Dropdown.Toggle
|
||||||
variant="danger"
|
variant="minimal"
|
||||||
title={intl.formatMessage({ id: "actions.delete" })}
|
id={`stash-menu-${index}`}
|
||||||
onClick={() => onDelete()}
|
className="minimal"
|
||||||
>
|
>
|
||||||
<Icon icon="minus" />
|
<Icon icon="ellipsis-v" />
|
||||||
</Button>
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu className="bg-secondary text-white">
|
||||||
|
<Dropdown.Item onClick={() => onEdit()}>
|
||||||
|
<FormattedMessage id="actions.edit" />
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item onClick={() => onDelete()}>
|
||||||
|
<FormattedMessage id="actions.delete" />
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
@@ -68,51 +97,75 @@ const StashConfiguration: React.FC<IStashConfigurationProps> = ({
|
|||||||
stashes,
|
stashes,
|
||||||
setStashes,
|
setStashes,
|
||||||
}) => {
|
}) => {
|
||||||
const [isDisplayingDialog, setIsDisplayingDialog] = useState<boolean>(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [editingIndex, setEditingIndex] = useState<number | undefined>();
|
||||||
|
|
||||||
|
function onEdit(index: number) {
|
||||||
|
setEditingIndex(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDelete(index: number) {
|
||||||
|
setStashes(stashes.filter((v, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNew() {
|
||||||
|
setIsCreating(true);
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = (index: number, stash: GQL.StashConfig) =>
|
const handleSave = (index: number, stash: GQL.StashConfig) =>
|
||||||
setStashes(stashes.map((s, i) => (i === index ? stash : s)));
|
setStashes(stashes.map((s, i) => (i === index ? stash : s)));
|
||||||
const handleDelete = (index: number) =>
|
|
||||||
setStashes(stashes.filter((s, i) => i !== index));
|
|
||||||
const handleAdd = (folder?: string) => {
|
|
||||||
setIsDisplayingDialog(false);
|
|
||||||
|
|
||||||
if (!folder) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStashes([
|
|
||||||
...stashes,
|
|
||||||
{
|
|
||||||
path: folder,
|
|
||||||
excludeImage: false,
|
|
||||||
excludeVideo: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
function maybeRenderDialog() {
|
|
||||||
if (!isDisplayingDialog) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <FolderSelectDialog onClose={handleAdd} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{maybeRenderDialog()}
|
{isCreating ? (
|
||||||
<Form.Group>
|
<FolderSelectDialog
|
||||||
|
onClose={(v) => {
|
||||||
|
if (v)
|
||||||
|
setStashes([
|
||||||
|
...stashes,
|
||||||
|
{
|
||||||
|
path: v,
|
||||||
|
excludeVideo: false,
|
||||||
|
excludeImage: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setIsCreating(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{editingIndex !== undefined ? (
|
||||||
|
<FolderSelectDialog
|
||||||
|
defaultValue={stashes[editingIndex].path}
|
||||||
|
onClose={(v) => {
|
||||||
|
if (v)
|
||||||
|
setStashes(
|
||||||
|
stashes.map((vv, index) => {
|
||||||
|
if (index === editingIndex) {
|
||||||
|
return {
|
||||||
|
...vv,
|
||||||
|
path: v,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return vv;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setEditingIndex(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
<div className="content" id="stash-table">
|
||||||
{stashes.length > 0 && (
|
{stashes.length > 0 && (
|
||||||
<Row>
|
<Row className="d-none d-md-flex">
|
||||||
<h6 className="col-4">
|
<h6 className="col-md-7">
|
||||||
<FormattedMessage id="path" />
|
<FormattedMessage id="path" />
|
||||||
</h6>
|
</h6>
|
||||||
<h6 className="col-3">
|
<h6 className="col-md-2 col-4">
|
||||||
<FormattedMessage id="config.general.exclude_video" />
|
<FormattedMessage id="videos" />
|
||||||
</h6>
|
</h6>
|
||||||
<h6 className="col-3">
|
<h6 className="col-md-2 col-4">
|
||||||
<FormattedMessage id="config.general.exclude_image" />
|
<FormattedMessage id="images" />
|
||||||
</h6>
|
</h6>
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
@@ -121,20 +174,34 @@ const StashConfiguration: React.FC<IStashConfigurationProps> = ({
|
|||||||
index={index}
|
index={index}
|
||||||
stash={stash}
|
stash={stash}
|
||||||
onSave={(s) => handleSave(index, s)}
|
onSave={(s) => handleSave(index, s)}
|
||||||
onDelete={() => handleDelete(index)}
|
onEdit={() => onEdit(index)}
|
||||||
|
onDelete={() => onDelete(index)}
|
||||||
key={stash.path}
|
key={stash.path}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button className="mt-2" variant="secondary" onClick={() => onNew()}>
|
||||||
className="mt-2"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setIsDisplayingDialog(true)}
|
|
||||||
>
|
|
||||||
<FormattedMessage id="actions.add_directory" />
|
<FormattedMessage id="actions.add_directory" />
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Group>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface IStashSetting {
|
||||||
|
value: GQL.StashConfigInput[];
|
||||||
|
onChange: (v: GQL.StashConfigInput[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StashSetting: React.FC<IStashSetting> = ({ value, onChange }) => {
|
||||||
|
return (
|
||||||
|
<SettingSection
|
||||||
|
id="stashes"
|
||||||
|
headingID="library"
|
||||||
|
subHeadingID="config.general.directory_locations_to_your_content"
|
||||||
|
>
|
||||||
|
<StashConfiguration stashes={value} setStashes={(v) => onChange(v)} />
|
||||||
|
</SettingSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default StashConfiguration;
|
export default StashConfiguration;
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import { useToast } from "src/hooks";
|
|||||||
import { downloadFile } from "src/utils";
|
import { downloadFile } from "src/utils";
|
||||||
import { Modal } from "../../Shared";
|
import { Modal } from "../../Shared";
|
||||||
import { ImportDialog } from "./ImportDialog";
|
import { ImportDialog } from "./ImportDialog";
|
||||||
import { Task } from "./Task";
|
|
||||||
import * as GQL from "src/core/generated-graphql";
|
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 {
|
interface ICleanOptions {
|
||||||
options: GQL.CleanMetadataInput;
|
options: GQL.CleanMetadataInput;
|
||||||
@@ -24,21 +27,19 @@ const CleanOptions: React.FC<ICleanOptions> = ({
|
|||||||
options,
|
options,
|
||||||
setOptions: setOptionsState,
|
setOptions: setOptionsState,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
function setOptions(input: Partial<GQL.CleanMetadataInput>) {
|
function setOptions(input: Partial<GQL.CleanMetadataInput>) {
|
||||||
setOptionsState({ ...options, ...input });
|
setOptionsState({ ...options, ...input });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Group>
|
<>
|
||||||
<Form.Check
|
<BooleanSetting
|
||||||
id="clean-dryrun"
|
id="clean-dryrun"
|
||||||
checked={options.dryRun}
|
checked={options.dryRun}
|
||||||
label={intl.formatMessage({ id: "config.tasks.only_dry_run" })}
|
headingID="config.tasks.only_dry_run"
|
||||||
onChange={() => setOptions({ dryRun: !options.dryRun })}
|
onChange={(v) => setOptions({ dryRun: v })}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -213,19 +214,19 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
|
|||||||
{renderImportDialog()}
|
{renderImportDialog()}
|
||||||
{renderCleanDialog()}
|
{renderCleanDialog()}
|
||||||
|
|
||||||
<Form.Group>
|
<SettingSection headingID="config.tasks.maintenance">
|
||||||
<div className="task-group">
|
<div className="setting-group">
|
||||||
<h5>{intl.formatMessage({ id: "config.tasks.maintenance" })}</h5>
|
<Setting
|
||||||
<Task
|
heading={
|
||||||
headingID="actions.clean"
|
<>
|
||||||
description={intl.formatMessage({
|
<FormattedMessage id="actions.clean" />
|
||||||
id: "config.tasks.cleanup_desc",
|
<ManualLink tab="Tasks">
|
||||||
})}
|
<Icon icon="question-circle" />
|
||||||
|
</ManualLink>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
subHeadingID="config.tasks.cleanup_desc"
|
||||||
>
|
>
|
||||||
<CleanOptions
|
|
||||||
options={cleanOptions}
|
|
||||||
setOptions={(o) => setCleanOptions(o)}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -233,19 +234,18 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
|
|||||||
>
|
>
|
||||||
<FormattedMessage id="actions.clean" />…
|
<FormattedMessage id="actions.clean" />…
|
||||||
</Button>
|
</Button>
|
||||||
</Task>
|
</Setting>
|
||||||
|
<CleanOptions
|
||||||
|
options={cleanOptions}
|
||||||
|
setOptions={(o) => setCleanOptions(o)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Form.Group>
|
</SettingSection>
|
||||||
|
|
||||||
<hr />
|
<SettingSection headingID="metadata">
|
||||||
|
<Setting
|
||||||
<Form.Group>
|
headingID="actions.full_export"
|
||||||
<h5>{intl.formatMessage({ id: "metadata" })}</h5>
|
subHeadingID="config.tasks.export_to_json"
|
||||||
<div className="task-group">
|
|
||||||
<Task
|
|
||||||
description={intl.formatMessage({
|
|
||||||
id: "config.tasks.export_to_json",
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
id="export"
|
id="export"
|
||||||
@@ -255,12 +255,11 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
|
|||||||
>
|
>
|
||||||
<FormattedMessage id="actions.full_export" />
|
<FormattedMessage id="actions.full_export" />
|
||||||
</Button>
|
</Button>
|
||||||
</Task>
|
</Setting>
|
||||||
|
|
||||||
<Task
|
<Setting
|
||||||
description={intl.formatMessage({
|
headingID="actions.full_import"
|
||||||
id: "config.tasks.import_from_exported_json",
|
subHeadingID="config.tasks.import_from_exported_json"
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
id="import"
|
id="import"
|
||||||
@@ -270,12 +269,11 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
|
|||||||
>
|
>
|
||||||
<FormattedMessage id="actions.full_import" />
|
<FormattedMessage id="actions.full_import" />
|
||||||
</Button>
|
</Button>
|
||||||
</Task>
|
</Setting>
|
||||||
|
|
||||||
<Task
|
<Setting
|
||||||
description={intl.formatMessage({
|
headingID="actions.import_from_file"
|
||||||
id: "config.tasks.incremental_import",
|
subHeadingID="config.tasks.incremental_import"
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
id="partial-import"
|
id="partial-import"
|
||||||
@@ -285,17 +283,13 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
|
|||||||
>
|
>
|
||||||
<FormattedMessage id="actions.import_from_file" />
|
<FormattedMessage id="actions.import_from_file" />
|
||||||
</Button>
|
</Button>
|
||||||
</Task>
|
</Setting>
|
||||||
</div>
|
</SettingSection>
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<hr />
|
<SettingSection headingID="actions.backup">
|
||||||
|
<Setting
|
||||||
<Form.Group>
|
headingID="actions.backup"
|
||||||
<h5>{intl.formatMessage({ id: "actions.backup" })}</h5>
|
subHeading={intl.formatMessage(
|
||||||
<div className="task-group">
|
|
||||||
<Task
|
|
||||||
description={intl.formatMessage(
|
|
||||||
{ id: "config.tasks.backup_database" },
|
{ id: "config.tasks.backup_database" },
|
||||||
{
|
{
|
||||||
filename_format: (
|
filename_format: (
|
||||||
@@ -314,12 +308,11 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
|
|||||||
>
|
>
|
||||||
<FormattedMessage id="actions.backup" />
|
<FormattedMessage id="actions.backup" />
|
||||||
</Button>
|
</Button>
|
||||||
</Task>
|
</Setting>
|
||||||
|
|
||||||
<Task
|
<Setting
|
||||||
description={intl.formatMessage({
|
headingID="actions.download_backup"
|
||||||
id: "config.tasks.backup_and_download",
|
subHeadingID="config.tasks.backup_and_download"
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
id="backupDownload"
|
id="backupDownload"
|
||||||
@@ -329,20 +322,13 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
|
|||||||
>
|
>
|
||||||
<FormattedMessage id="actions.download_backup" />
|
<FormattedMessage id="actions.download_backup" />
|
||||||
</Button>
|
</Button>
|
||||||
</Task>
|
</Setting>
|
||||||
</div>
|
</SettingSection>
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<hr />
|
<SettingSection headingID="config.tasks.migrations">
|
||||||
|
<Setting
|
||||||
<Form.Group>
|
headingID="actions.rename_gen_files"
|
||||||
<h5>{intl.formatMessage({ id: "config.tasks.migrations" })}</h5>
|
subHeadingID="config.tasks.migrate_hash_files"
|
||||||
|
|
||||||
<div className="task-group">
|
|
||||||
<Task
|
|
||||||
description={intl.formatMessage({
|
|
||||||
id: "config.tasks.migrate_hash_files",
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
id="migrateHashNaming"
|
id="migrateHashNaming"
|
||||||
@@ -351,9 +337,8 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
|
|||||||
>
|
>
|
||||||
<FormattedMessage id="actions.rename_gen_files" />
|
<FormattedMessage id="actions.rename_gen_files" />
|
||||||
</Button>
|
</Button>
|
||||||
</Task>
|
</Setting>
|
||||||
</div>
|
</SettingSection>
|
||||||
</Form.Group>
|
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { Form, Button, Collapse } from "react-bootstrap";
|
|
||||||
import { Icon } from "src/components/Shared";
|
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { BooleanSetting, ModalSetting } from "../Inputs";
|
||||||
|
import {
|
||||||
|
VideoPreviewInput,
|
||||||
|
VideoPreviewSettingsInput,
|
||||||
|
} from "../GeneratePreviewOptions";
|
||||||
import { useIntl } from "react-intl";
|
import { useIntl } from "react-intl";
|
||||||
|
|
||||||
interface IGenerateOptions {
|
interface IGenerateOptions {
|
||||||
@@ -15,8 +18,6 @@ export const GenerateOptions: React.FC<IGenerateOptions> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const [previewOptionsOpen, setPreviewOptionsOpen] = useState(false);
|
|
||||||
|
|
||||||
const previewOptions: GQL.GeneratePreviewOptionsInput =
|
const previewOptions: GQL.GeneratePreviewOptionsInput =
|
||||||
options.previewOptions ?? {};
|
options.previewOptions ?? {};
|
||||||
|
|
||||||
@@ -24,275 +25,110 @@ export const GenerateOptions: React.FC<IGenerateOptions> = ({
|
|||||||
setOptionsState({ ...options, ...input });
|
setOptionsState({ ...options, ...input });
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPreviewOptions(input: Partial<GQL.GeneratePreviewOptionsInput>) {
|
|
||||||
setOptions({
|
|
||||||
previewOptions: {
|
|
||||||
...previewOptions,
|
|
||||||
...input,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Group>
|
<>
|
||||||
<Form.Group>
|
<BooleanSetting
|
||||||
<Form.Check
|
|
||||||
id="preview-task"
|
id="preview-task"
|
||||||
checked={options.previews ?? false}
|
checked={options.previews ?? false}
|
||||||
label={intl.formatMessage({
|
headingID="dialogs.scene_gen.video_previews"
|
||||||
id: "dialogs.scene_gen.video_previews",
|
tooltipID="dialogs.scene_gen.video_previews_tooltip"
|
||||||
})}
|
onChange={(v) => setOptions({ previews: v })}
|
||||||
onChange={() => setOptions({ previews: !options.previews })}
|
|
||||||
/>
|
/>
|
||||||
<div className="d-flex flex-row">
|
<BooleanSetting
|
||||||
<div>↳</div>
|
className="sub-setting"
|
||||||
<Form.Check
|
|
||||||
id="image-preview-task"
|
id="image-preview-task"
|
||||||
checked={options.imagePreviews ?? false}
|
checked={options.imagePreviews ?? false}
|
||||||
disabled={!options.previews}
|
disabled={!options.previews}
|
||||||
label={intl.formatMessage({
|
headingID="dialogs.scene_gen.image_previews"
|
||||||
id: "dialogs.scene_gen.image_previews",
|
tooltipID="dialogs.scene_gen.image_previews_tooltip"
|
||||||
})}
|
onChange={(v) => setOptions({ imagePreviews: v })}
|
||||||
onChange={() =>
|
|
||||||
setOptions({ imagePreviews: !options.imagePreviews })
|
|
||||||
}
|
|
||||||
className="ml-2 flex-grow"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group>
|
<ModalSetting<VideoPreviewSettingsInput>
|
||||||
<Button
|
id="video-preview-settings"
|
||||||
onClick={() => setPreviewOptionsOpen(!previewOptionsOpen)}
|
className="sub-setting"
|
||||||
className="minimal pl-0 no-focus"
|
disabled={!options.previews}
|
||||||
>
|
buttonText={`${intl.formatMessage({
|
||||||
<Icon icon={previewOptionsOpen ? "chevron-down" : "chevron-right"} />
|
id: "dialogs.scene_gen.preview_generation_options",
|
||||||
<span>
|
})}…`}
|
||||||
{intl.formatMessage({
|
value={{
|
||||||
id: "dialogs.scene_gen.preview_options",
|
previewExcludeEnd: previewOptions.previewExcludeEnd,
|
||||||
})}
|
previewExcludeStart: previewOptions.previewExcludeStart,
|
||||||
</span>
|
previewSegmentDuration: previewOptions.previewSegmentDuration,
|
||||||
</Button>
|
previewSegments: previewOptions.previewSegments,
|
||||||
<Form.Group>
|
}}
|
||||||
<Collapse in={previewOptionsOpen}>
|
onChange={(v) => setOptions({ previewOptions: v })}
|
||||||
<Form.Group className="mt-2">
|
renderField={(value, setValue) => (
|
||||||
<Form.Group id="preview-preset">
|
<VideoPreviewInput value={value ?? {}} setValue={setValue} />
|
||||||
<h6>
|
)}
|
||||||
{intl.formatMessage({
|
renderValue={() => {
|
||||||
id: "dialogs.scene_gen.preview_preset_head",
|
return <></>;
|
||||||
})}
|
}}
|
||||||
</h6>
|
|
||||||
<Form.Control
|
|
||||||
className="w-auto input-control"
|
|
||||||
as="select"
|
|
||||||
value={previewOptions.previewPreset ?? GQL.PreviewPreset.Slow}
|
|
||||||
onChange={(e) =>
|
|
||||||
setPreviewOptions({
|
|
||||||
previewPreset: e.currentTarget.value as GQL.PreviewPreset,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{Object.keys(GQL.PreviewPreset).map((p) => (
|
|
||||||
<option value={p.toLowerCase()} key={p}>
|
|
||||||
{p}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Form.Control>
|
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "dialogs.scene_gen.preview_preset_desc",
|
|
||||||
})}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group id="preview-segments">
|
|
||||||
<h6>
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "dialogs.scene_gen.preview_seg_count_head",
|
|
||||||
})}
|
|
||||||
</h6>
|
|
||||||
<Form.Control
|
|
||||||
className="col col-sm-6 text-input"
|
|
||||||
type="number"
|
|
||||||
value={previewOptions.previewSegments?.toString() ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setPreviewOptions({
|
|
||||||
previewSegments: Number.parseInt(
|
|
||||||
e.currentTarget.value,
|
|
||||||
10
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "dialogs.scene_gen.preview_seg_count_desc",
|
|
||||||
})}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group id="preview-segment-duration">
|
<BooleanSetting
|
||||||
<h6>
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "dialogs.scene_gen.preview_seg_duration_head",
|
|
||||||
})}
|
|
||||||
</h6>
|
|
||||||
<Form.Control
|
|
||||||
className="col col-sm-6 text-input"
|
|
||||||
type="number"
|
|
||||||
value={
|
|
||||||
previewOptions.previewSegmentDuration?.toString() ?? ""
|
|
||||||
}
|
|
||||||
onChange={(e) =>
|
|
||||||
setPreviewOptions({
|
|
||||||
previewSegmentDuration: Number.parseFloat(
|
|
||||||
e.currentTarget.value
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "dialogs.scene_gen.preview_seg_duration_desc",
|
|
||||||
})}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group id="preview-exclude-start">
|
|
||||||
<h6>
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "dialogs.scene_gen.preview_exclude_start_time_head",
|
|
||||||
})}
|
|
||||||
</h6>
|
|
||||||
<Form.Control
|
|
||||||
className="col col-sm-6 text-input"
|
|
||||||
value={previewOptions.previewExcludeStart ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setPreviewOptions({
|
|
||||||
previewExcludeStart: e.currentTarget.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "dialogs.scene_gen.preview_exclude_start_time_desc",
|
|
||||||
})}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group id="preview-exclude-start">
|
|
||||||
<h6>
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "dialogs.scene_gen.preview_exclude_end_time_head",
|
|
||||||
})}
|
|
||||||
</h6>
|
|
||||||
<Form.Control
|
|
||||||
className="col col-sm-6 text-input"
|
|
||||||
value={previewOptions.previewExcludeEnd ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setPreviewOptions({
|
|
||||||
previewExcludeEnd: e.currentTarget.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Form.Text className="text-muted">
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "dialogs.scene_gen.preview_exclude_end_time_desc",
|
|
||||||
})}
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
</Form.Group>
|
|
||||||
</Collapse>
|
|
||||||
</Form.Group>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Check
|
|
||||||
id="sprite-task"
|
id="sprite-task"
|
||||||
checked={options.sprites ?? false}
|
checked={options.sprites ?? false}
|
||||||
label={intl.formatMessage({ id: "dialogs.scene_gen.sprites" })}
|
headingID="dialogs.scene_gen.sprites"
|
||||||
onChange={() => setOptions({ sprites: !options.sprites })}
|
tooltipID="dialogs.scene_gen.sprites_tooltip"
|
||||||
|
onChange={(v) => setOptions({ sprites: v })}
|
||||||
/>
|
/>
|
||||||
<Form.Group>
|
<BooleanSetting
|
||||||
<Form.Check
|
|
||||||
id="marker-task"
|
id="marker-task"
|
||||||
checked={options.markers ?? false}
|
checked={options.markers ?? false}
|
||||||
label={intl.formatMessage({ id: "dialogs.scene_gen.markers" })}
|
headingID="dialogs.scene_gen.markers"
|
||||||
onChange={() => setOptions({ markers: !options.markers })}
|
tooltipID="dialogs.scene_gen.markers_tooltip"
|
||||||
|
onChange={(v) => setOptions({ markers: v })}
|
||||||
/>
|
/>
|
||||||
<div className="d-flex flex-row">
|
<BooleanSetting
|
||||||
<div>↳</div>
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Check
|
|
||||||
id="marker-image-preview-task"
|
id="marker-image-preview-task"
|
||||||
|
className="sub-setting"
|
||||||
checked={options.markerImagePreviews ?? false}
|
checked={options.markerImagePreviews ?? false}
|
||||||
disabled={!options.markers}
|
disabled={!options.markers}
|
||||||
label={intl.formatMessage({
|
headingID="dialogs.scene_gen.marker_image_previews"
|
||||||
id: "dialogs.scene_gen.marker_image_previews",
|
tooltipID="dialogs.scene_gen.marker_image_previews_tooltip"
|
||||||
})}
|
onChange={(v) =>
|
||||||
onChange={() =>
|
|
||||||
setOptions({
|
setOptions({
|
||||||
markerImagePreviews: !options.markerImagePreviews,
|
markerImagePreviews: v,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="ml-2 flex-grow"
|
|
||||||
/>
|
/>
|
||||||
<Form.Check
|
<BooleanSetting
|
||||||
id="marker-screenshot-task"
|
id="marker-screenshot-task"
|
||||||
|
className="sub-setting"
|
||||||
checked={options.markerScreenshots ?? false}
|
checked={options.markerScreenshots ?? false}
|
||||||
disabled={!options.markers}
|
disabled={!options.markers}
|
||||||
label={intl.formatMessage({
|
headingID="dialogs.scene_gen.marker_screenshots"
|
||||||
id: "dialogs.scene_gen.marker_screenshots",
|
tooltipID="dialogs.scene_gen.marker_screenshots_tooltip"
|
||||||
})}
|
onChange={(v) => setOptions({ markerScreenshots: v })}
|
||||||
onChange={() =>
|
|
||||||
setOptions({ markerScreenshots: !options.markerScreenshots })
|
|
||||||
}
|
|
||||||
className="ml-2 flex-grow"
|
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
|
||||||
</div>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group>
|
<BooleanSetting
|
||||||
<Form.Check
|
|
||||||
id="transcode-task"
|
id="transcode-task"
|
||||||
checked={options.transcodes ?? false}
|
checked={options.transcodes ?? false}
|
||||||
label={intl.formatMessage({ id: "dialogs.scene_gen.transcodes" })}
|
headingID="dialogs.scene_gen.transcodes"
|
||||||
onChange={() => setOptions({ transcodes: !options.transcodes })}
|
tooltipID="dialogs.scene_gen.transcodes_tooltip"
|
||||||
|
onChange={(v) => setOptions({ transcodes: v })}
|
||||||
/>
|
/>
|
||||||
<Form.Check
|
<BooleanSetting
|
||||||
id="phash-task"
|
id="phash-task"
|
||||||
checked={options.phashes ?? false}
|
checked={options.phashes ?? false}
|
||||||
label={intl.formatMessage({ id: "dialogs.scene_gen.phash" })}
|
headingID="dialogs.scene_gen.phash"
|
||||||
onChange={() => setOptions({ phashes: !options.phashes })}
|
onChange={(v) => setOptions({ phashes: v })}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group>
|
<BooleanSetting
|
||||||
<Form.Check
|
|
||||||
id="interactive-heatmap-speed-task"
|
id="interactive-heatmap-speed-task"
|
||||||
checked={options.interactiveHeatmapsSpeeds ?? false}
|
checked={options.interactiveHeatmapsSpeeds ?? false}
|
||||||
label={intl.formatMessage({
|
headingID="dialogs.scene_gen.interactive_heatmap_speed"
|
||||||
id: "dialogs.scene_gen.interactive_heatmap_speed",
|
onChange={(v) => setOptions({ interactiveHeatmapsSpeeds: v })}
|
||||||
})}
|
|
||||||
onChange={() =>
|
|
||||||
setOptions({
|
|
||||||
interactiveHeatmapsSpeeds: !options.interactiveHeatmapsSpeeds,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
<BooleanSetting
|
||||||
|
|
||||||
<hr />
|
|
||||||
<Form.Group>
|
|
||||||
<Form.Check
|
|
||||||
id="overwrite"
|
id="overwrite"
|
||||||
checked={options.overwrite ?? false}
|
checked={options.overwrite ?? false}
|
||||||
label={intl.formatMessage({ id: "dialogs.scene_gen.overwrite" })}
|
headingID="dialogs.scene_gen.overwrite"
|
||||||
onChange={() => setOptions({ overwrite: !options.overwrite })}
|
onChange={(v) => setOptions({ overwrite: v })}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</>
|
||||||
</Form.Group>
|
|
||||||
</Form.Group>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Button, ProgressBar } from "react-bootstrap";
|
import { Button, Card, ProgressBar } from "react-bootstrap";
|
||||||
import {
|
import {
|
||||||
mutateStopJob,
|
mutateStopJob,
|
||||||
useJobQueue,
|
useJobQueue,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { Icon } from "src/components/Shared";
|
import { Icon } from "src/components/Shared";
|
||||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
||||||
|
import { useIntl } from "react-intl";
|
||||||
|
|
||||||
type JobFragment = Pick<
|
type JobFragment = Pick<
|
||||||
GQL.Job,
|
GQL.Job,
|
||||||
@@ -153,6 +154,7 @@ const Task: React.FC<IJob> = ({ job }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const JobTable: React.FC = () => {
|
export const JobTable: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const jobStatus = useJobQueue();
|
const jobStatus = useJobQueue();
|
||||||
const jobsSubscribe = useJobsSubscribe();
|
const jobsSubscribe = useJobsSubscribe();
|
||||||
|
|
||||||
@@ -200,12 +202,17 @@ export const JobTable: React.FC = () => {
|
|||||||
}, [jobsSubscribe.data]);
|
}, [jobsSubscribe.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="job-table">
|
<Card className="job-table">
|
||||||
<ul>
|
<ul>
|
||||||
|
{!queue?.length ? (
|
||||||
|
<span className="empty-queue-message">
|
||||||
|
{intl.formatMessage({ id: "config.tasks.empty_queue" })}
|
||||||
|
</span>
|
||||||
|
) : undefined}
|
||||||
{(queue ?? []).map((j) => (
|
{(queue ?? []).map((j) => (
|
||||||
<Task job={j} key={j.id} />
|
<Task job={j} key={j.id} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import { DirectorySelectionDialog } from "./DirectorySelectionDialog";
|
|||||||
import { ScanOptions } from "./ScanOptions";
|
import { ScanOptions } from "./ScanOptions";
|
||||||
import { useToast } from "src/hooks";
|
import { useToast } from "src/hooks";
|
||||||
import { GenerateOptions } from "./GenerateOptions";
|
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 {
|
interface IAutoTagOptions {
|
||||||
options: GQL.AutoTagMetadataInput;
|
options: GQL.AutoTagMetadataInput;
|
||||||
@@ -26,13 +29,11 @@ const AutoTagOptions: React.FC<IAutoTagOptions> = ({
|
|||||||
options,
|
options,
|
||||||
setOptions: setOptionsState,
|
setOptions: setOptionsState,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const { performers, studios, tags } = options;
|
const { performers, studios, tags } = options;
|
||||||
const wildcard = ["*"];
|
const wildcard = ["*"];
|
||||||
|
|
||||||
function toggle(v?: GQL.Maybe<string[]>) {
|
function set(v?: boolean) {
|
||||||
if (!v?.length) {
|
if (v) {
|
||||||
return wildcard;
|
return wildcard;
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
@@ -43,26 +44,26 @@ const AutoTagOptions: React.FC<IAutoTagOptions> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Group>
|
<>
|
||||||
<Form.Check
|
<BooleanSetting
|
||||||
id="autotag-performers"
|
id="autotag-performers"
|
||||||
checked={!!performers?.length}
|
checked={!!performers?.length}
|
||||||
label={intl.formatMessage({ id: "performers" })}
|
headingID="performers"
|
||||||
onChange={() => setOptions({ performers: toggle(performers) })}
|
onChange={(v) => setOptions({ performers: set(v) })}
|
||||||
/>
|
/>
|
||||||
<Form.Check
|
<BooleanSetting
|
||||||
id="autotag-studios"
|
id="autotag-studios"
|
||||||
checked={!!studios?.length}
|
checked={!!studios?.length}
|
||||||
label={intl.formatMessage({ id: "studios" })}
|
headingID="studios"
|
||||||
onChange={() => setOptions({ studios: toggle(studios) })}
|
onChange={(v) => setOptions({ studios: set(v) })}
|
||||||
/>
|
/>
|
||||||
<Form.Check
|
<BooleanSetting
|
||||||
id="autotag-tags"
|
id="autotag-tags"
|
||||||
checked={!!tags?.length}
|
checked={!!tags?.length}
|
||||||
label={intl.formatMessage({ id: "tags" })}
|
headingID="tags"
|
||||||
onChange={() => setOptions({ tags: toggle(tags) })}
|
onChange={(v) => setOptions({ tags: set(v) })}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -279,17 +280,21 @@ export const LibraryTasks: React.FC = () => {
|
|||||||
{renderAutoTagDialog()}
|
{renderAutoTagDialog()}
|
||||||
{maybeRenderIdentifyDialog()}
|
{maybeRenderIdentifyDialog()}
|
||||||
|
|
||||||
<Form.Group>
|
<SettingSection headingID="library">
|
||||||
<h5>{intl.formatMessage({ id: "library" })}</h5>
|
<SettingGroup
|
||||||
|
settingProps={{
|
||||||
<div className="task-group">
|
heading: (
|
||||||
<Task
|
<>
|
||||||
headingID="actions.scan"
|
<FormattedMessage id="actions.scan" />
|
||||||
description={intl.formatMessage({
|
<ManualLink tab="Tasks">
|
||||||
id: "config.tasks.scan_for_content_desc",
|
<Icon icon="question-circle" />
|
||||||
})}
|
</ManualLink>
|
||||||
>
|
</>
|
||||||
<ScanOptions options={scanOptions} setOptions={setScanOptions} />
|
),
|
||||||
|
subHeadingID: "config.tasks.scan_for_content_desc",
|
||||||
|
}}
|
||||||
|
topLevel={
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -307,13 +312,25 @@ export const LibraryTasks: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<FormattedMessage id="actions.selective_scan" />…
|
<FormattedMessage id="actions.selective_scan" />…
|
||||||
</Button>
|
</Button>
|
||||||
</Task>
|
</>
|
||||||
|
}
|
||||||
|
collapsible
|
||||||
|
>
|
||||||
|
<ScanOptions options={scanOptions} setOptions={setScanOptions} />
|
||||||
|
</SettingGroup>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
<Task
|
<SettingSection>
|
||||||
headingID="config.tasks.identify.heading"
|
<Setting
|
||||||
description={intl.formatMessage({
|
heading={
|
||||||
id: "config.tasks.identify.description",
|
<>
|
||||||
})}
|
<FormattedMessage id="config.tasks.identify.heading" />
|
||||||
|
<ManualLink tab="Identify">
|
||||||
|
<Icon icon="question-circle" />
|
||||||
|
</ManualLink>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
subHeadingID="config.tasks.identify.description"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -322,19 +339,24 @@ export const LibraryTasks: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<FormattedMessage id="actions.identify" />…
|
<FormattedMessage id="actions.identify" />…
|
||||||
</Button>
|
</Button>
|
||||||
</Task>
|
</Setting>
|
||||||
|
</SettingSection>
|
||||||
<Task
|
|
||||||
headingID="config.tasks.auto_tagging"
|
|
||||||
description={intl.formatMessage({
|
|
||||||
id: "config.tasks.auto_tag_based_on_filenames",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AutoTagOptions
|
|
||||||
options={autoTagOptions}
|
|
||||||
setOptions={(o) => setAutoTagOptions(o)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
<SettingSection>
|
||||||
|
<SettingGroup
|
||||||
|
settingProps={{
|
||||||
|
heading: (
|
||||||
|
<>
|
||||||
|
<FormattedMessage id="actions.auto_tag" />
|
||||||
|
<ManualLink tab="AutoTagging">
|
||||||
|
<Icon icon="question-circle" />
|
||||||
|
</ManualLink>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
subHeadingID: "config.tasks.auto_tag_based_on_filenames",
|
||||||
|
}}
|
||||||
|
topLevel={
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -350,25 +372,31 @@ export const LibraryTasks: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<FormattedMessage id="actions.selective_auto_tag" />…
|
<FormattedMessage id="actions.selective_auto_tag" />…
|
||||||
</Button>
|
</Button>
|
||||||
</Task>
|
</>
|
||||||
</div>
|
}
|
||||||
</Form.Group>
|
collapsible
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<Form.Group>
|
|
||||||
<h5>{intl.formatMessage({ id: "config.tasks.generated_content" })}</h5>
|
|
||||||
|
|
||||||
<div className="task-group">
|
|
||||||
<Task
|
|
||||||
description={intl.formatMessage({
|
|
||||||
id: "config.tasks.generate_desc",
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<GenerateOptions
|
<AutoTagOptions
|
||||||
options={generateOptions}
|
options={autoTagOptions}
|
||||||
setOptions={setGenerateOptions}
|
setOptions={(o) => setAutoTagOptions(o)}
|
||||||
/>
|
/>
|
||||||
|
</SettingGroup>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<SettingSection headingID="config.tasks.generated_content">
|
||||||
|
<SettingGroup
|
||||||
|
settingProps={{
|
||||||
|
heading: (
|
||||||
|
<>
|
||||||
|
<FormattedMessage id="actions.generate" />
|
||||||
|
<ManualLink tab="Tasks">
|
||||||
|
<Icon icon="question-circle" />
|
||||||
|
</ManualLink>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
subHeadingID: "config.tasks.generate_desc",
|
||||||
|
}}
|
||||||
|
topLevel={
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -376,9 +404,15 @@ export const LibraryTasks: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<FormattedMessage id="actions.generate" />
|
<FormattedMessage id="actions.generate" />
|
||||||
</Button>
|
</Button>
|
||||||
</Task>
|
}
|
||||||
</div>
|
collapsible
|
||||||
</Form.Group>
|
>
|
||||||
|
<GenerateOptions
|
||||||
|
options={generateOptions}
|
||||||
|
setOptions={setGenerateOptions}
|
||||||
|
/>
|
||||||
|
</SettingGroup>
|
||||||
|
</SettingSection>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { Button, Form } from "react-bootstrap";
|
|||||||
import { mutateRunPluginTask, usePlugins } from "src/core/StashService";
|
import { mutateRunPluginTask, usePlugins } from "src/core/StashService";
|
||||||
import { useToast } from "src/hooks";
|
import { useToast } from "src/hooks";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { Task } from "./Task";
|
import { SettingSection } from "../SettingSection";
|
||||||
|
import { Setting, SettingGroup } from "../Inputs";
|
||||||
|
|
||||||
type Plugin = Pick<GQL.Plugin, "id">;
|
type Plugin = Pick<GQL.Plugin, "id">;
|
||||||
type PluginTask = Pick<GQL.PluginTask, "name" | "description">;
|
type PluginTask = Pick<GQL.PluginTask, "name" | "description">;
|
||||||
@@ -25,19 +26,21 @@ export const PluginTasks: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Group>
|
<SettingSection headingID="config.tasks.plugin_tasks">
|
||||||
<h5>{intl.formatMessage({ id: "config.tasks.plugin_tasks" })}</h5>
|
|
||||||
{taskPlugins.map((o) => {
|
{taskPlugins.map((o) => {
|
||||||
return (
|
return (
|
||||||
<Form.Group key={`${o.id}`}>
|
<SettingGroup
|
||||||
<h6>{o.name}</h6>
|
key={`${o.id}`}
|
||||||
<div className="task-group">
|
settingProps={{
|
||||||
|
heading: o.name,
|
||||||
|
}}
|
||||||
|
collapsible
|
||||||
|
>
|
||||||
{renderPluginTasks(o, o.tasks ?? [])}
|
{renderPluginTasks(o, o.tasks ?? [])}
|
||||||
</div>
|
</SettingGroup>
|
||||||
</Form.Group>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Form.Group>
|
</SettingSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +51,11 @@ export const PluginTasks: React.FC = () => {
|
|||||||
|
|
||||||
return pluginTasks.map((o) => {
|
return pluginTasks.map((o) => {
|
||||||
return (
|
return (
|
||||||
<Task description={o.description} key={o.name}>
|
<Setting
|
||||||
|
heading={o.name}
|
||||||
|
subHeading={o.description ?? undefined}
|
||||||
|
key={o.name}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onPluginTaskClicked(plugin, o)}
|
onClick={() => onPluginTaskClicked(plugin, o)}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -56,7 +63,7 @@ export const PluginTasks: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{o.name}
|
{o.name}
|
||||||
</Button>
|
</Button>
|
||||||
</Task>
|
</Setting>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Form } from "react-bootstrap";
|
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { useIntl } from "react-intl";
|
import { BooleanSetting } from "../Inputs";
|
||||||
|
|
||||||
interface IScanOptions {
|
interface IScanOptions {
|
||||||
options: GQL.ScanMetadataInput;
|
options: GQL.ScanMetadataInput;
|
||||||
@@ -12,8 +11,6 @@ export const ScanOptions: React.FC<IScanOptions> = ({
|
|||||||
options,
|
options,
|
||||||
setOptions: setOptionsState,
|
setOptions: setOptionsState,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
useFileMetadata,
|
useFileMetadata,
|
||||||
stripFileExtension,
|
stripFileExtension,
|
||||||
@@ -29,80 +26,55 @@ export const ScanOptions: React.FC<IScanOptions> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Group>
|
<>
|
||||||
<Form.Check
|
<BooleanSetting
|
||||||
id="scan-generate-previews"
|
id="scan-generate-previews"
|
||||||
|
headingID="config.tasks.generate_video_previews_during_scan"
|
||||||
|
tooltipID="config.tasks.generate_video_previews_during_scan_tooltip"
|
||||||
checked={scanGeneratePreviews ?? false}
|
checked={scanGeneratePreviews ?? false}
|
||||||
label={intl.formatMessage({
|
onChange={(v) => setOptions({ scanGeneratePreviews: v })}
|
||||||
id: "config.tasks.generate_video_previews_during_scan",
|
|
||||||
})}
|
|
||||||
onChange={() =>
|
|
||||||
setOptions({ scanGeneratePreviews: !scanGeneratePreviews })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<div className="d-flex flex-row">
|
<BooleanSetting
|
||||||
<div>↳</div>
|
|
||||||
<Form.Check
|
|
||||||
id="scan-generate-image-previews"
|
id="scan-generate-image-previews"
|
||||||
|
className="sub-setting"
|
||||||
|
headingID="config.tasks.generate_previews_during_scan"
|
||||||
|
tooltipID="config.tasks.generate_previews_during_scan_tooltip"
|
||||||
checked={scanGenerateImagePreviews ?? false}
|
checked={scanGenerateImagePreviews ?? false}
|
||||||
disabled={!scanGeneratePreviews}
|
disabled={!scanGeneratePreviews}
|
||||||
label={intl.formatMessage({
|
onChange={(v) => setOptions({ scanGenerateImagePreviews: v })}
|
||||||
id: "config.tasks.generate_previews_during_scan",
|
|
||||||
})}
|
|
||||||
onChange={() =>
|
|
||||||
setOptions({
|
|
||||||
scanGenerateImagePreviews: !scanGenerateImagePreviews,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="ml-2 flex-grow"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<Form.Check
|
<BooleanSetting
|
||||||
id="scan-generate-sprites"
|
id="scan-generate-sprites"
|
||||||
|
headingID="config.tasks.generate_sprites_during_scan"
|
||||||
checked={scanGenerateSprites ?? false}
|
checked={scanGenerateSprites ?? false}
|
||||||
label={intl.formatMessage({
|
onChange={(v) => setOptions({ scanGenerateSprites: v })}
|
||||||
id: "config.tasks.generate_sprites_during_scan",
|
|
||||||
})}
|
|
||||||
onChange={() =>
|
|
||||||
setOptions({ scanGenerateSprites: !scanGenerateSprites })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Form.Check
|
<BooleanSetting
|
||||||
id="scan-generate-phashes"
|
id="scan-generate-phashes"
|
||||||
checked={scanGeneratePhashes ?? false}
|
checked={scanGeneratePhashes ?? false}
|
||||||
label={intl.formatMessage({
|
headingID="config.tasks.generate_phashes_during_scan"
|
||||||
id: "config.tasks.generate_phashes_during_scan",
|
tooltipID="config.tasks.generate_phashes_during_scan_tooltip"
|
||||||
})}
|
onChange={(v) => setOptions({ scanGeneratePhashes: v })}
|
||||||
onChange={() =>
|
|
||||||
setOptions({ scanGeneratePhashes: !scanGeneratePhashes })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Form.Check
|
<BooleanSetting
|
||||||
id="scan-generate-thumbnails"
|
id="scan-generate-thumbnails"
|
||||||
checked={scanGenerateThumbnails ?? false}
|
checked={scanGenerateThumbnails ?? false}
|
||||||
label={intl.formatMessage({
|
headingID="config.tasks.generate_thumbnails_during_scan"
|
||||||
id: "config.tasks.generate_thumbnails_during_scan",
|
onChange={(v) => setOptions({ scanGenerateThumbnails: v })}
|
||||||
})}
|
|
||||||
onChange={() =>
|
|
||||||
setOptions({ scanGenerateThumbnails: !scanGenerateThumbnails })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Form.Check
|
<BooleanSetting
|
||||||
id="strip-file-extension"
|
id="strip-file-extension"
|
||||||
checked={stripFileExtension ?? false}
|
checked={stripFileExtension ?? false}
|
||||||
label={intl.formatMessage({
|
headingID="config.tasks.dont_include_file_extension_as_part_of_the_title"
|
||||||
id: "config.tasks.dont_include_file_extension_as_part_of_the_title",
|
onChange={(v) => setOptions({ stripFileExtension: v })}
|
||||||
})}
|
|
||||||
onChange={() => setOptions({ stripFileExtension: !stripFileExtension })}
|
|
||||||
/>
|
/>
|
||||||
<Form.Check
|
<BooleanSetting
|
||||||
id="use-file-metadata"
|
id="use-file-metadata"
|
||||||
checked={useFileMetadata ?? false}
|
checked={useFileMetadata ?? false}
|
||||||
label={intl.formatMessage({
|
headingID="config.tasks.set_name_date_details_from_metadata_if_present"
|
||||||
id: "config.tasks.set_name_date_details_from_metadata_if_present",
|
onChange={(v) => setOptions({ useFileMetadata: v })}
|
||||||
})}
|
|
||||||
onChange={() => setOptions({ useFileMetadata: !useFileMetadata })}
|
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,18 +19,19 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div id="tasks-panel">
|
||||||
<h4>{intl.formatMessage({ id: "config.tasks.job_queue" })}</h4>
|
<div className="tasks-panel-queue">
|
||||||
|
<h1>{intl.formatMessage({ id: "config.tasks.job_queue" })}</h1>
|
||||||
<JobTable />
|
<JobTable />
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr />
|
<div className="tasks-panel-tasks">
|
||||||
|
|
||||||
<LibraryTasks />
|
<LibraryTasks />
|
||||||
<hr />
|
<hr />
|
||||||
<DataManagementTasks setIsBackupRunning={setIsBackupRunning} />
|
<DataManagementTasks setIsBackupRunning={setIsBackupRunning} />
|
||||||
<hr />
|
<hr />
|
||||||
<PluginTasks />
|
<PluginTasks />
|
||||||
</>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import React, { PropsWithChildren } from "react";
|
|
||||||
import { Form } from "react-bootstrap";
|
|
||||||
import { FormattedMessage } from "react-intl";
|
|
||||||
|
|
||||||
interface ITask {
|
|
||||||
headingID?: string;
|
|
||||||
description?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Task: React.FC<PropsWithChildren<ITask>> = ({
|
|
||||||
children,
|
|
||||||
headingID,
|
|
||||||
description,
|
|
||||||
}) => (
|
|
||||||
<div className="task">
|
|
||||||
{headingID ? (
|
|
||||||
<h6>
|
|
||||||
<FormattedMessage id={headingID} />
|
|
||||||
</h6>
|
|
||||||
) : undefined}
|
|
||||||
{children}
|
|
||||||
{description ? (
|
|
||||||
<Form.Text className="text-muted">{description}</Form.Text>
|
|
||||||
) : undefined}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
419
ui/v2.5/src/components/Settings/context.tsx
Normal file
419
ui/v2.5/src/components/Settings/context.tsx
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import { ApolloError } from "@apollo/client/errors";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import React, {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import { Spinner } from "react-bootstrap";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import {
|
||||||
|
useConfiguration,
|
||||||
|
useConfigureDefaults,
|
||||||
|
useConfigureDLNA,
|
||||||
|
useConfigureGeneral,
|
||||||
|
useConfigureInterface,
|
||||||
|
useConfigureScraping,
|
||||||
|
} from "src/core/StashService";
|
||||||
|
import { useToast } from "src/hooks";
|
||||||
|
import { withoutTypename } from "src/utils";
|
||||||
|
import { Icon } from "../Shared";
|
||||||
|
|
||||||
|
export interface ISettingsContextState {
|
||||||
|
loading: boolean;
|
||||||
|
error: ApolloError | undefined;
|
||||||
|
general: GQL.ConfigGeneralInput;
|
||||||
|
interface: GQL.ConfigInterfaceInput;
|
||||||
|
defaults: GQL.ConfigDefaultSettingsInput;
|
||||||
|
scraping: GQL.ConfigScrapingInput;
|
||||||
|
dlna: GQL.ConfigDlnaInput;
|
||||||
|
|
||||||
|
// apikey isn't directly settable, so expose it here
|
||||||
|
apiKey: string;
|
||||||
|
|
||||||
|
saveGeneral: (input: Partial<GQL.ConfigGeneralInput>) => void;
|
||||||
|
saveInterface: (input: Partial<GQL.ConfigInterfaceInput>) => void;
|
||||||
|
saveDefaults: (input: Partial<GQL.ConfigDefaultSettingsInput>) => void;
|
||||||
|
saveScraping: (input: Partial<GQL.ConfigScrapingInput>) => void;
|
||||||
|
saveDLNA: (input: Partial<GQL.ConfigDlnaInput>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingStateContext = React.createContext<ISettingsContextState>({
|
||||||
|
loading: false,
|
||||||
|
error: undefined,
|
||||||
|
general: {},
|
||||||
|
interface: {},
|
||||||
|
defaults: {},
|
||||||
|
scraping: {},
|
||||||
|
dlna: {},
|
||||||
|
apiKey: "",
|
||||||
|
saveGeneral: () => {},
|
||||||
|
saveInterface: () => {},
|
||||||
|
saveDefaults: () => {},
|
||||||
|
saveScraping: () => {},
|
||||||
|
saveDLNA: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SettingsContext: React.FC = ({ children }) => {
|
||||||
|
const Toast = useToast();
|
||||||
|
|
||||||
|
const { data, error, loading } = useConfiguration();
|
||||||
|
const initialRef = useRef(false);
|
||||||
|
|
||||||
|
const [general, setGeneral] = useState<GQL.ConfigGeneralInput>({});
|
||||||
|
const [pendingGeneral, setPendingGeneral] = useState<
|
||||||
|
GQL.ConfigGeneralInput | undefined
|
||||||
|
>();
|
||||||
|
const [updateGeneralConfig] = useConfigureGeneral();
|
||||||
|
|
||||||
|
const [iface, setIface] = useState<GQL.ConfigInterfaceInput>({});
|
||||||
|
const [pendingInterface, setPendingInterface] = useState<
|
||||||
|
GQL.ConfigInterfaceInput | undefined
|
||||||
|
>();
|
||||||
|
const [updateInterfaceConfig] = useConfigureInterface();
|
||||||
|
|
||||||
|
const [defaults, setDefaults] = useState<GQL.ConfigDefaultSettingsInput>({});
|
||||||
|
const [pendingDefaults, setPendingDefaults] = useState<
|
||||||
|
GQL.ConfigDefaultSettingsInput | undefined
|
||||||
|
>();
|
||||||
|
const [updateDefaultsConfig] = useConfigureDefaults();
|
||||||
|
|
||||||
|
const [scraping, setScraping] = useState<GQL.ConfigScrapingInput>({});
|
||||||
|
const [pendingScraping, setPendingScraping] = useState<
|
||||||
|
GQL.ConfigScrapingInput | undefined
|
||||||
|
>();
|
||||||
|
const [updateScrapingConfig] = useConfigureScraping();
|
||||||
|
|
||||||
|
const [dlna, setDLNA] = useState<GQL.ConfigDlnaInput>({});
|
||||||
|
const [pendingDLNA, setPendingDLNA] = useState<
|
||||||
|
GQL.ConfigDlnaInput | undefined
|
||||||
|
>();
|
||||||
|
const [updateDLNAConfig] = useConfigureDLNA();
|
||||||
|
|
||||||
|
const [updateSuccess, setUpdateSuccess] = useState(false);
|
||||||
|
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// only initialise once - assume we have control over these settings and
|
||||||
|
// they aren't modified elsewhere
|
||||||
|
if (!data?.configuration || error || initialRef.current) return;
|
||||||
|
initialRef.current = true;
|
||||||
|
|
||||||
|
setGeneral({ ...withoutTypename(data.configuration.general) });
|
||||||
|
setIface({ ...withoutTypename(data.configuration.interface) });
|
||||||
|
setDefaults({ ...withoutTypename(data.configuration.defaults) });
|
||||||
|
setScraping({ ...withoutTypename(data.configuration.scraping) });
|
||||||
|
setDLNA({ ...withoutTypename(data.configuration.dlna) });
|
||||||
|
setApiKey(data.configuration.general.apiKey);
|
||||||
|
}, [data, error]);
|
||||||
|
|
||||||
|
const resetSuccess = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce(() => {
|
||||||
|
setUpdateSuccess(false);
|
||||||
|
}, 4000),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSuccess = useCallback(() => {
|
||||||
|
setUpdateSuccess(true);
|
||||||
|
resetSuccess();
|
||||||
|
}, [resetSuccess]);
|
||||||
|
|
||||||
|
// saves the configuration if no further changes are made after a half second
|
||||||
|
const saveGeneralConfig = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce(async (input: GQL.ConfigGeneralInput) => {
|
||||||
|
try {
|
||||||
|
await updateGeneralConfig({
|
||||||
|
variables: {
|
||||||
|
input,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingGeneral(undefined);
|
||||||
|
onSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}, 500),
|
||||||
|
[Toast, updateGeneralConfig, onSuccess]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingGeneral) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveGeneralConfig(pendingGeneral);
|
||||||
|
}, [pendingGeneral, saveGeneralConfig]);
|
||||||
|
|
||||||
|
function saveGeneral(input: Partial<GQL.ConfigGeneralInput>) {
|
||||||
|
if (!general) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGeneral({
|
||||||
|
...general,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingGeneral((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// saves the configuration if no further changes are made after a half second
|
||||||
|
const saveInterfaceConfig = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce(async (input: GQL.ConfigInterfaceInput) => {
|
||||||
|
try {
|
||||||
|
await updateInterfaceConfig({
|
||||||
|
variables: {
|
||||||
|
input,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingInterface(undefined);
|
||||||
|
onSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}, 500),
|
||||||
|
[Toast, updateInterfaceConfig, onSuccess]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingInterface) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveInterfaceConfig(pendingInterface);
|
||||||
|
}, [pendingInterface, saveInterfaceConfig]);
|
||||||
|
|
||||||
|
function saveInterface(input: Partial<GQL.ConfigInterfaceInput>) {
|
||||||
|
if (!iface) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIface({
|
||||||
|
...iface,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingInterface((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// saves the configuration if no further changes are made after a half second
|
||||||
|
const saveDefaultsConfig = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce(async (input: GQL.ConfigDefaultSettingsInput) => {
|
||||||
|
try {
|
||||||
|
await updateDefaultsConfig({
|
||||||
|
variables: {
|
||||||
|
input,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingDefaults(undefined);
|
||||||
|
onSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}, 500),
|
||||||
|
[Toast, updateDefaultsConfig, onSuccess]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingDefaults) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDefaultsConfig(pendingDefaults);
|
||||||
|
}, [pendingDefaults, saveDefaultsConfig]);
|
||||||
|
|
||||||
|
function saveDefaults(input: Partial<GQL.ConfigDefaultSettingsInput>) {
|
||||||
|
if (!defaults) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDefaults({
|
||||||
|
...defaults,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingDefaults((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// saves the configuration if no further changes are made after a half second
|
||||||
|
const saveScrapingConfig = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce(async (input: GQL.ConfigScrapingInput) => {
|
||||||
|
try {
|
||||||
|
await updateScrapingConfig({
|
||||||
|
variables: {
|
||||||
|
input,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingScraping(undefined);
|
||||||
|
onSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}, 500),
|
||||||
|
[Toast, updateScrapingConfig, onSuccess]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingScraping) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveScrapingConfig(pendingScraping);
|
||||||
|
}, [pendingScraping, saveScrapingConfig]);
|
||||||
|
|
||||||
|
function saveScraping(input: Partial<GQL.ConfigScrapingInput>) {
|
||||||
|
if (!scraping) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setScraping({
|
||||||
|
...scraping,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingScraping((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// saves the configuration if no further changes are made after a half second
|
||||||
|
const saveDLNAConfig = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce(async (input: GQL.ConfigDlnaInput) => {
|
||||||
|
try {
|
||||||
|
await updateDLNAConfig({
|
||||||
|
variables: {
|
||||||
|
input,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingDLNA(undefined);
|
||||||
|
onSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}, 500),
|
||||||
|
[Toast, updateDLNAConfig, onSuccess]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingDLNA) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDLNAConfig(pendingDLNA);
|
||||||
|
}, [pendingDLNA, saveDLNAConfig]);
|
||||||
|
|
||||||
|
function saveDLNA(input: Partial<GQL.ConfigDlnaInput>) {
|
||||||
|
if (!dlna) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDLNA({
|
||||||
|
...dlna,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingDLNA((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderLoadingIndicator() {
|
||||||
|
if (
|
||||||
|
pendingGeneral ||
|
||||||
|
pendingInterface ||
|
||||||
|
pendingDefaults ||
|
||||||
|
pendingScraping ||
|
||||||
|
pendingDLNA
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="loading-indicator">
|
||||||
|
<Spinner animation="border" role="status">
|
||||||
|
<span className="sr-only">Loading...</span>
|
||||||
|
</Spinner>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateSuccess) {
|
||||||
|
return (
|
||||||
|
<div className="loading-indicator">
|
||||||
|
<Icon icon="check-circle" className="fa-fw" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingStateContext.Provider
|
||||||
|
value={{
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
apiKey,
|
||||||
|
general,
|
||||||
|
interface: iface,
|
||||||
|
defaults,
|
||||||
|
scraping,
|
||||||
|
dlna,
|
||||||
|
saveGeneral,
|
||||||
|
saveInterface,
|
||||||
|
saveDefaults,
|
||||||
|
saveScraping,
|
||||||
|
saveDLNA,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{maybeRenderLoadingIndicator()}
|
||||||
|
{children}
|
||||||
|
</SettingStateContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 {
|
.logs {
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||||
monospace;
|
monospace;
|
||||||
@@ -71,9 +255,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-table {
|
.job-table.card {
|
||||||
|
background-color: $card-bg;
|
||||||
height: 10em;
|
height: 10em;
|
||||||
|
margin-bottom: 30px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem 15px;
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -570,7 +570,7 @@ export const Setup: React.FC = () => {
|
|||||||
</section>
|
</section>
|
||||||
<section className="mt-5">
|
<section className="mt-5">
|
||||||
<div className="d-flex justify-content-center">
|
<div className="d-flex justify-content-center">
|
||||||
<Link to="/settings?tab=configuration">
|
<Link to="/settings?tab=library">
|
||||||
<Button variant="success mx-2 p-5" onClick={() => goBack(2)}>
|
<Button variant="success mx-2 p-5" onClick={() => goBack(2)}>
|
||||||
<FormattedMessage id="actions.finish" />
|
<FormattedMessage id="actions.finish" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ import { Button, Modal } from "react-bootstrap";
|
|||||||
import { FolderSelect } from "./FolderSelect";
|
import { FolderSelect } from "./FolderSelect";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
|
defaultValue?: string;
|
||||||
onClose: (directory?: string) => void;
|
onClose: (directory?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FolderSelectDialog: React.FC<IProps> = (props: IProps) => {
|
export const FolderSelectDialog: React.FC<IProps> = ({
|
||||||
const [currentDirectory, setCurrentDirectory] = useState<string>("");
|
defaultValue: currentValue,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [currentDirectory, setCurrentDirectory] = useState<string>(
|
||||||
|
currentValue ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal show onHide={() => props.onClose()} title="">
|
<Modal show onHide={() => onClose()} title="">
|
||||||
<Modal.Header>Select Directory</Modal.Header>
|
<Modal.Header>Select Directory</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<div className="dialog-content">
|
<div className="dialog-content">
|
||||||
@@ -22,11 +28,11 @@ export const FolderSelectDialog: React.FC<IProps> = (props: IProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button
|
<Button variant="secondary" onClick={() => onClose()}>
|
||||||
variant="success"
|
<FormattedMessage id="actions.cancel" />
|
||||||
onClick={() => props.onClose(currentDirectory)}
|
</Button>
|
||||||
>
|
<Button variant="success" onClick={() => onClose(currentDirectory)}>
|
||||||
<FormattedMessage id="actions.add" />
|
<FormattedMessage id="actions.confirm" />
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ export const PerformerSelect: React.FC<IFilterProps> = (props) => {
|
|||||||
|
|
||||||
const { configuration } = React.useContext(ConfigurationContext);
|
const { configuration } = React.useContext(ConfigurationContext);
|
||||||
const defaultCreatable =
|
const defaultCreatable =
|
||||||
!configuration?.interface.disabledDropdownCreate.performer ?? true;
|
!configuration?.interface.disableDropdownCreate.performer ?? true;
|
||||||
|
|
||||||
const performers = data?.allPerformers ?? [];
|
const performers = data?.allPerformers ?? [];
|
||||||
|
|
||||||
@@ -443,7 +443,7 @@ export const StudioSelect: React.FC<
|
|||||||
|
|
||||||
const { configuration } = React.useContext(ConfigurationContext);
|
const { configuration } = React.useContext(ConfigurationContext);
|
||||||
const defaultCreatable =
|
const defaultCreatable =
|
||||||
!configuration?.interface.disabledDropdownCreate.studio ?? true;
|
!configuration?.interface.disableDropdownCreate.studio ?? true;
|
||||||
|
|
||||||
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
|
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
|
||||||
const studios = useMemo(
|
const studios = useMemo(
|
||||||
@@ -584,7 +584,7 @@ export const TagSelect: React.FC<IFilterProps & { excludeIds?: string[] }> = (
|
|||||||
|
|
||||||
const { configuration } = React.useContext(ConfigurationContext);
|
const { configuration } = React.useContext(ConfigurationContext);
|
||||||
const defaultCreatable =
|
const defaultCreatable =
|
||||||
!configuration?.interface.disabledDropdownCreate.tag ?? true;
|
!configuration?.interface.disableDropdownCreate.tag ?? true;
|
||||||
|
|
||||||
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
|
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
|
||||||
const tags = useMemo(
|
const tags = useMemo(
|
||||||
|
|||||||
@@ -640,7 +640,7 @@ export const PerformerTagger: React.FC<ITaggerProps> = ({ performers }) => {
|
|||||||
<h5 className="text-center">
|
<h5 className="text-center">
|
||||||
Please see{" "}
|
Please see{" "}
|
||||||
<HashLink
|
<HashLink
|
||||||
to="/settings?tab=configuration#stashbox"
|
to="/settings?tab=metadata-providers#stash-boxes"
|
||||||
scroll={(el) =>
|
scroll={(el) =>
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -737,16 +737,14 @@ export const useTagsMerge = () =>
|
|||||||
update: deleteCache(tagMutationImpactedQueries),
|
update: deleteCache(tagMutationImpactedQueries),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useConfigureGeneral = (input: GQL.ConfigGeneralInput) =>
|
export const useConfigureGeneral = () =>
|
||||||
GQL.useConfigureGeneralMutation({
|
GQL.useConfigureGeneralMutation({
|
||||||
variables: { input },
|
|
||||||
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
|
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
|
||||||
update: deleteCache([GQL.ConfigurationDocument]),
|
update: deleteCache([GQL.ConfigurationDocument]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useConfigureInterface = (input: GQL.ConfigInterfaceInput) =>
|
export const useConfigureInterface = () =>
|
||||||
GQL.useConfigureInterfaceMutation({
|
GQL.useConfigureInterfaceMutation({
|
||||||
variables: { input },
|
|
||||||
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
|
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
|
||||||
update: deleteCache([GQL.ConfigurationDocument]),
|
update: deleteCache([GQL.ConfigurationDocument]),
|
||||||
});
|
});
|
||||||
@@ -781,9 +779,8 @@ export const useRemoveTempDLNAIP = () => GQL.useRemoveTempDlnaipMutation();
|
|||||||
|
|
||||||
export const useLoggingSubscribe = () => GQL.useLoggingSubscribeSubscription();
|
export const useLoggingSubscribe = () => GQL.useLoggingSubscribeSubscription();
|
||||||
|
|
||||||
export const useConfigureScraping = (input: GQL.ConfigScrapingInput) =>
|
export const useConfigureScraping = () =>
|
||||||
GQL.useConfigureScrapingMutation({
|
GQL.useConfigureScrapingMutation({
|
||||||
variables: { input },
|
|
||||||
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
|
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
|
||||||
update: deleteCache([GQL.ConfigurationDocument]),
|
update: deleteCache([GQL.ConfigurationDocument]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
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
|
# Auto Tagging
|
||||||
See the [Auto Tagging](/help/AutoTagging.md) page.
|
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
|
* Transcoded versions of scenes. See below
|
||||||
* Image thumbnails of galleries
|
* 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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -159,6 +159,7 @@
|
|||||||
"build_hash": "Build hash:",
|
"build_hash": "Build hash:",
|
||||||
"build_time": "Build time:",
|
"build_time": "Build time:",
|
||||||
"check_for_new_version": "Check for new version",
|
"check_for_new_version": "Check for new version",
|
||||||
|
"latest_version": "Latest Version",
|
||||||
"latest_version_build_hash": "Latest Version Build Hash:",
|
"latest_version_build_hash": "Latest Version Build Hash:",
|
||||||
"new_version_notice": "[NEW]",
|
"new_version_notice": "[NEW]",
|
||||||
"stash_discord": "Join our {url} channel",
|
"stash_discord": "Join our {url} channel",
|
||||||
@@ -167,12 +168,19 @@
|
|||||||
"stash_wiki": "Stash {url} page",
|
"stash_wiki": "Stash {url} page",
|
||||||
"version": "Version"
|
"version": "Version"
|
||||||
},
|
},
|
||||||
|
"application_paths": {
|
||||||
|
"heading": "Application Paths"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"interface": "Interface",
|
"interface": "Interface",
|
||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
|
"metadata_providers": "Metadata Providers",
|
||||||
"plugins": "Plugins",
|
"plugins": "Plugins",
|
||||||
"scraping": "Scraping",
|
"scraping": "Scraping",
|
||||||
|
"security": "Security",
|
||||||
|
"services": "Services",
|
||||||
|
"system": "System",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks",
|
||||||
"tools": "Tools"
|
"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.",
|
"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",
|
"authentication": "Authentication",
|
||||||
"clear_api_key": "Clear API key",
|
"clear_api_key": "Clear API key",
|
||||||
|
"credentials": {
|
||||||
|
"description": "Credentials to restrict access to stash.",
|
||||||
|
"heading": "Credentials"
|
||||||
|
},
|
||||||
"generate_api_key": "Generate API key",
|
"generate_api_key": "Generate API key",
|
||||||
"log_file": "Log file",
|
"log_file": "Log file",
|
||||||
"log_file_desc": "Path to the file to output logging to. Blank to disable file logging. Requires restart.",
|
"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",
|
"create_galleries_from_folders_label": "Create galleries from folders containing images",
|
||||||
"db_path_head": "Database Path",
|
"db_path_head": "Database Path",
|
||||||
"directory_locations_to_your_content": "Directory locations to your content",
|
"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_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_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",
|
"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_ext_head": "Video Extensions",
|
||||||
"video_head": "Video"
|
"video_head": "Video"
|
||||||
},
|
},
|
||||||
|
"library": {
|
||||||
|
"exclusions": "Exclusions",
|
||||||
|
"gallery_and_image_options": "Gallery and Image options",
|
||||||
|
"media_content_extensions": "Media content extensions"
|
||||||
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"log_level": "Log Level"
|
"log_level": "Log Level"
|
||||||
},
|
},
|
||||||
@@ -289,6 +304,9 @@
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"title": "Stash-box Endpoints"
|
"title": "Stash-box Endpoints"
|
||||||
},
|
},
|
||||||
|
"system": {
|
||||||
|
"transcoding": "Transcoding"
|
||||||
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"added_job_to_queue": "Added {operation_name} to job queue",
|
"added_job_to_queue": "Added {operation_name} to job queue",
|
||||||
"auto_tag": {
|
"auto_tag": {
|
||||||
@@ -304,17 +322,21 @@
|
|||||||
"data_management": "Data management",
|
"data_management": "Data management",
|
||||||
"defaults_set": "Defaults have been set and will be used when clicking the {action} button on the Tasks page.",
|
"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",
|
"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.",
|
"export_to_json": "Exports the database content into JSON format in the metadata directory.",
|
||||||
"generate": {
|
"generate": {
|
||||||
"generating_scenes": "Generating for {num} {scene}",
|
"generating_scenes": "Generating for {num} {scene}",
|
||||||
"generating_from_paths": "Generating for scenes from the following paths"
|
"generating_from_paths": "Generating for scenes from the following paths"
|
||||||
},
|
},
|
||||||
"generate_desc": "Generate supporting image, sprite, video, vtt and other files.",
|
"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_phashes_during_scan": "Generate perceptual hashes",
|
||||||
"generate_previews_during_scan": "Generate image previews during scan (animated WebP previews, only required if Preview Type is set to Animated Image)",
|
"generate_phashes_during_scan_tooltip": "For deduplication and scene identification.",
|
||||||
"generate_sprites_during_scan": "Generate sprites during scan (for the scene scrubber)",
|
"generate_previews_during_scan": "Generate animated image previews",
|
||||||
"generate_thumbnails_during_scan": "Generate thumbnails for images during scan.",
|
"generate_previews_during_scan_tooltip": "Generate animated WebP previews, only required if Preview Type is set to Animated Image.",
|
||||||
"generate_video_previews_during_scan": "Generate previews during scan (video previews which play when hovering over a scene)",
|
"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",
|
"generated_content": "Generated Content",
|
||||||
"identify": {
|
"identify": {
|
||||||
"and_create_missing": "and create missing",
|
"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.",
|
"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.",
|
"incremental_import": "Incremental import from a supplied export zip file.",
|
||||||
"job_queue": "Job Queue",
|
"job_queue": "Task Queue",
|
||||||
"maintenance": "Maintenance",
|
"maintenance": "Maintenance",
|
||||||
"migrate_hash_files": "Used after changing the Generated file naming hash to rename existing generated files to the new hash format.",
|
"migrate_hash_files": "Used after changing the Generated file naming hash to rename existing generated files to the new hash format.",
|
||||||
"migrations": "Migrations",
|
"migrations": "Migrations",
|
||||||
@@ -349,7 +371,7 @@
|
|||||||
"scanning_all_paths": "Scanning all paths"
|
"scanning_all_paths": "Scanning all paths"
|
||||||
},
|
},
|
||||||
"scan_for_content_desc": "Scan for new content and add it to the database.",
|
"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": {
|
"tools": {
|
||||||
"scene_duplicate_checker": "Scene Duplicate Checker",
|
"scene_duplicate_checker": "Scene Duplicate Checker",
|
||||||
@@ -371,6 +393,7 @@
|
|||||||
"scene_tools": "Scene Tools"
|
"scene_tools": "Scene Tools"
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
|
"basic_settings": "Basic Settings",
|
||||||
"custom_css": {
|
"custom_css": {
|
||||||
"description": "Page must be reloaded for changes to take effect.",
|
"description": "Page must be reloaded for changes to take effect.",
|
||||||
"heading": "Custom CSS",
|
"heading": "Custom CSS",
|
||||||
@@ -405,6 +428,7 @@
|
|||||||
"heading": "Handy Connection Key"
|
"heading": "Handy Connection Key"
|
||||||
},
|
},
|
||||||
"images": {
|
"images": {
|
||||||
|
"heading": "Images",
|
||||||
"options": {
|
"options": {
|
||||||
"write_image_thumbnails": {
|
"write_image_thumbnails": {
|
||||||
"description": "Write image thumbnails to disk when generated on-the-fly",
|
"description": "Write image thumbnails to disk when generated on-the-fly",
|
||||||
@@ -412,6 +436,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"interactive_options": "Interactive Options",
|
||||||
"language": {
|
"language": {
|
||||||
"heading": "Language"
|
"heading": "Language"
|
||||||
},
|
},
|
||||||
@@ -559,17 +584,22 @@
|
|||||||
},
|
},
|
||||||
"overwrite_filter_confirm": "Are you sure you want to overwrite existing saved query {entityName}?",
|
"overwrite_filter_confirm": "Are you sure you want to overwrite existing saved query {entityName}?",
|
||||||
"scene_gen": {
|
"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",
|
"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_image_previews": "Marker Animated Image Previews",
|
||||||
"marker_screenshots": "Marker Screenshots (static JPG image, only required if Preview Type is set to Static Image)",
|
"marker_image_previews_tooltip": "Animated marker WebP previews, only required if Preview Type is set to Animated Image.",
|
||||||
"markers": "Markers (20 second videos which begin at the given timecode)",
|
"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",
|
"overwrite": "Overwrite existing generated files",
|
||||||
"phash": "Perceptual hashes (for deduplication)",
|
"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_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_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_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_exclude_start_time_head": "Exclude start time",
|
||||||
|
"preview_generation_options": "Preview Generation Options",
|
||||||
"preview_options": "Preview 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_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",
|
"preview_preset_head": "Preview encoding preset",
|
||||||
@@ -577,9 +607,12 @@
|
|||||||
"preview_seg_count_head": "Number of segments in preview",
|
"preview_seg_count_head": "Number of segments in preview",
|
||||||
"preview_seg_duration_desc": "Duration of each preview segment, in seconds.",
|
"preview_seg_duration_desc": "Duration of each preview segment, in seconds.",
|
||||||
"preview_seg_duration_head": "Preview segment duration",
|
"preview_seg_duration_head": "Preview segment duration",
|
||||||
"sprites": "Sprites (for the scene scrubber)",
|
"sprites": "Scene Scrubber Sprites",
|
||||||
"transcodes": "Transcodes (MP4 conversions of unsupported video formats)",
|
"sprites_tooltip": "Sprites (for the scene scrubber)",
|
||||||
"video_previews": "Previews (video previews which play when hovering over a scene)"
|
"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",
|
"scenes_found": "{count} scenes found",
|
||||||
"scrape_entity_query": "{entity_type} Scrape Query",
|
"scrape_entity_query": "{entity_type} Scrape Query",
|
||||||
@@ -853,6 +886,7 @@
|
|||||||
"up-dir": "Up a directory",
|
"up-dir": "Up a directory",
|
||||||
"updated_at": "Updated At",
|
"updated_at": "Updated At",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
|
"videos": "Videos",
|
||||||
"weight": "Weight",
|
"weight": "Weight",
|
||||||
"years_old": "years old"
|
"years_old": "years old"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user