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
|
||||
language
|
||||
slideshowDelay
|
||||
disabledDropdownCreate {
|
||||
disableDropdownCreate {
|
||||
performer
|
||||
tag
|
||||
studio
|
||||
|
||||
@@ -44,13 +44,13 @@ input ConfigGeneralInput {
|
||||
"""Path to cache"""
|
||||
cachePath: String
|
||||
"""Whether to calculate MD5 checksums for scene video files"""
|
||||
calculateMD5: Boolean!
|
||||
calculateMD5: Boolean
|
||||
"""Hash algorithm to use for generated file naming"""
|
||||
videoFileNamingAlgorithm: HashAlgorithm!
|
||||
videoFileNamingAlgorithm: HashAlgorithm
|
||||
"""Number of parallel tasks to start during scan/generate"""
|
||||
parallelTasks: Int
|
||||
"""Include audio stream in previews"""
|
||||
previewAudio: Boolean!
|
||||
previewAudio: Boolean
|
||||
"""Number of segments in a preview file"""
|
||||
previewSegments: Int
|
||||
"""Preview segment duration, in seconds"""
|
||||
@@ -78,13 +78,13 @@ input ConfigGeneralInput {
|
||||
"""Name of the log file"""
|
||||
logFile: String
|
||||
"""Whether to also output to stderr"""
|
||||
logOut: Boolean!
|
||||
logOut: Boolean
|
||||
"""Minimum log level"""
|
||||
logLevel: String!
|
||||
logLevel: String
|
||||
"""Whether to log http access"""
|
||||
logAccess: Boolean!
|
||||
logAccess: Boolean
|
||||
"""True if galleries should be created from folders with images"""
|
||||
createGalleriesFromFolders: Boolean!
|
||||
createGalleriesFromFolders: Boolean
|
||||
"""Array of video file extensions"""
|
||||
videoExtensions: [String!]
|
||||
"""Array of image file extensions"""
|
||||
@@ -104,7 +104,7 @@ input ConfigGeneralInput {
|
||||
"""Whether the scraper should check for invalid certificates"""
|
||||
scraperCertCheck: Boolean @deprecated(reason: "use mutation ConfigureScraping(input: ConfigScrapingInput) instead")
|
||||
"""Stash-box instances used for tagging"""
|
||||
stashBoxes: [StashBoxInput!]!
|
||||
stashBoxes: [StashBoxInput!]
|
||||
}
|
||||
|
||||
type ConfigGeneralResult {
|
||||
@@ -282,7 +282,8 @@ type ConfigInterfaceResult {
|
||||
slideshowDelay: Int
|
||||
|
||||
"""Fields are true if creating via dropdown menus are disabled"""
|
||||
disabledDropdownCreate: ConfigDisableDropdownCreate!
|
||||
disableDropdownCreate: ConfigDisableDropdownCreate!
|
||||
disabledDropdownCreate: ConfigDisableDropdownCreate! @deprecated(reason: "Use disableDropdownCreate")
|
||||
|
||||
"""Handy Connection Key"""
|
||||
handyKey: String
|
||||
@@ -316,7 +317,7 @@ input ConfigScrapingInput {
|
||||
"""Scraper CDP path. Path to chrome executable or remote address"""
|
||||
scraperCDPPath: String
|
||||
"""Whether the scraper should check for invalid certificates"""
|
||||
scraperCertCheck: Boolean!
|
||||
scraperCertCheck: Boolean
|
||||
"""Tags blacklist during scraping"""
|
||||
excludeTagPatterns: [String!]
|
||||
}
|
||||
|
||||
@@ -110,26 +110,34 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
|
||||
c.Set(config.Cache, input.CachePath)
|
||||
}
|
||||
|
||||
if !input.CalculateMd5 && input.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {
|
||||
return makeConfigGeneralResult(), errors.New("calculateMD5 must be true if using MD5")
|
||||
}
|
||||
if input.VideoFileNamingAlgorithm != nil && *input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() {
|
||||
calculateMD5 := c.IsCalculateMD5()
|
||||
if input.CalculateMd5 != nil {
|
||||
calculateMD5 = *input.CalculateMd5
|
||||
}
|
||||
if !calculateMD5 && *input.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {
|
||||
return makeConfigGeneralResult(), errors.New("calculateMD5 must be true if using MD5")
|
||||
}
|
||||
|
||||
if input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() {
|
||||
// validate changing VideoFileNamingAlgorithm
|
||||
if err := manager.ValidateVideoFileNamingAlgorithm(r.txnManager, input.VideoFileNamingAlgorithm); err != nil {
|
||||
if err := manager.ValidateVideoFileNamingAlgorithm(r.txnManager, *input.VideoFileNamingAlgorithm); err != nil {
|
||||
return makeConfigGeneralResult(), err
|
||||
}
|
||||
|
||||
c.Set(config.VideoFileNamingAlgorithm, input.VideoFileNamingAlgorithm)
|
||||
c.Set(config.VideoFileNamingAlgorithm, *input.VideoFileNamingAlgorithm)
|
||||
}
|
||||
|
||||
c.Set(config.CalculateMD5, input.CalculateMd5)
|
||||
if input.CalculateMd5 != nil {
|
||||
c.Set(config.CalculateMD5, *input.CalculateMd5)
|
||||
}
|
||||
|
||||
if input.ParallelTasks != nil {
|
||||
c.Set(config.ParallelTasks, *input.ParallelTasks)
|
||||
}
|
||||
|
||||
c.Set(config.PreviewAudio, input.PreviewAudio)
|
||||
if input.PreviewAudio != nil {
|
||||
c.Set(config.PreviewAudio, *input.PreviewAudio)
|
||||
}
|
||||
|
||||
if input.PreviewSegments != nil {
|
||||
c.Set(config.PreviewSegments, *input.PreviewSegments)
|
||||
@@ -185,12 +193,17 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
|
||||
c.Set(config.LogFile, input.LogFile)
|
||||
}
|
||||
|
||||
c.Set(config.LogOut, input.LogOut)
|
||||
c.Set(config.LogAccess, input.LogAccess)
|
||||
if input.LogOut != nil {
|
||||
c.Set(config.LogOut, *input.LogOut)
|
||||
}
|
||||
|
||||
if input.LogLevel != c.GetLogLevel() {
|
||||
if input.LogAccess != nil {
|
||||
c.Set(config.LogAccess, *input.LogAccess)
|
||||
}
|
||||
|
||||
if input.LogLevel != nil && *input.LogLevel != c.GetLogLevel() {
|
||||
c.Set(config.LogLevel, input.LogLevel)
|
||||
logger.SetLogLevel(input.LogLevel)
|
||||
logger.SetLogLevel(*input.LogLevel)
|
||||
}
|
||||
|
||||
if input.Excludes != nil {
|
||||
@@ -213,7 +226,9 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
|
||||
c.Set(config.GalleryExtensions, input.GalleryExtensions)
|
||||
}
|
||||
|
||||
c.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders)
|
||||
if input.CreateGalleriesFromFolders != nil {
|
||||
c.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders)
|
||||
}
|
||||
|
||||
if input.CustomPerformerImageLocation != nil {
|
||||
c.Set(config.CustomPerformerImageLocation, *input.CustomPerformerImageLocation)
|
||||
@@ -293,14 +308,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
|
||||
c.Set(config.SlideshowDelay, *input.SlideshowDelay)
|
||||
}
|
||||
|
||||
css := ""
|
||||
|
||||
if input.CSS != nil {
|
||||
css = *input.CSS
|
||||
c.SetCSS(*input.CSS)
|
||||
}
|
||||
|
||||
c.SetCSS(css)
|
||||
|
||||
setBool(config.CSSEnabled, input.CSSEnabled)
|
||||
|
||||
if input.DisableDropdownCreate != nil {
|
||||
@@ -332,7 +343,9 @@ func (r *mutationResolver) ConfigureDlna(ctx context.Context, input models.Confi
|
||||
c.Set(config.DLNAServerName, *input.ServerName)
|
||||
}
|
||||
|
||||
c.Set(config.DLNADefaultIPWhitelist, input.WhitelistedIPs)
|
||||
if input.WhitelistedIPs != nil {
|
||||
c.Set(config.DLNADefaultIPWhitelist, input.WhitelistedIPs)
|
||||
}
|
||||
|
||||
currentDLNAEnabled := c.GetDLNADefaultEnabled()
|
||||
if input.Enabled != nil && *input.Enabled != currentDLNAEnabled {
|
||||
@@ -349,7 +362,9 @@ func (r *mutationResolver) ConfigureDlna(ctx context.Context, input models.Confi
|
||||
}
|
||||
}
|
||||
|
||||
c.Set(config.DLNAInterfaces, input.Interfaces)
|
||||
if input.Interfaces != nil {
|
||||
c.Set(config.DLNAInterfaces, input.Interfaces)
|
||||
}
|
||||
|
||||
if err := c.Write(); err != nil {
|
||||
return makeConfigDLNAResult(), err
|
||||
@@ -376,7 +391,10 @@ func (r *mutationResolver) ConfigureScraping(ctx context.Context, input models.C
|
||||
c.Set(config.ScraperExcludeTagPatterns, input.ExcludeTagPatterns)
|
||||
}
|
||||
|
||||
c.Set(config.ScraperCertCheck, input.ScraperCertCheck)
|
||||
if input.ScraperCertCheck != nil {
|
||||
c.Set(config.ScraperCertCheck, input.ScraperCertCheck)
|
||||
}
|
||||
|
||||
if refreshScraperCache {
|
||||
manager.GetInstance().RefreshScraperCache()
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
||||
handyKey := config.GetHandyKey()
|
||||
scriptOffset := config.GetFunscriptOffset()
|
||||
|
||||
// FIXME - misnamed output field means we have redundant fields
|
||||
disableDropdownCreate := config.GetDisableDropdownCreate()
|
||||
|
||||
return &models.ConfigInterfaceResult{
|
||||
MenuItems: menuItems,
|
||||
SoundOnPreview: &soundOnPreview,
|
||||
@@ -136,9 +139,13 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
||||
CSSEnabled: &cssEnabled,
|
||||
Language: &language,
|
||||
SlideshowDelay: &slideshowDelay,
|
||||
DisabledDropdownCreate: config.GetDisableDropdownCreate(),
|
||||
HandyKey: &handyKey,
|
||||
FunscriptOffset: &scriptOffset,
|
||||
|
||||
// FIXME - see above
|
||||
DisabledDropdownCreate: disableDropdownCreate,
|
||||
DisableDropdownCreate: disableDropdownCreate,
|
||||
|
||||
HandyKey: &handyKey,
|
||||
FunscriptOffset: &scriptOffset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import { Migrate } from "./components/Setup/Migrate";
|
||||
import * as GQL from "./core/generated-graphql";
|
||||
import { LoadingIndicator, TITLE_SUFFIX } from "./components/Shared";
|
||||
import { ConfigurationProvider } from "./hooks/Config";
|
||||
import { ManualProvider } from "./components/Help/Manual";
|
||||
|
||||
initPolyfills();
|
||||
|
||||
@@ -146,12 +147,14 @@ export const App: React.FC = () => {
|
||||
>
|
||||
<ToastProvider>
|
||||
<LightboxProvider>
|
||||
<Helmet
|
||||
titleTemplate={`%s ${TITLE_SUFFIX}`}
|
||||
defaultTitle="Stash"
|
||||
/>
|
||||
{maybeRenderNavbar()}
|
||||
<div className="main container-fluid">{renderContent()}</div>
|
||||
<ManualProvider>
|
||||
<Helmet
|
||||
titleTemplate={`%s ${TITLE_SUFFIX}`}
|
||||
defaultTitle="Stash"
|
||||
/>
|
||||
{maybeRenderNavbar()}
|
||||
<div className="main container-fluid">{renderContent()}</div>
|
||||
</ManualProvider>
|
||||
</LightboxProvider>
|
||||
</ToastProvider>
|
||||
</ConfigurationProvider>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Add forward jump 10 second button to video player. ([#1973](https://github.com/stashapp/stash/pull/1973))
|
||||
|
||||
### 🎨 Improvements
|
||||
* Overhauled, restructured and added auto-save to the settings pages. ([#2086](https://github.com/stashapp/stash/pull/2086))
|
||||
* Include path and hashes in destroy scene/image/gallery post hook input. ([#2102](https://github.com/stashapp/stash/pull/2102/files))
|
||||
* Rollback operation if files fail to be deleted. ([#1954](https://github.com/stashapp/stash/pull/1954))
|
||||
* Prefer right-most Studio match in the file path when autotagging. ([#2057](https://github.com/stashapp/stash/pull/2057))
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { Manual } from "../Help/Manual";
|
||||
import { withoutTypename } from "src/utils";
|
||||
import { GenerateOptions } from "../Settings/Tasks/GenerateOptions";
|
||||
import { SettingSection } from "../Settings/SettingSection";
|
||||
|
||||
interface ISceneGenerateDialog {
|
||||
selectedIds?: string[];
|
||||
@@ -276,7 +277,9 @@ export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
|
||||
>
|
||||
<Form>
|
||||
{selectionStatus}
|
||||
<GenerateOptions options={options} setOptions={setOptions} />
|
||||
<SettingSection>
|
||||
<GenerateOptions options={options} setOptions={setOptions} />
|
||||
</SettingSection>
|
||||
</Form>
|
||||
</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 Introduction from "src/docs/en/Introduction.md";
|
||||
import Tasks from "src/docs/en/Tasks.md";
|
||||
@@ -155,6 +155,12 @@ export const Manual: React.FC<IManualProps> = ({
|
||||
defaultActiveTab ?? content[0].key
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultActiveTab) {
|
||||
setActiveTab(defaultActiveTab);
|
||||
}
|
||||
}, [defaultActiveTab]);
|
||||
|
||||
// links to other manual pages are specified as "/help/page.md"
|
||||
// intercept clicks to these pages and set the tab accordingly
|
||||
function interceptLinkClick(
|
||||
@@ -226,3 +232,63 @@ export const Manual: React.FC<IManualProps> = ({
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IManualContextState {
|
||||
openManual: (tab?: string) => void;
|
||||
}
|
||||
|
||||
export const ManualStateContext = React.createContext<IManualContextState>({
|
||||
openManual: () => {},
|
||||
});
|
||||
|
||||
export const ManualProvider: React.FC = ({ children }) => {
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
const [manualLink, setManualLink] = useState<string | undefined>();
|
||||
|
||||
function openManual(tab?: string) {
|
||||
setManualLink(tab);
|
||||
setShowManual(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (manualLink) setManualLink(undefined);
|
||||
}, [manualLink]);
|
||||
|
||||
return (
|
||||
<ManualStateContext.Provider
|
||||
value={{
|
||||
openManual,
|
||||
}}
|
||||
>
|
||||
<Manual
|
||||
show={showManual}
|
||||
onClose={() => setShowManual(false)}
|
||||
defaultActiveTab={manualLink}
|
||||
/>
|
||||
{children}
|
||||
</ManualStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface IManualLink {
|
||||
tab: string;
|
||||
}
|
||||
|
||||
export const ManualLink: React.FC<PropsWithChildren<IManualLink>> = ({
|
||||
tab,
|
||||
children,
|
||||
}) => {
|
||||
const { openManual } = React.useContext(ManualStateContext);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/help/${tab}.md`}
|
||||
onClick={(e) => {
|
||||
openManual(`${tab}.md`);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import Mousetrap from "mousetrap";
|
||||
import { SessionUtils } from "src/utils";
|
||||
import { Icon } from "src/components/Shared";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { Manual } from "./Help/Manual";
|
||||
import { ManualStateContext } from "./Help/Manual";
|
||||
import { SettingsButton } from "./SettingsButton";
|
||||
|
||||
interface IMenuItem {
|
||||
@@ -141,12 +141,12 @@ export const MainNavbar: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const { configuration, loading } = React.useContext(ConfigurationContext);
|
||||
const { openManual } = React.useContext(ManualStateContext);
|
||||
|
||||
// Show all menu items by default, unless config says otherwise
|
||||
const [menuItems, setMenuItems] = useState<IMenuItem[]>(allMenuItems);
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const iCfg = configuration?.interface;
|
||||
@@ -203,7 +203,7 @@ export const MainNavbar: React.FC = () => {
|
||||
|
||||
// set up hotkeys
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("?", () => setShowManual(!showManual));
|
||||
Mousetrap.bind("?", () => openManual());
|
||||
Mousetrap.bind("g z", () => goto("/settings"));
|
||||
|
||||
menuItems.forEach((item) =>
|
||||
@@ -267,7 +267,7 @@ export const MainNavbar: React.FC = () => {
|
||||
</NavLink>
|
||||
<Button
|
||||
className="nav-utility minimal"
|
||||
onClick={() => setShowManual(true)}
|
||||
onClick={() => openManual()}
|
||||
title="Help"
|
||||
>
|
||||
<Icon icon="question-circle" />
|
||||
@@ -279,7 +279,6 @@ export const MainNavbar: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Manual show={showManual} onClose={() => setShowManual(false)} />
|
||||
<Navbar
|
||||
collapseOnSelect
|
||||
fixed="top"
|
||||
|
||||
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 queryString from "query-string";
|
||||
import { Card, Tab, Nav, Row, Col } from "react-bootstrap";
|
||||
import { Tab, Nav, Row, Col } from "react-bootstrap";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { TITLE_SUFFIX } from "src/components/Shared";
|
||||
import { SettingsAboutPanel } from "./SettingsAboutPanel";
|
||||
import { SettingsConfigurationPanel } from "./SettingsConfigurationPanel";
|
||||
import { SettingsConfigurationPanel } from "./SettingsSystemPanel";
|
||||
import { SettingsInterfacePanel } from "./SettingsInterfacePanel/SettingsInterfacePanel";
|
||||
import { SettingsLogsPanel } from "./SettingsLogsPanel";
|
||||
import { SettingsTasksPanel } from "./Tasks/SettingsTasksPanel";
|
||||
import { SettingsPluginsPanel } from "./SettingsPluginsPanel";
|
||||
import { SettingsScrapingPanel } from "./SettingsScrapingPanel";
|
||||
import { SettingsToolsPanel } from "./SettingsToolsPanel";
|
||||
import { SettingsDLNAPanel } from "./SettingsDLNAPanel";
|
||||
import { SettingsServicesPanel } from "./SettingsServicesPanel";
|
||||
import { SettingsContext } from "./context";
|
||||
import { SettingsLibraryPanel } from "./SettingsLibraryPanel";
|
||||
import { SettingsSecurityPanel } from "./SettingsSecurityPanel";
|
||||
|
||||
export const Settings: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
@@ -27,85 +30,108 @@ export const Settings: React.FC = () => {
|
||||
id: "settings",
|
||||
})} ${TITLE_SUFFIX}`;
|
||||
return (
|
||||
<Card className="col col-lg-9 mx-auto">
|
||||
<Tab.Container
|
||||
activeKey={defaultTab}
|
||||
id="configuration-tabs"
|
||||
onSelect={(tab) => tab && onSelect(tab)}
|
||||
>
|
||||
<Helmet
|
||||
defaultTitle={title_template}
|
||||
titleTemplate={`%s | ${title_template}`}
|
||||
/>
|
||||
<Tab.Container
|
||||
activeKey={defaultTab}
|
||||
id="configuration-tabs"
|
||||
onSelect={(tab) => tab && onSelect(tab)}
|
||||
>
|
||||
<Row>
|
||||
<Col sm={3} md={2}>
|
||||
<Nav variant="pills" className="flex-column">
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="configuration">
|
||||
<FormattedMessage id="configuration" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="interface">
|
||||
<FormattedMessage id="config.categories.interface" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="tasks">
|
||||
<FormattedMessage id="config.categories.tasks" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="dlna">DLNA</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="tools">
|
||||
<FormattedMessage id="config.categories.tools" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="scraping">
|
||||
<FormattedMessage id="config.categories.scraping" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="plugins">
|
||||
<FormattedMessage id="config.categories.plugins" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="logs">
|
||||
<FormattedMessage id="config.categories.logs" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="about">
|
||||
<FormattedMessage id="config.categories.about" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<hr className="d-sm-none" />
|
||||
</Nav>
|
||||
</Col>
|
||||
<Col sm={9} md={10}>
|
||||
<Tab.Content>
|
||||
<Tab.Pane eventKey="configuration">
|
||||
<SettingsConfigurationPanel />
|
||||
<Row>
|
||||
<Col id="settings-menu-container" sm={3} md={3} xl={2}>
|
||||
<Nav variant="pills" className="flex-column">
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="tasks">
|
||||
<FormattedMessage id="config.categories.tasks" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="library">
|
||||
<FormattedMessage id="library" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="interface">
|
||||
<FormattedMessage id="config.categories.interface" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="security">
|
||||
<FormattedMessage id="config.categories.security" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="metadata-providers">
|
||||
<FormattedMessage id="config.categories.metadata_providers" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="services">
|
||||
<FormattedMessage id="config.categories.services" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="system">
|
||||
<FormattedMessage id="config.categories.system" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="plugins">
|
||||
<FormattedMessage id="config.categories.plugins" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="logs">
|
||||
<FormattedMessage id="config.categories.logs" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="tools">
|
||||
<FormattedMessage id="config.categories.tools" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="about">
|
||||
<FormattedMessage id="config.categories.about" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<hr className="d-sm-none" />
|
||||
</Nav>
|
||||
</Col>
|
||||
<Col
|
||||
id="settings-container"
|
||||
sm={{ offset: 3 }}
|
||||
md={{ offset: 3 }}
|
||||
xl={{ offset: 2 }}
|
||||
>
|
||||
<SettingsContext>
|
||||
<Tab.Content className="mx-auto">
|
||||
<Tab.Pane eventKey="library">
|
||||
<SettingsLibraryPanel />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="interface">
|
||||
<SettingsInterfacePanel />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="security">
|
||||
<SettingsSecurityPanel />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="tasks">
|
||||
<SettingsTasksPanel />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="dlna" unmountOnExit>
|
||||
<SettingsDLNAPanel />
|
||||
<Tab.Pane eventKey="services" unmountOnExit>
|
||||
<SettingsServicesPanel />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="tools" unmountOnExit>
|
||||
<SettingsToolsPanel />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="scraping" unmountOnExit>
|
||||
<Tab.Pane eventKey="metadata-providers" unmountOnExit>
|
||||
<SettingsScrapingPanel />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="system">
|
||||
<SettingsConfigurationPanel />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="plugins" unmountOnExit>
|
||||
<SettingsPluginsPanel />
|
||||
</Tab.Pane>
|
||||
@@ -116,9 +142,9 @@ export const Settings: React.FC = () => {
|
||||
<SettingsAboutPanel />
|
||||
</Tab.Pane>
|
||||
</Tab.Content>
|
||||
</Col>
|
||||
</Row>
|
||||
</Tab.Container>
|
||||
</Card>
|
||||
</SettingsContext>
|
||||
</Col>
|
||||
</Row>
|
||||
</Tab.Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from "react";
|
||||
import { Button, Table } from "react-bootstrap";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { LoadingIndicator } from "src/components/Shared";
|
||||
import { useLatestVersion } from "src/core/StashService";
|
||||
import { ConstantSetting, Setting, SettingGroup } from "./Inputs";
|
||||
import { SettingSection } from "./SettingSection";
|
||||
|
||||
export const SettingsAboutPanel: React.FC = () => {
|
||||
const gitHash = import.meta.env.VITE_APP_GITHASH;
|
||||
@@ -19,95 +20,73 @@ export const SettingsAboutPanel: React.FC = () => {
|
||||
networkStatus,
|
||||
} = useLatestVersion();
|
||||
|
||||
function maybeRenderTag() {
|
||||
if (!stashVersion) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<tr>
|
||||
<td>{intl.formatMessage({ id: "config.about.version" })}:</td>
|
||||
<td>{stashVersion}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
const hasNew = dataLatest && gitHash !== dataLatest.latestversion.shorthash;
|
||||
|
||||
function maybeRenderLatestVersion() {
|
||||
if (
|
||||
!dataLatest?.latestversion.shorthash ||
|
||||
!dataLatest?.latestversion.url
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (gitHash !== dataLatest.latestversion.shorthash) {
|
||||
return (
|
||||
<>
|
||||
<strong>
|
||||
{dataLatest.latestversion.shorthash}{" "}
|
||||
{intl.formatMessage({ id: "config.about.new_version_notice" })}{" "}
|
||||
</strong>
|
||||
<a href={dataLatest.latestversion.url}>
|
||||
{intl.formatMessage({ id: "actions.download" })}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{dataLatest.latestversion.shorthash}</>;
|
||||
}
|
||||
|
||||
function renderLatestVersion() {
|
||||
return (
|
||||
<Table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{intl.formatMessage({
|
||||
id: "config.about.latest_version_build_hash",
|
||||
})}{" "}
|
||||
</td>
|
||||
<td>{maybeRenderLatestVersion()} </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Button onClick={() => refetch()}>
|
||||
{intl.formatMessage({
|
||||
id: "config.about.check_for_new_version",
|
||||
})}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function renderVersion() {
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<tbody>
|
||||
{maybeRenderTag()}
|
||||
<tr>
|
||||
<td>{intl.formatMessage({ id: "config.about.build_hash" })}</td>
|
||||
<td>{gitHash}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{intl.formatMessage({ id: "config.about.build_time" })}</td>
|
||||
<td>{buildTime}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h4>{intl.formatMessage({ id: "config.categories.about" })}</h4>
|
||||
<Table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<SettingSection headingID="config.about.version">
|
||||
<SettingGroup
|
||||
settingProps={{
|
||||
heading: stashVersion,
|
||||
}}
|
||||
>
|
||||
<ConstantSetting
|
||||
headingID="config.about.build_hash"
|
||||
value={gitHash}
|
||||
/>
|
||||
<ConstantSetting
|
||||
headingID="config.about.build_time"
|
||||
value={buildTime}
|
||||
/>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup
|
||||
settingProps={{
|
||||
headingID: "config.about.latest_version",
|
||||
}}
|
||||
>
|
||||
{errorLatest ? (
|
||||
<Setting heading={errorLatest.message} />
|
||||
) : !dataLatest || loadingLatest || networkStatus === 4 ? (
|
||||
<Setting headingID="loading.generic" />
|
||||
) : (
|
||||
<div className="setting">
|
||||
<div>
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
id: "config.about.latest_version_build_hash",
|
||||
})}
|
||||
</h3>
|
||||
<div className="value">
|
||||
{dataLatest.latestversion.shorthash}{" "}
|
||||
{hasNew
|
||||
? intl.formatMessage({
|
||||
id: "config.about.new_version_notice",
|
||||
})
|
||||
: undefined}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href={dataLatest.latestversion.url}>
|
||||
<Button>
|
||||
{intl.formatMessage({ id: "actions.download" })}
|
||||
</Button>
|
||||
</a>
|
||||
<Button onClick={() => refetch()}>
|
||||
{intl.formatMessage({
|
||||
id: "config.about.check_for_new_version",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingGroup>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection headingID="config.categories.about">
|
||||
<div className="setting">
|
||||
<div>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{ id: "config.about.stash_home" },
|
||||
{
|
||||
@@ -122,10 +101,8 @@ export const SettingsAboutPanel: React.FC = () => {
|
||||
),
|
||||
}
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</p>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{ id: "config.about.stash_wiki" },
|
||||
{
|
||||
@@ -140,10 +117,8 @@ export const SettingsAboutPanel: React.FC = () => {
|
||||
),
|
||||
}
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</p>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{ id: "config.about.stash_discord" },
|
||||
{
|
||||
@@ -158,10 +133,8 @@ export const SettingsAboutPanel: React.FC = () => {
|
||||
),
|
||||
}
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</p>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{ id: "config.about.stash_open_collective" },
|
||||
{
|
||||
@@ -176,17 +149,11 @@ export const SettingsAboutPanel: React.FC = () => {
|
||||
),
|
||||
}
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
{errorLatest && <span>{errorLatest.message}</span>}
|
||||
{renderVersion()}
|
||||
{!dataLatest || loadingLatest || networkStatus === 4 ? (
|
||||
<LoadingIndicator inline />
|
||||
) : (
|
||||
renderLatestVersion()
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
</SettingSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { BooleanSetting } from "../Inputs";
|
||||
|
||||
interface IItem {
|
||||
id: string;
|
||||
label: string;
|
||||
headingID: string;
|
||||
}
|
||||
|
||||
interface ICheckboxGroupProps {
|
||||
@@ -25,22 +25,20 @@ export const CheckboxGroup: React.FC<ICheckboxGroupProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map(({ id, label }) => (
|
||||
<Form.Check
|
||||
{items.map(({ id, headingID }) => (
|
||||
<BooleanSetting
|
||||
key={id}
|
||||
type="checkbox"
|
||||
id={generateId(id)}
|
||||
label={label}
|
||||
headingID={headingID}
|
||||
checked={checkedIds.includes(id)}
|
||||
onChange={(event) => {
|
||||
const target = event.currentTarget;
|
||||
if (target.checked) {
|
||||
onChange={(v) => {
|
||||
if (v) {
|
||||
onChange?.(
|
||||
items
|
||||
.map((item) => item.id)
|
||||
.filter(
|
||||
(itemId) =>
|
||||
generateId(itemId) === target.id ||
|
||||
generateId(itemId) === generateId(id) ||
|
||||
checkedIds.includes(itemId)
|
||||
)
|
||||
);
|
||||
@@ -50,7 +48,7 @@ export const CheckboxGroup: React.FC<ICheckboxGroupProps> = ({
|
||||
.map((item) => item.id)
|
||||
.filter(
|
||||
(itemId) =>
|
||||
generateId(itemId) !== target.id &&
|
||||
generateId(itemId) !== generateId(id) &&
|
||||
checkedIds.includes(itemId)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,174 +1,48 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import React from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { DurationInput, LoadingIndicator } from "src/components/Shared";
|
||||
import {
|
||||
useConfiguration,
|
||||
useConfigureDefaults,
|
||||
useConfigureInterface,
|
||||
} from "src/core/StashService";
|
||||
import { useToast } from "src/hooks";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { CheckboxGroup } from "./CheckboxGroup";
|
||||
import { withoutTypename } from "src/utils";
|
||||
import { SettingSection } from "../SettingSection";
|
||||
import {
|
||||
BooleanSetting,
|
||||
ModalSetting,
|
||||
NumberSetting,
|
||||
SelectSetting,
|
||||
StringSetting,
|
||||
} from "../Inputs";
|
||||
import { SettingStateContext } from "../context";
|
||||
import { DurationUtils } from "src/utils";
|
||||
|
||||
const allMenuItems = [
|
||||
{ id: "scenes", label: "Scenes" },
|
||||
{ id: "images", label: "Images" },
|
||||
{ id: "movies", label: "Movies" },
|
||||
{ id: "markers", label: "Markers" },
|
||||
{ id: "galleries", label: "Galleries" },
|
||||
{ id: "performers", label: "Performers" },
|
||||
{ id: "studios", label: "Studios" },
|
||||
{ id: "tags", label: "Tags" },
|
||||
{ id: "scenes", headingID: "scenes" },
|
||||
{ id: "images", headingID: "images" },
|
||||
{ id: "movies", headingID: "movies" },
|
||||
{ id: "markers", headingID: "markers" },
|
||||
{ id: "galleries", headingID: "galleries" },
|
||||
{ id: "performers", headingID: "performers" },
|
||||
{ id: "studios", headingID: "studios" },
|
||||
{ id: "tags", headingID: "tags" },
|
||||
];
|
||||
|
||||
const SECONDS_TO_MS = 1000;
|
||||
|
||||
export const SettingsInterfacePanel: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
const { data: config, error, loading } = useConfiguration();
|
||||
const [menuItemIds, setMenuItemIds] = useState<string[]>(
|
||||
allMenuItems.map((item) => item.id)
|
||||
|
||||
const { interface: iface, saveInterface, loading, error } = React.useContext(
|
||||
SettingStateContext
|
||||
);
|
||||
const [noBrowser, setNoBrowserFlag] = useState<boolean>(false);
|
||||
const [soundOnPreview, setSoundOnPreview] = useState<boolean>(true);
|
||||
const [wallShowTitle, setWallShowTitle] = useState<boolean>(true);
|
||||
const [wallPlayback, setWallPlayback] = useState<string>("video");
|
||||
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
|
||||
const [autostartVideo, setAutostartVideo] = useState<boolean>(false);
|
||||
const [
|
||||
autostartVideoOnPlaySelected,
|
||||
setAutostartVideoOnPlaySelected,
|
||||
] = useState(true);
|
||||
const [continuePlaylistDefault, setContinuePlaylistDefault] = useState(false);
|
||||
const [slideshowDelay, setSlideshowDelay] = useState<number>(0);
|
||||
const [showStudioAsText, setShowStudioAsText] = useState<boolean>(false);
|
||||
const [css, setCSS] = useState<string>();
|
||||
const [cssEnabled, setCSSEnabled] = useState<boolean>(false);
|
||||
const [language, setLanguage] = useState<string>("en");
|
||||
const [handyKey, setHandyKey] = useState<string>();
|
||||
const [funscriptOffset, setFunscriptOffset] = useState<number>(0);
|
||||
const [deleteFileDefault, setDeleteFileDefault] = useState<boolean>(false);
|
||||
const [deleteGeneratedDefault, setDeleteGeneratedDefault] = useState<boolean>(
|
||||
true
|
||||
);
|
||||
const [
|
||||
disableDropdownCreate,
|
||||
setDisableDropdownCreate,
|
||||
] = useState<GQL.ConfigDisableDropdownCreateInput>({});
|
||||
|
||||
const [updateInterfaceConfig] = useConfigureInterface({
|
||||
menuItems: menuItemIds,
|
||||
soundOnPreview,
|
||||
wallShowTitle,
|
||||
wallPlayback,
|
||||
maximumLoopDuration,
|
||||
noBrowser,
|
||||
autostartVideo,
|
||||
autostartVideoOnPlaySelected,
|
||||
continuePlaylistDefault,
|
||||
showStudioAsText,
|
||||
css,
|
||||
cssEnabled,
|
||||
language,
|
||||
slideshowDelay,
|
||||
handyKey,
|
||||
funscriptOffset,
|
||||
disableDropdownCreate,
|
||||
});
|
||||
|
||||
const [updateDefaultsConfig] = useConfigureDefaults();
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
const { interface: iCfg, defaults } = config.configuration;
|
||||
setMenuItemIds(iCfg.menuItems ?? allMenuItems.map((item) => item.id));
|
||||
setSoundOnPreview(iCfg.soundOnPreview ?? true);
|
||||
setWallShowTitle(iCfg.wallShowTitle ?? true);
|
||||
setWallPlayback(iCfg.wallPlayback ?? "video");
|
||||
setMaximumLoopDuration(iCfg.maximumLoopDuration ?? 0);
|
||||
setNoBrowserFlag(iCfg?.noBrowser ?? false);
|
||||
setAutostartVideo(iCfg.autostartVideo ?? false);
|
||||
setAutostartVideoOnPlaySelected(
|
||||
iCfg.autostartVideoOnPlaySelected ?? true
|
||||
);
|
||||
setContinuePlaylistDefault(iCfg.continuePlaylistDefault ?? false);
|
||||
setShowStudioAsText(iCfg.showStudioAsText ?? false);
|
||||
setCSS(iCfg.css ?? "");
|
||||
setCSSEnabled(iCfg.cssEnabled ?? false);
|
||||
setLanguage(iCfg.language ?? "en-US");
|
||||
setSlideshowDelay(iCfg.slideshowDelay ?? 5000);
|
||||
setHandyKey(iCfg.handyKey ?? "");
|
||||
setFunscriptOffset(iCfg.funscriptOffset ?? 0);
|
||||
setDisableDropdownCreate({
|
||||
performer: iCfg.disabledDropdownCreate.performer,
|
||||
studio: iCfg.disabledDropdownCreate.studio,
|
||||
tag: iCfg.disabledDropdownCreate.tag,
|
||||
});
|
||||
|
||||
setDeleteFileDefault(defaults.deleteFile ?? false);
|
||||
setDeleteGeneratedDefault(defaults.deleteGenerated ?? true);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
async function onSave() {
|
||||
const prevCSS = config?.configuration.interface.css;
|
||||
const prevCSSenabled = config?.configuration.interface.cssEnabled;
|
||||
try {
|
||||
if (config?.configuration.defaults) {
|
||||
await updateDefaultsConfig({
|
||||
variables: {
|
||||
input: {
|
||||
...withoutTypename(config?.configuration.defaults),
|
||||
deleteFile: deleteFileDefault,
|
||||
deleteGenerated: deleteGeneratedDefault,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
const result = await updateInterfaceConfig();
|
||||
|
||||
// Force refetch of custom css if it was changed
|
||||
if (
|
||||
prevCSS !== result.data?.configureInterface.css ||
|
||||
prevCSSenabled !== result.data?.configureInterface.cssEnabled
|
||||
) {
|
||||
await fetch("/css", { cache: "reload" });
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
Toast.success({
|
||||
content: intl.formatMessage(
|
||||
{ id: "toast.updated_entity" },
|
||||
{
|
||||
entity: intl
|
||||
.formatMessage({ id: "configuration" })
|
||||
.toLocaleLowerCase(),
|
||||
}
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <h1>{error.message}</h1>;
|
||||
if (loading) return <LoadingIndicator />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4>{intl.formatMessage({ id: "config.ui.title" })}</h4>
|
||||
<Form.Group controlId="language">
|
||||
<h5>{intl.formatMessage({ id: "config.ui.language.heading" })}</h5>
|
||||
<Form.Control
|
||||
as="select"
|
||||
className="col-4 input-control"
|
||||
value={language}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setLanguage(e.currentTarget.value)
|
||||
}
|
||||
<SettingSection headingID="config.ui.basic_settings">
|
||||
<SelectSetting
|
||||
id="language"
|
||||
headingID="config.ui.language.heading"
|
||||
value={iface.language ?? undefined}
|
||||
onChange={(v) => saveInterface({ language: v })}
|
||||
>
|
||||
<option value="en-US">English (United States)</option>
|
||||
<option value="en-GB">English (United Kingdom)</option>
|
||||
@@ -181,76 +55,61 @@ export const SettingsInterfacePanel: React.FC = () => {
|
||||
<option value="sv-SE">Swedish (Sweden)</option>
|
||||
<option value="zh-TW">繁體中文 (台灣)</option>
|
||||
<option value="zh-CN">简体中文 (中国)</option>
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<h5>{intl.formatMessage({ id: "config.ui.menu_items.heading" })}</h5>
|
||||
<CheckboxGroup
|
||||
groupId="menu-items"
|
||||
items={allMenuItems}
|
||||
checkedIds={menuItemIds}
|
||||
onChange={setMenuItemIds}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({ id: "config.ui.menu_items.description" })}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
</SelectSetting>
|
||||
|
||||
<hr />
|
||||
<div className="setting-group">
|
||||
<div className="setting">
|
||||
<div>
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
id: "config.ui.menu_items.heading",
|
||||
})}
|
||||
</h3>
|
||||
<div className="sub-heading">
|
||||
{intl.formatMessage({ id: "config.ui.menu_items.description" })}
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<CheckboxGroup
|
||||
groupId="menu-items"
|
||||
items={allMenuItems}
|
||||
checkedIds={iface.menuItems ?? undefined}
|
||||
onChange={(v) => saveInterface({ menuItems: v })}
|
||||
/>
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
<h4>
|
||||
{intl.formatMessage({
|
||||
id: "config.ui.desktop_integration.desktop_integration",
|
||||
})}
|
||||
</h4>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
<SettingSection headingID="config.ui.desktop_integration.desktop_integration">
|
||||
<BooleanSetting
|
||||
id="skip-browser"
|
||||
checked={noBrowser}
|
||||
label={intl.formatMessage({
|
||||
id: "config.ui.desktop_integration.skip_opening_browser",
|
||||
})}
|
||||
onChange={() => setNoBrowserFlag(!noBrowser)}
|
||||
headingID="config.ui.desktop_integration.skip_opening_browser"
|
||||
subHeadingID="config.ui.desktop_integration.skip_opening_browser_on_startup"
|
||||
checked={iface.noBrowser ?? undefined}
|
||||
onChange={(v) => saveInterface({ noBrowser: v })}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id: "config.ui.desktop_integration.skip_opening_browser_on_startup",
|
||||
})}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<hr />
|
||||
</SettingSection>
|
||||
|
||||
<Form.Group>
|
||||
<h5>{intl.formatMessage({ id: "config.ui.scene_wall.heading" })}</h5>
|
||||
<Form.Check
|
||||
<SettingSection headingID="config.ui.scene_wall.heading">
|
||||
<BooleanSetting
|
||||
id="wall-show-title"
|
||||
checked={wallShowTitle}
|
||||
label={intl.formatMessage({
|
||||
id: "config.ui.scene_wall.options.display_title",
|
||||
})}
|
||||
onChange={() => setWallShowTitle(!wallShowTitle)}
|
||||
headingID="config.ui.scene_wall.options.display_title"
|
||||
checked={iface.wallShowTitle ?? undefined}
|
||||
onChange={(v) => saveInterface({ wallShowTitle: v })}
|
||||
/>
|
||||
<Form.Check
|
||||
<BooleanSetting
|
||||
id="wall-sound-enabled"
|
||||
checked={soundOnPreview}
|
||||
label={intl.formatMessage({
|
||||
id: "config.ui.scene_wall.options.toggle_sound",
|
||||
})}
|
||||
onChange={() => setSoundOnPreview(!soundOnPreview)}
|
||||
headingID="config.ui.scene_wall.options.toggle_sound"
|
||||
checked={iface.soundOnPreview ?? undefined}
|
||||
onChange={(v) => saveInterface({ soundOnPreview: v })}
|
||||
/>
|
||||
<Form.Label htmlFor="wall-preview">
|
||||
<h6>
|
||||
{intl.formatMessage({ id: "config.ui.preview_type.heading" })}
|
||||
</h6>
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
name="wall-preview"
|
||||
className="col-4 input-control"
|
||||
value={wallPlayback}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setWallPlayback(e.currentTarget.value)
|
||||
}
|
||||
|
||||
<SelectSetting
|
||||
id="wall-preview"
|
||||
headingID="config.ui.preview_type.heading"
|
||||
subHeadingID="config.ui.preview_type.description"
|
||||
value={iface.wallPlayback ?? undefined}
|
||||
onChange={(v) => saveInterface({ wallPlayback: v })}
|
||||
>
|
||||
<option value="video">
|
||||
{intl.formatMessage({ id: "config.ui.preview_type.options.video" })}
|
||||
@@ -265,269 +124,172 @@ export const SettingsInterfacePanel: React.FC = () => {
|
||||
id: "config.ui.preview_type.options.static",
|
||||
})}
|
||||
</option>
|
||||
</Form.Control>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({ id: "config.ui.preview_type.description" })}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
</SelectSetting>
|
||||
</SettingSection>
|
||||
|
||||
<Form.Group>
|
||||
<h5>{intl.formatMessage({ id: "config.ui.scene_list.heading" })}</h5>
|
||||
<Form.Check
|
||||
<SettingSection headingID="config.ui.scene_list.heading">
|
||||
<BooleanSetting
|
||||
id="show-text-studios"
|
||||
checked={showStudioAsText}
|
||||
label={intl.formatMessage({
|
||||
id: "config.ui.scene_list.options.show_studio_as_text",
|
||||
})}
|
||||
onChange={() => {
|
||||
setShowStudioAsText(!showStudioAsText);
|
||||
headingID="config.ui.scene_list.options.show_studio_as_text"
|
||||
checked={iface.showStudioAsText ?? undefined}
|
||||
onChange={(v) => saveInterface({ showStudioAsText: v })}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection headingID="config.ui.scene_player.heading">
|
||||
<BooleanSetting
|
||||
id="auto-start-video"
|
||||
headingID="config.ui.scene_player.options.auto_start_video"
|
||||
checked={iface.autostartVideo ?? undefined}
|
||||
onChange={(v) => saveInterface({ autostartVideo: v })}
|
||||
/>
|
||||
<BooleanSetting
|
||||
id="auto-start-video-on-play-selected"
|
||||
headingID="config.ui.scene_player.options.auto_start_video_on_play_selected.heading"
|
||||
subHeadingID="config.ui.scene_player.options.auto_start_video_on_play_selected.description"
|
||||
checked={iface.autostartVideoOnPlaySelected ?? undefined}
|
||||
onChange={(v) => saveInterface({ autostartVideoOnPlaySelected: v })}
|
||||
/>
|
||||
|
||||
<BooleanSetting
|
||||
id="continue-playlist-default"
|
||||
headingID="config.ui.scene_player.options.continue_playlist_default.heading"
|
||||
subHeadingID="config.ui.scene_player.options.continue_playlist_default.description"
|
||||
checked={iface.continuePlaylistDefault ?? undefined}
|
||||
onChange={(v) => saveInterface({ continuePlaylistDefault: v })}
|
||||
/>
|
||||
|
||||
<ModalSetting<number>
|
||||
id="max-loop-duration"
|
||||
headingID="config.ui.max_loop_duration.heading"
|
||||
subHeadingID="config.ui.max_loop_duration.description"
|
||||
value={iface.maximumLoopDuration ?? undefined}
|
||||
onChange={(v) => saveInterface({ maximumLoopDuration: v })}
|
||||
renderField={(value, setValue) => (
|
||||
<DurationInput
|
||||
numericValue={value}
|
||||
onValueChange={(duration) => setValue(duration ?? 0)}
|
||||
/>
|
||||
)}
|
||||
renderValue={(v) => {
|
||||
return <span>{DurationUtils.secondsToString(v ?? 0)}</span>;
|
||||
}}
|
||||
/>
|
||||
</Form.Group>
|
||||
</SettingSection>
|
||||
|
||||
<Form.Group>
|
||||
<h5>{intl.formatMessage({ id: "config.ui.scene_player.heading" })}</h5>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id="auto-start-video"
|
||||
checked={autostartVideo}
|
||||
label={intl.formatMessage({
|
||||
id: "config.ui.scene_player.options.auto_start_video",
|
||||
})}
|
||||
onChange={() => {
|
||||
setAutostartVideo(!autostartVideo);
|
||||
}}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group id="auto-start-video-on-play-selected">
|
||||
<Form.Check
|
||||
checked={autostartVideoOnPlaySelected}
|
||||
label={intl.formatMessage({
|
||||
id:
|
||||
"config.ui.scene_player.options.auto_start_video_on_play_selected.heading",
|
||||
})}
|
||||
onChange={() => {
|
||||
setAutostartVideoOnPlaySelected(!autostartVideoOnPlaySelected);
|
||||
}}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id:
|
||||
"config.ui.scene_player.options.auto_start_video_on_play_selected.description",
|
||||
})}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group id="continue-playlist-default">
|
||||
<Form.Check
|
||||
checked={continuePlaylistDefault}
|
||||
label={intl.formatMessage({
|
||||
id:
|
||||
"config.ui.scene_player.options.continue_playlist_default.heading",
|
||||
})}
|
||||
onChange={() => {
|
||||
setContinuePlaylistDefault(!continuePlaylistDefault);
|
||||
}}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id:
|
||||
"config.ui.scene_player.options.continue_playlist_default.description",
|
||||
})}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group id="max-loop-duration">
|
||||
<h6>
|
||||
{intl.formatMessage({ id: "config.ui.max_loop_duration.heading" })}
|
||||
</h6>
|
||||
<DurationInput
|
||||
className="row col col-4"
|
||||
numericValue={maximumLoopDuration}
|
||||
onValueChange={(duration) => setMaximumLoopDuration(duration ?? 0)}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id: "config.ui.max_loop_duration.description",
|
||||
})}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group id="slideshow-delay">
|
||||
<h5>
|
||||
{intl.formatMessage({ id: "config.ui.slideshow_delay.heading" })}
|
||||
</h5>
|
||||
<Form.Control
|
||||
className="col col-sm-6 text-input"
|
||||
type="number"
|
||||
value={slideshowDelay / SECONDS_TO_MS}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSlideshowDelay(
|
||||
Number.parseInt(e.currentTarget.value, 10) * SECONDS_TO_MS
|
||||
);
|
||||
}}
|
||||
<SettingSection headingID="config.ui.images.heading">
|
||||
<NumberSetting
|
||||
headingID="config.ui.slideshow_delay.heading"
|
||||
subHeadingID="config.ui.slideshow_delay.description"
|
||||
value={iface.slideshowDelay ?? undefined}
|
||||
onChange={(v) => saveInterface({ slideshowDelay: v })}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({ id: "config.ui.slideshow_delay.description" })}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
</SettingSection>
|
||||
|
||||
<Form.Group>
|
||||
<h5>{intl.formatMessage({ id: "config.ui.editing.heading" })}</h5>
|
||||
|
||||
<Form.Group>
|
||||
<h6>
|
||||
{intl.formatMessage({
|
||||
id: "config.ui.editing.disable_dropdown_create.heading",
|
||||
})}
|
||||
</h6>
|
||||
<Form.Check
|
||||
<SettingSection headingID="config.ui.editing.heading">
|
||||
<div className="setting-group">
|
||||
<div className="setting">
|
||||
<div>
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
id: "config.ui.editing.disable_dropdown_create.heading",
|
||||
})}
|
||||
</h3>
|
||||
<div className="sub-heading">
|
||||
{intl.formatMessage({
|
||||
id: "config.ui.editing.disable_dropdown_create.description",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<BooleanSetting
|
||||
id="disableDropdownCreate_performer"
|
||||
checked={disableDropdownCreate.performer ?? false}
|
||||
label={intl.formatMessage({
|
||||
id: "performer",
|
||||
})}
|
||||
onChange={() => {
|
||||
setDisableDropdownCreate({
|
||||
...disableDropdownCreate,
|
||||
performer: !disableDropdownCreate.performer ?? true,
|
||||
});
|
||||
}}
|
||||
headingID="performer"
|
||||
checked={iface.disableDropdownCreate?.performer ?? undefined}
|
||||
onChange={(v) =>
|
||||
saveInterface({
|
||||
disableDropdownCreate: {
|
||||
...iface.disableDropdownCreate,
|
||||
performer: v,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.Check
|
||||
<BooleanSetting
|
||||
id="disableDropdownCreate_studio"
|
||||
checked={disableDropdownCreate.studio ?? false}
|
||||
label={intl.formatMessage({
|
||||
id: "studio",
|
||||
})}
|
||||
onChange={() => {
|
||||
setDisableDropdownCreate({
|
||||
...disableDropdownCreate,
|
||||
studio: !disableDropdownCreate.studio ?? true,
|
||||
});
|
||||
}}
|
||||
headingID="studio"
|
||||
checked={iface.disableDropdownCreate?.studio ?? undefined}
|
||||
onChange={(v) =>
|
||||
saveInterface({
|
||||
disableDropdownCreate: {
|
||||
...iface.disableDropdownCreate,
|
||||
studio: v,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.Check
|
||||
<BooleanSetting
|
||||
id="disableDropdownCreate_tag"
|
||||
checked={disableDropdownCreate.tag ?? false}
|
||||
label={intl.formatMessage({
|
||||
id: "tag",
|
||||
})}
|
||||
onChange={() => {
|
||||
setDisableDropdownCreate({
|
||||
...disableDropdownCreate,
|
||||
tag: !disableDropdownCreate.tag ?? true,
|
||||
});
|
||||
}}
|
||||
headingID="tag"
|
||||
checked={iface.disableDropdownCreate?.tag ?? undefined}
|
||||
onChange={(v) =>
|
||||
saveInterface({
|
||||
disableDropdownCreate: {
|
||||
...iface.disableDropdownCreate,
|
||||
tag: v,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id: "config.ui.editing.disable_dropdown_create.description",
|
||||
})}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
</Form.Group>
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
<Form.Group>
|
||||
<h5>{intl.formatMessage({ id: "config.ui.custom_css.heading" })}</h5>
|
||||
<Form.Check
|
||||
<SettingSection headingID="config.ui.custom_css.heading">
|
||||
<BooleanSetting
|
||||
id="custom-css-enabled"
|
||||
headingID="config.ui.custom_css.option_label"
|
||||
checked={iface.cssEnabled ?? undefined}
|
||||
onChange={(v) => saveInterface({ cssEnabled: v })}
|
||||
/>
|
||||
|
||||
<ModalSetting<string>
|
||||
id="custom-css"
|
||||
checked={cssEnabled}
|
||||
label={intl.formatMessage({
|
||||
id: "config.ui.custom_css.option_label",
|
||||
})}
|
||||
onChange={() => {
|
||||
setCSSEnabled(!cssEnabled);
|
||||
headingID="config.ui.custom_css.heading"
|
||||
subHeadingID="config.ui.custom_css.description"
|
||||
value={iface.css ?? undefined}
|
||||
onChange={(v) => saveInterface({ css: v })}
|
||||
renderField={(value, setValue) => (
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
value={value}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setValue(e.currentTarget.value)
|
||||
}
|
||||
rows={16}
|
||||
className="text-input code"
|
||||
/>
|
||||
)}
|
||||
renderValue={() => {
|
||||
return <></>;
|
||||
}}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
value={css}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setCSS(e.currentTarget.value)
|
||||
}
|
||||
rows={16}
|
||||
className="col col-sm-6 text-input code"
|
||||
<SettingSection headingID="config.ui.interactive_options">
|
||||
<StringSetting
|
||||
headingID="config.ui.handy_connection_key.heading"
|
||||
subHeadingID="config.ui.handy_connection_key.description"
|
||||
value={iface.handyKey ?? undefined}
|
||||
onChange={(v) => saveInterface({ handyKey: v })}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({ id: "config.ui.custom_css.description" })}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group>
|
||||
<h5>
|
||||
{intl.formatMessage({ id: "config.ui.handy_connection_key.heading" })}
|
||||
</h5>
|
||||
<Form.Control
|
||||
className="col col-sm-6 text-input"
|
||||
value={handyKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setHandyKey(e.currentTarget.value);
|
||||
}}
|
||||
<NumberSetting
|
||||
headingID="config.ui.funscript_offset.heading"
|
||||
subHeadingID="config.ui.funscript_offset.description"
|
||||
value={iface.funscriptOffset ?? undefined}
|
||||
onChange={(v) => saveInterface({ funscriptOffset: v })}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id: "config.ui.handy_connection_key.description",
|
||||
})}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<h5>
|
||||
{intl.formatMessage({ id: "config.ui.funscript_offset.heading" })}
|
||||
</h5>
|
||||
<Form.Control
|
||||
className="col col-sm-6 text-input"
|
||||
type="number"
|
||||
value={funscriptOffset}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFunscriptOffset(Number.parseInt(e.currentTarget.value, 10));
|
||||
}}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({ id: "config.ui.funscript_offset.description" })}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group>
|
||||
<h5>
|
||||
{intl.formatMessage({ id: "config.ui.delete_options.heading" })}
|
||||
</h5>
|
||||
<Form.Check
|
||||
id="delete-file-default"
|
||||
checked={deleteFileDefault}
|
||||
label={intl.formatMessage({
|
||||
id: "config.ui.delete_options.options.delete_file",
|
||||
})}
|
||||
onChange={() => {
|
||||
setDeleteFileDefault(!deleteFileDefault);
|
||||
}}
|
||||
/>
|
||||
<Form.Check
|
||||
id="delete-generated-default"
|
||||
checked={deleteGeneratedDefault}
|
||||
label={intl.formatMessage({
|
||||
id:
|
||||
"config.ui.delete_options.options.delete_generated_supporting_files",
|
||||
})}
|
||||
onChange={() => {
|
||||
setDeleteGeneratedDefault(!deleteGeneratedDefault);
|
||||
}}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id: "config.ui.delete_options.description",
|
||||
})}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
<Button variant="primary" onClick={() => onSave()}>
|
||||
{intl.formatMessage({ id: "actions.save" })}
|
||||
</Button>
|
||||
</SettingSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 { Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useLogs, useLoggingSubscribe } from "src/core/StashService";
|
||||
import { SelectSetting } from "./Inputs";
|
||||
import { SettingSection } from "./SettingSection";
|
||||
|
||||
function convertTime(logEntry: GQL.LogEntryDataFragment) {
|
||||
function pad(val: number) {
|
||||
@@ -75,7 +75,6 @@ const logReducer = (existingEntries: LogEntry[], newEntries: LogEntry[]) => [
|
||||
];
|
||||
|
||||
export const SettingsLogsPanel: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { data, error } = useLoggingSubscribe();
|
||||
const { data: existingData } = useLogs();
|
||||
const [currentData, dispatchLogUpdate] = useReducer(logReducer, []);
|
||||
@@ -108,24 +107,21 @@ export const SettingsLogsPanel: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4>{intl.formatMessage({ id: "config.categories.logs" })}</h4>
|
||||
<Form.Row id="log-level">
|
||||
<Form.Label className="col-6 col-sm-2">
|
||||
{intl.formatMessage({ id: "config.logs.log_level" })}
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
className="col-6 col-sm-2 input-control"
|
||||
as="select"
|
||||
defaultValue={logLevel}
|
||||
onChange={(event) => setLogLevel(event.currentTarget.value)}
|
||||
<SettingSection headingID="config.categories.logs">
|
||||
<SelectSetting
|
||||
id="log-level"
|
||||
headingID="config.logs.log_level"
|
||||
value={logLevel}
|
||||
onChange={(v) => setLogLevel(v)}
|
||||
>
|
||||
{logLevels.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{level}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Row>
|
||||
</SelectSetting>
|
||||
</SettingSection>
|
||||
|
||||
<div className="logs">
|
||||
{maybeRenderError}
|
||||
{filteredLogEntries.map((logEntry) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
@@ -6,6 +6,8 @@ import { mutateReloadPlugins, usePlugins } from "src/core/StashService";
|
||||
import { useToast } from "src/hooks";
|
||||
import { TextUtils } from "src/utils";
|
||||
import { CollapseButton, Icon, LoadingIndicator } from "src/components/Shared";
|
||||
import { SettingSection } from "./SettingSection";
|
||||
import { Setting, SettingGroup } from "./Inputs";
|
||||
|
||||
export const SettingsPluginsPanel: React.FC = () => {
|
||||
const Toast = useToast();
|
||||
@@ -17,91 +19,101 @@ export const SettingsPluginsPanel: React.FC = () => {
|
||||
await mutateReloadPlugins().catch((e) => Toast.error(e));
|
||||
}
|
||||
|
||||
function renderLink(url?: string) {
|
||||
if (url) {
|
||||
const pluginElements = useMemo(() => {
|
||||
function renderLink(url?: string) {
|
||||
if (url) {
|
||||
return (
|
||||
<Button className="minimal">
|
||||
<a
|
||||
href={TextUtils.sanitiseURL(url)}
|
||||
className="link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon="link" />
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlugins() {
|
||||
const elements = (data?.plugins ?? []).map((plugin) => (
|
||||
<SettingGroup
|
||||
key={plugin.id}
|
||||
settingProps={{
|
||||
heading: `${plugin.name} ${
|
||||
plugin.version ? `(${plugin.version})` : undefined
|
||||
}`,
|
||||
subHeading: plugin.description,
|
||||
}}
|
||||
topLevel={renderLink(plugin.url ?? undefined)}
|
||||
>
|
||||
{renderPluginHooks(plugin.hooks ?? undefined)}
|
||||
</SettingGroup>
|
||||
));
|
||||
|
||||
return <div>{elements}</div>;
|
||||
}
|
||||
|
||||
function renderPluginHooks(
|
||||
hooks?: Pick<GQL.PluginHook, "name" | "description" | "hooks">[]
|
||||
) {
|
||||
if (!hooks || hooks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button className="minimal">
|
||||
<a
|
||||
href={TextUtils.sanitiseURL(url)}
|
||||
className="link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon icon="link" />
|
||||
</a>
|
||||
</Button>
|
||||
<div className="setting">
|
||||
<div>
|
||||
<h5>
|
||||
<FormattedMessage id="config.plugins.hooks" />
|
||||
</h5>
|
||||
{hooks.map((h) => (
|
||||
<div key={`${h.name}`}>
|
||||
<h6>{h.name}</h6>
|
||||
<CollapseButton
|
||||
text={intl.formatMessage({
|
||||
id: "config.plugins.triggers_on",
|
||||
})}
|
||||
>
|
||||
<ul>
|
||||
{h.hooks?.map((hh) => (
|
||||
<li key={hh}>
|
||||
<code>{hh}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CollapseButton>
|
||||
<small className="text-muted">{h.description}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlugins() {
|
||||
const elements = (data?.plugins ?? []).map((plugin) => (
|
||||
<div key={plugin.id}>
|
||||
<h4>
|
||||
{plugin.name} {plugin.version ? `(${plugin.version})` : undefined}{" "}
|
||||
{renderLink(plugin.url ?? undefined)}
|
||||
</h4>
|
||||
{plugin.description ? (
|
||||
<small className="text-muted">{plugin.description}</small>
|
||||
) : undefined}
|
||||
{renderPluginHooks(plugin.hooks ?? undefined)}
|
||||
<hr />
|
||||
</div>
|
||||
));
|
||||
|
||||
return <div>{elements}</div>;
|
||||
}
|
||||
|
||||
function renderPluginHooks(
|
||||
hooks?: Pick<GQL.PluginHook, "name" | "description" | "hooks">[]
|
||||
) {
|
||||
if (!hooks || hooks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<h5>
|
||||
<FormattedMessage id="config.plugins.hooks" />
|
||||
</h5>
|
||||
{hooks.map((h) => (
|
||||
<div key={`${h.name}`} className="mb-3">
|
||||
<h6>{h.name}</h6>
|
||||
<CollapseButton
|
||||
text={intl.formatMessage({ id: "config.plugins.triggers_on" })}
|
||||
>
|
||||
<ul>
|
||||
{h.hooks?.map((hh) => (
|
||||
<li key={hh}>
|
||||
<code>{hh}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CollapseButton>
|
||||
<small className="text-muted">{h.description}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return renderPlugins();
|
||||
}, [data?.plugins, intl]);
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<FormattedMessage id="config.categories.plugins" />
|
||||
</h3>
|
||||
<hr />
|
||||
{renderPlugins()}
|
||||
<Button onClick={() => onReloadPlugins()}>
|
||||
<span className="fa-icon">
|
||||
<Icon icon="sync-alt" />
|
||||
</span>
|
||||
<span>
|
||||
<FormattedMessage id="actions.reload_plugins" />
|
||||
</span>
|
||||
</Button>
|
||||
<SettingSection headingID="config.categories.plugins">
|
||||
<Setting headingID="actions.reload_plugins">
|
||||
<Button onClick={() => onReloadPlugins()}>
|
||||
<span className="fa-icon">
|
||||
<Icon icon="sync-alt" />
|
||||
</span>
|
||||
<span>
|
||||
<FormattedMessage id="actions.reload_plugins" />
|
||||
</span>
|
||||
</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 { Button, Form } from "react-bootstrap";
|
||||
import { Button } from "react-bootstrap";
|
||||
import {
|
||||
mutateReloadScrapers,
|
||||
useListMovieScrapers,
|
||||
useListPerformerScrapers,
|
||||
useListSceneScrapers,
|
||||
useListGalleryScrapers,
|
||||
useConfiguration,
|
||||
useConfigureScraping,
|
||||
} from "src/core/StashService";
|
||||
import { useToast } from "src/hooks";
|
||||
import { TextUtils } from "src/utils";
|
||||
import { CollapseButton, Icon, LoadingIndicator } from "src/components/Shared";
|
||||
import { ScrapeType } from "src/core/generated-graphql";
|
||||
import { StringListInput } from "../Shared/StringListInput";
|
||||
import { SettingSection } from "./SettingSection";
|
||||
import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs";
|
||||
import { SettingStateContext } from "./context";
|
||||
import { StashBoxSetting } from "./StashBoxConfiguration";
|
||||
|
||||
interface IURLList {
|
||||
urls: string[];
|
||||
@@ -90,58 +91,19 @@ export const SettingsScrapingPanel: React.FC = () => {
|
||||
loading: loadingMovies,
|
||||
} = useListMovieScrapers();
|
||||
|
||||
const [scraperUserAgent, setScraperUserAgent] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [scraperCDPPath, setScraperCDPPath] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [scraperCertCheck, setScraperCertCheck] = useState<boolean>(true);
|
||||
const [excludeTagPatterns, setExcludeTagPatterns] = useState<string[]>([]);
|
||||
|
||||
const { data, error } = useConfiguration();
|
||||
|
||||
const [updateScrapingConfig] = useConfigureScraping({
|
||||
scraperUserAgent,
|
||||
scraperCDPPath,
|
||||
scraperCertCheck,
|
||||
excludeTagPatterns,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.configuration || error) return;
|
||||
|
||||
const conf = data.configuration;
|
||||
if (conf.scraping) {
|
||||
setScraperUserAgent(conf.scraping.scraperUserAgent ?? undefined);
|
||||
setScraperCDPPath(conf.scraping.scraperCDPPath ?? undefined);
|
||||
setScraperCertCheck(conf.scraping.scraperCertCheck);
|
||||
setExcludeTagPatterns(conf.scraping.excludeTagPatterns);
|
||||
}
|
||||
}, [data, error]);
|
||||
const {
|
||||
general,
|
||||
scraping,
|
||||
loading,
|
||||
error,
|
||||
saveGeneral,
|
||||
saveScraping,
|
||||
} = React.useContext(SettingStateContext);
|
||||
|
||||
async function onReloadScrapers() {
|
||||
await mutateReloadScrapers().catch((e) => Toast.error(e));
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
try {
|
||||
await updateScrapingConfig();
|
||||
Toast.success({
|
||||
content: intl.formatMessage(
|
||||
{ id: "toast.updated_entity" },
|
||||
{
|
||||
entity: intl
|
||||
.formatMessage({ id: "configuration" })
|
||||
.toLocaleLowerCase(),
|
||||
}
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPerformerScrapeTypes(types: ScrapeType[]) {
|
||||
const typeStrings = types
|
||||
.filter((t) => t !== ScrapeType.Fragment)
|
||||
@@ -344,110 +306,76 @@ export const SettingsScrapingPanel: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingScenes || loadingGalleries || loadingPerformers || loadingMovies)
|
||||
if (error) return <h1>{error.message}</h1>;
|
||||
if (
|
||||
loading ||
|
||||
loadingScenes ||
|
||||
loadingGalleries ||
|
||||
loadingPerformers ||
|
||||
loadingMovies
|
||||
)
|
||||
return <LoadingIndicator />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Group>
|
||||
<h4>{intl.formatMessage({ id: "config.general.scraping" })}</h4>
|
||||
<Form.Group id="scraperUserAgent">
|
||||
<h6>
|
||||
{intl.formatMessage({ id: "config.general.scraper_user_agent" })}
|
||||
</h6>
|
||||
<Form.Control
|
||||
className="col col-sm-6 text-input"
|
||||
defaultValue={scraperUserAgent}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setScraperUserAgent(e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id: "config.general.scraper_user_agent_desc",
|
||||
})}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<StashBoxSetting
|
||||
value={general.stashBoxes ?? []}
|
||||
onChange={(v) => saveGeneral({ stashBoxes: v })}
|
||||
/>
|
||||
|
||||
<Form.Group id="scraperCDPPath">
|
||||
<h6>
|
||||
{intl.formatMessage({ id: "config.general.chrome_cdp_path" })}
|
||||
</h6>
|
||||
<Form.Control
|
||||
className="col col-sm-6 text-input"
|
||||
defaultValue={scraperCDPPath}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setScraperCDPPath(e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({ id: "config.general.chrome_cdp_path_desc" })}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id="scaper-cert-check"
|
||||
checked={scraperCertCheck}
|
||||
label={intl.formatMessage({
|
||||
id: "config.general.check_for_insecure_certificates",
|
||||
})}
|
||||
onChange={() => setScraperCertCheck(!scraperCertCheck)}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id: "config.general.check_for_insecure_certificates_desc",
|
||||
})}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group>
|
||||
<h6>
|
||||
{intl.formatMessage({
|
||||
id: "config.scraping.excluded_tag_patterns_head",
|
||||
})}
|
||||
</h6>
|
||||
<StringListInput
|
||||
className="w-50"
|
||||
value={excludeTagPatterns}
|
||||
setValue={setExcludeTagPatterns}
|
||||
defaultNewValue="4K"
|
||||
<SettingSection headingID="config.general.scraping">
|
||||
<StringSetting
|
||||
id="scraperUserAgent"
|
||||
headingID="config.general.scraper_user_agent"
|
||||
subHeadingID="config.general.scraper_user_agent_desc"
|
||||
value={scraping.scraperUserAgent ?? undefined}
|
||||
onChange={(v) => saveScraping({ scraperUserAgent: v })}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id: "config.scraping.excluded_tag_patterns_desc",
|
||||
})}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
<StringSetting
|
||||
id="scraperCDPPath"
|
||||
headingID="config.general.chrome_cdp_path"
|
||||
subHeadingID="config.general.chrome_cdp_path_desc"
|
||||
value={scraping.scraperCDPPath ?? undefined}
|
||||
onChange={(v) => saveScraping({ scraperCDPPath: v })}
|
||||
/>
|
||||
|
||||
<h4>{intl.formatMessage({ id: "config.scraping.scrapers" })}</h4>
|
||||
<BooleanSetting
|
||||
id="scraper-cert-check"
|
||||
headingID="config.general.check_for_insecure_certificates"
|
||||
subHeadingID="config.general.check_for_insecure_certificates_desc"
|
||||
checked={scraping.scraperCertCheck ?? undefined}
|
||||
onChange={(v) => saveScraping({ scraperCertCheck: v })}
|
||||
/>
|
||||
|
||||
<div className="mb-3">
|
||||
<Button onClick={() => onReloadScrapers()}>
|
||||
<span className="fa-icon">
|
||||
<Icon icon="sync-alt" />
|
||||
</span>
|
||||
<span>
|
||||
<FormattedMessage id="actions.reload_scrapers" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div>
|
||||
{renderSceneScrapers()}
|
||||
{renderGalleryScrapers()}
|
||||
{renderPerformerScrapers()}
|
||||
{renderMovieScrapers()}
|
||||
</div>
|
||||
<SettingSection headingID="config.scraping.scrapers">
|
||||
<div className="content">
|
||||
<Button onClick={() => onReloadScrapers()}>
|
||||
<span className="fa-icon">
|
||||
<Icon icon="sync-alt" />
|
||||
</span>
|
||||
<span>
|
||||
<FormattedMessage id="actions.reload_scrapers" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<Button variant="primary" onClick={() => onSave()}>
|
||||
<FormattedMessage id="actions.save" />
|
||||
</Button>
|
||||
<div className="content">
|
||||
{renderSceneScrapers()}
|
||||
{renderGalleryScrapers()}
|
||||
{renderPerformerScrapers()}
|
||||
{renderMovieScrapers()}
|
||||
</div>
|
||||
</SettingSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 { Formik, useFormikContext } from "formik";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { Prompt } from "react-router-dom";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import * as yup from "yup";
|
||||
import {
|
||||
useConfiguration,
|
||||
useConfigureDLNA,
|
||||
useDisableDLNA,
|
||||
useDLNAStatus,
|
||||
useEnableDLNA,
|
||||
@@ -15,12 +10,18 @@ import {
|
||||
} from "src/core/StashService";
|
||||
import { useToast } from "src/hooks";
|
||||
import { DurationInput, Icon, LoadingIndicator, Modal } from "../Shared";
|
||||
import { StringListInput } from "../Shared/StringListInput";
|
||||
import { SettingSection } from "./SettingSection";
|
||||
import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs";
|
||||
import { SettingStateContext } from "./context";
|
||||
|
||||
export const SettingsDLNAPanel: React.FC = () => {
|
||||
export const SettingsServicesPanel: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
|
||||
const { dlna, loading: configLoading, error, saveDLNA } = React.useContext(
|
||||
SettingStateContext
|
||||
);
|
||||
|
||||
// undefined to hide dialog, true for enable, false for disable
|
||||
const [enableDisable, setEnableDisable] = useState<boolean | undefined>(
|
||||
undefined
|
||||
@@ -34,64 +35,15 @@ export const SettingsDLNAPanel: React.FC = () => {
|
||||
const [ipEntry, setIPEntry] = useState<string>("");
|
||||
const [tempIP, setTempIP] = useState<string | undefined>();
|
||||
|
||||
const { data, refetch: configRefetch } = useConfiguration();
|
||||
const { data: statusData, loading, refetch: statusRefetch } = useDLNAStatus();
|
||||
|
||||
const [updateDLNAConfig] = useConfigureDLNA();
|
||||
|
||||
const [enableDLNA] = useEnableDLNA();
|
||||
const [disableDLNA] = useDisableDLNA();
|
||||
const [addTempDLANIP] = useAddTempDLNAIP();
|
||||
const [removeTempDLNAIP] = useRemoveTempDLNAIP();
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
|
||||
// settings
|
||||
const schema = yup.object({
|
||||
serverName: yup.string(),
|
||||
enabled: yup.boolean().required(),
|
||||
whitelistedIPs: yup.array(yup.string().required()).required(),
|
||||
interfaces: yup.array(yup.string().required()).required(),
|
||||
});
|
||||
|
||||
interface IConfigValues {
|
||||
serverName: string;
|
||||
enabled: boolean;
|
||||
whitelistedIPs: string[];
|
||||
interfaces: string[];
|
||||
}
|
||||
|
||||
const initialValues: IConfigValues = {
|
||||
serverName: data?.configuration.dlna.serverName ?? "",
|
||||
enabled: data?.configuration.dlna.enabled ?? false,
|
||||
whitelistedIPs: data?.configuration.dlna.whitelistedIPs ?? [],
|
||||
interfaces: data?.configuration.dlna.interfaces ?? [],
|
||||
};
|
||||
|
||||
async function onSave(input: IConfigValues) {
|
||||
try {
|
||||
await updateDLNAConfig({
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
configRefetch();
|
||||
Toast.success({
|
||||
content: intl.formatMessage(
|
||||
{ id: "toast.updated_entity" },
|
||||
{
|
||||
entity: intl
|
||||
.formatMessage({ id: "configuration" })
|
||||
.toLocaleLowerCase(),
|
||||
}
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
statusRefetch();
|
||||
}
|
||||
}
|
||||
if (error) return <h1>{error.message}</h1>;
|
||||
if (loading || configLoading) return <LoadingIndicator />;
|
||||
|
||||
async function onTempEnable() {
|
||||
const input = {
|
||||
@@ -185,13 +137,9 @@ export const SettingsDLNAPanel: React.FC = () => {
|
||||
}
|
||||
|
||||
function renderEnableButton() {
|
||||
if (!data?.configuration.dlna) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if enabled by default, then show the disable temporarily
|
||||
// if disabled by default, then show enable temporarily
|
||||
if (data?.configuration.dlna.enabled) {
|
||||
if (dlna.enabled) {
|
||||
return (
|
||||
<Button onClick={() => setEnableDisable(false)} className="mr-1">
|
||||
<FormattedMessage id="actions.temp_disable" />
|
||||
@@ -207,12 +155,12 @@ export const SettingsDLNAPanel: React.FC = () => {
|
||||
}
|
||||
|
||||
function canCancel() {
|
||||
if (!statusData || !data) {
|
||||
if (!statusData || !dlna) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { dlnaStatus } = statusData;
|
||||
const { enabled } = data.configuration.dlna;
|
||||
const { enabled } = dlna;
|
||||
|
||||
return dlnaStatus.until || dlnaStatus.running !== enabled;
|
||||
}
|
||||
@@ -348,7 +296,7 @@ export const SettingsDLNAPanel: React.FC = () => {
|
||||
|
||||
const { allowedIPAddresses } = statusData.dlnaStatus;
|
||||
return (
|
||||
<Form.Group>
|
||||
<Form.Group className="content">
|
||||
<h6>
|
||||
{intl.formatMessage({ id: "config.dlna.allowed_ip_addresses" })}
|
||||
</h6>
|
||||
@@ -429,93 +377,47 @@ export const SettingsDLNAPanel: React.FC = () => {
|
||||
}
|
||||
|
||||
const DLNASettingsForm: React.FC = () => {
|
||||
const {
|
||||
handleSubmit,
|
||||
values,
|
||||
setFieldValue,
|
||||
dirty,
|
||||
} = useFormikContext<IConfigValues>();
|
||||
|
||||
return (
|
||||
<Form noValidate onSubmit={handleSubmit}>
|
||||
<Prompt
|
||||
when={dirty}
|
||||
message={intl.formatMessage({ id: "dialogs.unsaved_changes" })}
|
||||
/>
|
||||
<>
|
||||
<SettingSection headingID="settings">
|
||||
<StringSetting
|
||||
headingID="config.dlna.server_display_name"
|
||||
subHeading={intl.formatMessage(
|
||||
{ id: "config.dlna.server_display_name_desc" },
|
||||
{ server_name: <code>stash</code> }
|
||||
)}
|
||||
value={dlna.serverName ?? undefined}
|
||||
onChange={(v) => saveDLNA({ serverName: v })}
|
||||
/>
|
||||
|
||||
<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" },
|
||||
{ server_name: <code>stash</code> }
|
||||
)}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id="dlna-enabled-by-default"
|
||||
checked={values.enabled}
|
||||
label={intl.formatMessage({
|
||||
id: "config.dlna.enabled_by_default",
|
||||
})}
|
||||
onChange={() => setFieldValue("enabled", !values.enabled)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<BooleanSetting
|
||||
id="dlna-enabled-by-default"
|
||||
headingID="config.dlna.enabled_by_default"
|
||||
checked={dlna.enabled ?? undefined}
|
||||
onChange={(v) => saveDLNA({ enabled: v })}
|
||||
/>
|
||||
|
||||
<Form.Group>
|
||||
<h6>
|
||||
{intl.formatMessage({ id: "config.dlna.network_interfaces" })}
|
||||
</h6>
|
||||
<StringListInput
|
||||
value={values.interfaces}
|
||||
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>
|
||||
<StringListSetting
|
||||
id="dlna-network-interfaces"
|
||||
headingID="config.dlna.network_interfaces"
|
||||
subHeadingID="config.dlna.network_interfaces_desc"
|
||||
value={dlna.interfaces ?? undefined}
|
||||
onChange={(v) => saveDLNA({ interfaces: v })}
|
||||
/>
|
||||
|
||||
<Form.Group>
|
||||
<h6>
|
||||
{intl.formatMessage({ id: "config.dlna.default_ip_whitelist" })}
|
||||
</h6>
|
||||
<StringListInput
|
||||
value={values.whitelistedIPs}
|
||||
setValue={(value) => setFieldValue("whitelistedIPs", value)}
|
||||
defaultNewValue="*"
|
||||
className="ip-whitelist-input"
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage(
|
||||
{ id: "config.dlna.default_ip_whitelist_desc" },
|
||||
{ wildcard: <code>*</code> }
|
||||
)}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
|
||||
<Button variant="primary" type="submit" disabled={!dirty}>
|
||||
<FormattedMessage id="actions.save" />
|
||||
</Button>
|
||||
</Form>
|
||||
<StringListSetting
|
||||
id="dlna-default-ip-whitelist"
|
||||
headingID="config.dlna.default_ip_whitelist"
|
||||
subHeading={intl.formatMessage(
|
||||
{ id: "config.dlna.default_ip_whitelist_desc" },
|
||||
{ wildcard: <code>*</code> }
|
||||
)}
|
||||
defaultNewValue="*"
|
||||
value={dlna.whitelistedIPs ?? undefined}
|
||||
onChange={(v) => saveDLNA({ whitelistedIPs: v })}
|
||||
/>
|
||||
</SettingSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -532,17 +434,15 @@ export const SettingsDLNAPanel: React.FC = () => {
|
||||
</h5>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group>
|
||||
<h5>{intl.formatMessage({ id: "actions_name" })}</h5>
|
||||
|
||||
<Form.Group>
|
||||
<SettingSection headingID="actions_name">
|
||||
<Form.Group className="content">
|
||||
{renderEnableButton()}
|
||||
{renderTempCancelButton()}
|
||||
</Form.Group>
|
||||
|
||||
{renderAllowedIPs()}
|
||||
|
||||
<Form.Group>
|
||||
<Form.Group className="content">
|
||||
<h6>
|
||||
{intl.formatMessage({ id: "config.dlna.recent_ip_addresses" })}
|
||||
</h6>
|
||||
@@ -553,18 +453,9 @@ export const SettingsDLNAPanel: React.FC = () => {
|
||||
</Button>
|
||||
</Form.Group>
|
||||
</Form.Group>
|
||||
</Form.Group>
|
||||
</SettingSection>
|
||||
|
||||
<hr />
|
||||
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={schema}
|
||||
onSubmit={(values) => onSave(values)}
|
||||
enableReinitialize
|
||||
>
|
||||
<DLNASettingsForm />
|
||||
</Formik>
|
||||
<DLNASettingsForm />
|
||||
</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 { Form } from "react-bootstrap";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Setting } from "./Inputs";
|
||||
import { SettingSection } from "./SettingSection";
|
||||
|
||||
export const SettingsToolsPanel: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<h4>
|
||||
<FormattedMessage id="config.tools.scene_tools" />
|
||||
</h4>
|
||||
<SettingSection headingID="config.tools.scene_tools">
|
||||
<Setting
|
||||
heading={
|
||||
<Link to="/sceneFilenameParser">
|
||||
<Button>
|
||||
<FormattedMessage id="config.tools.scene_filename_parser.title" />
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.Group>
|
||||
<Link to="/sceneFilenameParser">
|
||||
<FormattedMessage id="config.tools.scene_filename_parser.title" />
|
||||
</Link>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group>
|
||||
<Link to="/sceneDuplicateChecker">
|
||||
<FormattedMessage id="config.tools.scene_duplicate_checker" />
|
||||
</Link>
|
||||
</Form.Group>
|
||||
<Setting
|
||||
heading={
|
||||
<Link to="/sceneDuplicateChecker">
|
||||
<Button>
|
||||
<FormattedMessage id="config.tools.scene_duplicate_checker" />
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</SettingSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,142 +1,172 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Form, InputGroup } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { Icon } from "src/components/Shared";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { SettingSection } from "./SettingSection";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { SettingModal } from "./Inputs";
|
||||
|
||||
interface IInstanceProps {
|
||||
instance: IStashBoxInstance;
|
||||
onSave: (instance: IStashBoxInstance) => void;
|
||||
onDelete: (id: number) => void;
|
||||
isMulti: boolean;
|
||||
export interface IStashBoxModal {
|
||||
value: GQL.StashBoxInput;
|
||||
close: (v?: GQL.StashBoxInput) => void;
|
||||
}
|
||||
|
||||
const Instance: React.FC<IInstanceProps> = ({
|
||||
instance,
|
||||
onSave,
|
||||
onDelete,
|
||||
isMulti,
|
||||
}) => {
|
||||
export const StashBoxModal: React.FC<IStashBoxModal> = ({ value, close }) => {
|
||||
const intl = useIntl();
|
||||
const handleInput = (key: string, value: string) => {
|
||||
const newObj = {
|
||||
...instance,
|
||||
[key]: value,
|
||||
};
|
||||
onSave(newObj);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Group className="row no-gutters">
|
||||
<InputGroup className="col">
|
||||
<Form.Control
|
||||
placeholder={intl.formatMessage({ id: "config.stashbox.name" })}
|
||||
className="text-input col-3 stash-box-name"
|
||||
value={instance?.name}
|
||||
isValid={!isMulti || (instance?.name?.length ?? 0) > 0}
|
||||
onInput={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
handleInput("name", e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<Form.Control
|
||||
placeholder={intl.formatMessage({
|
||||
id: "config.stashbox.graphql_endpoint",
|
||||
})}
|
||||
className="text-input col-3 stash-box-endpoint"
|
||||
value={instance?.endpoint}
|
||||
isValid={(instance?.endpoint?.length ?? 0) > 0}
|
||||
onInput={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
handleInput("endpoint", e.currentTarget.value.trim())
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
<SettingModal<GQL.StashBoxInput>
|
||||
headingID="config.stashbox.title"
|
||||
value={value}
|
||||
renderField={(v, setValue) => (
|
||||
<>
|
||||
<Form.Group id="stashbox-name">
|
||||
<h6>
|
||||
{intl.formatMessage({
|
||||
id: "config.stashbox.name",
|
||||
})}
|
||||
</h6>
|
||||
<Form.Control
|
||||
placeholder={intl.formatMessage({ id: "config.stashbox.name" })}
|
||||
className="text-input stash-box-name"
|
||||
value={v?.name}
|
||||
isValid={(v?.name?.length ?? 0) > 0}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setValue({ ...v!, name: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
interface IStashBoxConfigurationProps {
|
||||
boxes: IStashBoxInstance[];
|
||||
saveBoxes: (boxes: IStashBoxInstance[]) => void;
|
||||
}
|
||||
<Form.Group id="stashbox-name">
|
||||
<h6>
|
||||
{intl.formatMessage({
|
||||
id: "config.stashbox.graphql_endpoint",
|
||||
})}
|
||||
</h6>
|
||||
<Form.Control
|
||||
placeholder={intl.formatMessage({
|
||||
id: "config.stashbox.graphql_endpoint",
|
||||
})}
|
||||
className="text-input stash-box-endpoint"
|
||||
value={v?.endpoint}
|
||||
isValid={(v?.endpoint?.length ?? 0) > 0}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setValue({ ...v!, endpoint: e.currentTarget.value.trim() })
|
||||
}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
export interface IStashBoxInstance {
|
||||
name?: string;
|
||||
endpoint?: string;
|
||||
api_key?: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const StashBoxConfiguration: React.FC<IStashBoxConfigurationProps> = ({
|
||||
boxes,
|
||||
saveBoxes,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [index, setIndex] = useState(1000);
|
||||
|
||||
const handleSave = (instance: IStashBoxInstance) =>
|
||||
saveBoxes(
|
||||
boxes.map((box) => (box.index === instance.index ? instance : box))
|
||||
);
|
||||
const handleDelete = (id: number) =>
|
||||
saveBoxes(boxes.filter((box) => box.index !== id));
|
||||
const handleAdd = () => {
|
||||
saveBoxes([...boxes, { index }]);
|
||||
setIndex(index + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
<h6>{intl.formatMessage({ id: "config.stashbox.title" })}</h6>
|
||||
{boxes.length > 0 && (
|
||||
<div className="row no-gutters">
|
||||
<h6 className="col-3 ml-1">
|
||||
{intl.formatMessage({ id: "config.stashbox.name" })}
|
||||
</h6>
|
||||
<h6 className="col-3 ml-1">
|
||||
{intl.formatMessage({ id: "config.stashbox.endpoint" })}
|
||||
</h6>
|
||||
<h6 className="col-3 ml-1">
|
||||
{intl.formatMessage({ id: "config.general.auth.api_key" })}
|
||||
</h6>
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{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" />
|
||||
</Button>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({ id: "config.stashbox.description" })}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
close={close}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStashBoxSetting {
|
||||
value: GQL.StashBoxInput[];
|
||||
onChange: (v: GQL.StashBoxInput[]) => void;
|
||||
}
|
||||
|
||||
export const StashBoxSetting: React.FC<IStashBoxSetting> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingIndex, setEditingIndex] = useState<number | undefined>();
|
||||
|
||||
function onEdit(index: number) {
|
||||
setEditingIndex(index);
|
||||
}
|
||||
|
||||
function onDelete(index: number) {
|
||||
onChange(value.filter((v, i) => i !== index));
|
||||
}
|
||||
|
||||
function onNew() {
|
||||
setIsCreating(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingSection
|
||||
id="stash-boxes"
|
||||
headingID="config.stashbox.title"
|
||||
subHeadingID="config.stashbox.description"
|
||||
>
|
||||
{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 variant="danger" onClick={() => onDelete(index)}>
|
||||
<FormattedMessage id="actions.delete" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="setting">
|
||||
<div />
|
||||
<div>
|
||||
<Button onClick={() => onNew()}>
|
||||
<FormattedMessage id="actions.add" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Form, Row, Col } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Button, Form, Row, Col, Dropdown } from "react-bootstrap";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Icon } from "src/components/Shared";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { FolderSelectDialog } from "../Shared/FolderSelect/FolderSelectDialog";
|
||||
import { BooleanSetting } from "./Inputs";
|
||||
import { SettingSection } from "./SettingSection";
|
||||
|
||||
interface IStashProps {
|
||||
index: number;
|
||||
stash: GQL.StashConfig;
|
||||
onSave: (instance: GQL.StashConfig) => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const Stash: React.FC<IStashProps> = ({ index, stash, onSave, onDelete }) => {
|
||||
const Stash: React.FC<IStashProps> = ({
|
||||
index,
|
||||
stash,
|
||||
onSave,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
// eslint-disable-next-line
|
||||
const handleInput = (key: string, value: any) => {
|
||||
const newObj = {
|
||||
@@ -22,38 +31,58 @@ const Stash: React.FC<IStashProps> = ({ index, stash, onSave, onDelete }) => {
|
||||
onSave(newObj);
|
||||
};
|
||||
|
||||
const intl = useIntl();
|
||||
const classAdd = index % 2 === 1 ? "bg-dark" : "";
|
||||
|
||||
return (
|
||||
<Row className={`align-items-center ${classAdd}`}>
|
||||
<Form.Label column xs={4}>
|
||||
<Row className={`stash-row align-items-center ${classAdd}`}>
|
||||
<Form.Label column md={7}>
|
||||
{stash.path}
|
||||
</Form.Label>
|
||||
<Col xs={3}>
|
||||
<Form.Check
|
||||
id="stash-exclude-video"
|
||||
checked={stash.excludeVideo}
|
||||
onChange={() => handleInput("excludeVideo", !stash.excludeVideo)}
|
||||
/>
|
||||
<Col md={2} xs={4} className="col form-label">
|
||||
{/* NOTE - language is opposite to meaning:
|
||||
internally exclude flags, displayed as include */}
|
||||
<div>
|
||||
<h6 className="d-md-none">
|
||||
<FormattedMessage id="videos" />
|
||||
</h6>
|
||||
<BooleanSetting
|
||||
id={`stash-exclude-video-${index}`}
|
||||
checked={!stash.excludeVideo}
|
||||
onChange={(v) => handleInput("excludeVideo", !v)}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col xs={3}>
|
||||
<Form.Check
|
||||
id="stash-exclude-image"
|
||||
checked={stash.excludeImage}
|
||||
onChange={() => handleInput("excludeImage", !stash.excludeImage)}
|
||||
/>
|
||||
<Col md={2} xs={4} className="col-form-label">
|
||||
<div>
|
||||
<h6 className="d-md-none">
|
||||
<FormattedMessage id="images" />
|
||||
</h6>
|
||||
<BooleanSetting
|
||||
id={`stash-exclude-image-${index}`}
|
||||
checked={!stash.excludeImage}
|
||||
onChange={(v) => handleInput("excludeImage", !v)}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
title={intl.formatMessage({ id: "actions.delete" })}
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
<Icon icon="minus" />
|
||||
</Button>
|
||||
<Col className="justify-content-end" xs={4} md={1}>
|
||||
<Dropdown className="text-right">
|
||||
<Dropdown.Toggle
|
||||
variant="minimal"
|
||||
id={`stash-menu-${index}`}
|
||||
className="minimal"
|
||||
>
|
||||
<Icon icon="ellipsis-v" />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="bg-secondary text-white">
|
||||
<Dropdown.Item onClick={() => onEdit()}>
|
||||
<FormattedMessage id="actions.edit" />
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={() => onDelete()}>
|
||||
<FormattedMessage id="actions.delete" />
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
@@ -68,51 +97,75 @@ const StashConfiguration: React.FC<IStashConfigurationProps> = ({
|
||||
stashes,
|
||||
setStashes,
|
||||
}) => {
|
||||
const [isDisplayingDialog, setIsDisplayingDialog] = useState<boolean>(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingIndex, setEditingIndex] = useState<number | undefined>();
|
||||
|
||||
function onEdit(index: number) {
|
||||
setEditingIndex(index);
|
||||
}
|
||||
|
||||
function onDelete(index: number) {
|
||||
setStashes(stashes.filter((v, i) => i !== index));
|
||||
}
|
||||
|
||||
function onNew() {
|
||||
setIsCreating(true);
|
||||
}
|
||||
|
||||
const handleSave = (index: number, stash: GQL.StashConfig) =>
|
||||
setStashes(stashes.map((s, i) => (i === index ? stash : s)));
|
||||
const handleDelete = (index: number) =>
|
||||
setStashes(stashes.filter((s, i) => i !== index));
|
||||
const handleAdd = (folder?: string) => {
|
||||
setIsDisplayingDialog(false);
|
||||
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStashes([
|
||||
...stashes,
|
||||
{
|
||||
path: folder,
|
||||
excludeImage: false,
|
||||
excludeVideo: false,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
function maybeRenderDialog() {
|
||||
if (!isDisplayingDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
return <FolderSelectDialog onClose={handleAdd} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{maybeRenderDialog()}
|
||||
<Form.Group>
|
||||
{isCreating ? (
|
||||
<FolderSelectDialog
|
||||
onClose={(v) => {
|
||||
if (v)
|
||||
setStashes([
|
||||
...stashes,
|
||||
{
|
||||
path: v,
|
||||
excludeVideo: false,
|
||||
excludeImage: false,
|
||||
},
|
||||
]);
|
||||
setIsCreating(false);
|
||||
}}
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
{editingIndex !== undefined ? (
|
||||
<FolderSelectDialog
|
||||
defaultValue={stashes[editingIndex].path}
|
||||
onClose={(v) => {
|
||||
if (v)
|
||||
setStashes(
|
||||
stashes.map((vv, index) => {
|
||||
if (index === editingIndex) {
|
||||
return {
|
||||
...vv,
|
||||
path: v,
|
||||
};
|
||||
}
|
||||
return vv;
|
||||
})
|
||||
);
|
||||
setEditingIndex(undefined);
|
||||
}}
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
<div className="content" id="stash-table">
|
||||
{stashes.length > 0 && (
|
||||
<Row>
|
||||
<h6 className="col-4">
|
||||
<Row className="d-none d-md-flex">
|
||||
<h6 className="col-md-7">
|
||||
<FormattedMessage id="path" />
|
||||
</h6>
|
||||
<h6 className="col-3">
|
||||
<FormattedMessage id="config.general.exclude_video" />
|
||||
<h6 className="col-md-2 col-4">
|
||||
<FormattedMessage id="videos" />
|
||||
</h6>
|
||||
<h6 className="col-3">
|
||||
<FormattedMessage id="config.general.exclude_image" />
|
||||
<h6 className="col-md-2 col-4">
|
||||
<FormattedMessage id="images" />
|
||||
</h6>
|
||||
</Row>
|
||||
)}
|
||||
@@ -121,20 +174,34 @@ const StashConfiguration: React.FC<IStashConfigurationProps> = ({
|
||||
index={index}
|
||||
stash={stash}
|
||||
onSave={(s) => handleSave(index, s)}
|
||||
onDelete={() => handleDelete(index)}
|
||||
onEdit={() => onEdit(index)}
|
||||
onDelete={() => onDelete(index)}
|
||||
key={stash.path}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
className="mt-2"
|
||||
variant="secondary"
|
||||
onClick={() => setIsDisplayingDialog(true)}
|
||||
>
|
||||
<Button className="mt-2" variant="secondary" onClick={() => onNew()}>
|
||||
<FormattedMessage id="actions.add_directory" />
|
||||
</Button>
|
||||
</Form.Group>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStashSetting {
|
||||
value: GQL.StashConfigInput[];
|
||||
onChange: (v: GQL.StashConfigInput[]) => void;
|
||||
}
|
||||
|
||||
export const StashSetting: React.FC<IStashSetting> = ({ value, onChange }) => {
|
||||
return (
|
||||
<SettingSection
|
||||
id="stashes"
|
||||
headingID="library"
|
||||
subHeadingID="config.general.directory_locations_to_your_content"
|
||||
>
|
||||
<StashConfiguration stashes={value} setStashes={(v) => onChange(v)} />
|
||||
</SettingSection>
|
||||
);
|
||||
};
|
||||
|
||||
export default StashConfiguration;
|
||||
|
||||
@@ -12,8 +12,11 @@ import { useToast } from "src/hooks";
|
||||
import { downloadFile } from "src/utils";
|
||||
import { Modal } from "../../Shared";
|
||||
import { ImportDialog } from "./ImportDialog";
|
||||
import { Task } from "./Task";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { SettingSection } from "../SettingSection";
|
||||
import { BooleanSetting, Setting } from "../Inputs";
|
||||
import { ManualLink } from "src/components/Help/Manual";
|
||||
import { Icon } from "src/components/Shared";
|
||||
|
||||
interface ICleanOptions {
|
||||
options: GQL.CleanMetadataInput;
|
||||
@@ -24,21 +27,19 @@ const CleanOptions: React.FC<ICleanOptions> = ({
|
||||
options,
|
||||
setOptions: setOptionsState,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
function setOptions(input: Partial<GQL.CleanMetadataInput>) {
|
||||
setOptionsState({ ...options, ...input });
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
<>
|
||||
<BooleanSetting
|
||||
id="clean-dryrun"
|
||||
checked={options.dryRun}
|
||||
label={intl.formatMessage({ id: "config.tasks.only_dry_run" })}
|
||||
onChange={() => setOptions({ dryRun: !options.dryRun })}
|
||||
headingID="config.tasks.only_dry_run"
|
||||
onChange={(v) => setOptions({ dryRun: v })}
|
||||
/>
|
||||
</Form.Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -213,19 +214,19 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
|
||||
{renderImportDialog()}
|
||||
{renderCleanDialog()}
|
||||
|
||||
<Form.Group>
|
||||
<div className="task-group">
|
||||
<h5>{intl.formatMessage({ id: "config.tasks.maintenance" })}</h5>
|
||||
<Task
|
||||
headingID="actions.clean"
|
||||
description={intl.formatMessage({
|
||||
id: "config.tasks.cleanup_desc",
|
||||
})}
|
||||
<SettingSection headingID="config.tasks.maintenance">
|
||||
<div className="setting-group">
|
||||
<Setting
|
||||
heading={
|
||||
<>
|
||||
<FormattedMessage id="actions.clean" />
|
||||
<ManualLink tab="Tasks">
|
||||
<Icon icon="question-circle" />
|
||||
</ManualLink>
|
||||
</>
|
||||
}
|
||||
subHeadingID="config.tasks.cleanup_desc"
|
||||
>
|
||||
<CleanOptions
|
||||
options={cleanOptions}
|
||||
setOptions={(o) => setCleanOptions(o)}
|
||||
/>
|
||||
<Button
|
||||
variant="danger"
|
||||
type="submit"
|
||||
@@ -233,127 +234,111 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
|
||||
>
|
||||
<FormattedMessage id="actions.clean" />…
|
||||
</Button>
|
||||
</Task>
|
||||
</Setting>
|
||||
<CleanOptions
|
||||
options={cleanOptions}
|
||||
setOptions={(o) => setCleanOptions(o)}
|
||||
/>
|
||||
</div>
|
||||
</Form.Group>
|
||||
</SettingSection>
|
||||
|
||||
<hr />
|
||||
|
||||
<Form.Group>
|
||||
<h5>{intl.formatMessage({ id: "metadata" })}</h5>
|
||||
<div className="task-group">
|
||||
<Task
|
||||
description={intl.formatMessage({
|
||||
id: "config.tasks.export_to_json",
|
||||
})}
|
||||
<SettingSection headingID="metadata">
|
||||
<Setting
|
||||
headingID="actions.full_export"
|
||||
subHeadingID="config.tasks.export_to_json"
|
||||
>
|
||||
<Button
|
||||
id="export"
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => onExport()}
|
||||
>
|
||||
<Button
|
||||
id="export"
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => onExport()}
|
||||
>
|
||||
<FormattedMessage id="actions.full_export" />
|
||||
</Button>
|
||||
</Task>
|
||||
<FormattedMessage id="actions.full_export" />
|
||||
</Button>
|
||||
</Setting>
|
||||
|
||||
<Task
|
||||
description={intl.formatMessage({
|
||||
id: "config.tasks.import_from_exported_json",
|
||||
})}
|
||||
<Setting
|
||||
headingID="actions.full_import"
|
||||
subHeadingID="config.tasks.import_from_exported_json"
|
||||
>
|
||||
<Button
|
||||
id="import"
|
||||
variant="danger"
|
||||
type="submit"
|
||||
onClick={() => setDialogOpen({ importAlert: true })}
|
||||
>
|
||||
<Button
|
||||
id="import"
|
||||
variant="danger"
|
||||
type="submit"
|
||||
onClick={() => setDialogOpen({ importAlert: true })}
|
||||
>
|
||||
<FormattedMessage id="actions.full_import" />
|
||||
</Button>
|
||||
</Task>
|
||||
<FormattedMessage id="actions.full_import" />
|
||||
</Button>
|
||||
</Setting>
|
||||
|
||||
<Task
|
||||
description={intl.formatMessage({
|
||||
id: "config.tasks.incremental_import",
|
||||
})}
|
||||
<Setting
|
||||
headingID="actions.import_from_file"
|
||||
subHeadingID="config.tasks.incremental_import"
|
||||
>
|
||||
<Button
|
||||
id="partial-import"
|
||||
variant="danger"
|
||||
type="submit"
|
||||
onClick={() => setDialogOpen({ import: true })}
|
||||
>
|
||||
<Button
|
||||
id="partial-import"
|
||||
variant="danger"
|
||||
type="submit"
|
||||
onClick={() => setDialogOpen({ import: true })}
|
||||
>
|
||||
<FormattedMessage id="actions.import_from_file" />
|
||||
</Button>
|
||||
</Task>
|
||||
</div>
|
||||
</Form.Group>
|
||||
<FormattedMessage id="actions.import_from_file" />
|
||||
</Button>
|
||||
</Setting>
|
||||
</SettingSection>
|
||||
|
||||
<hr />
|
||||
|
||||
<Form.Group>
|
||||
<h5>{intl.formatMessage({ id: "actions.backup" })}</h5>
|
||||
<div className="task-group">
|
||||
<Task
|
||||
description={intl.formatMessage(
|
||||
{ id: "config.tasks.backup_database" },
|
||||
{
|
||||
filename_format: (
|
||||
<code>
|
||||
[origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS]
|
||||
</code>
|
||||
),
|
||||
}
|
||||
)}
|
||||
<SettingSection headingID="actions.backup">
|
||||
<Setting
|
||||
headingID="actions.backup"
|
||||
subHeading={intl.formatMessage(
|
||||
{ id: "config.tasks.backup_database" },
|
||||
{
|
||||
filename_format: (
|
||||
<code>
|
||||
[origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS]
|
||||
</code>
|
||||
),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
id="backup"
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => onBackup()}
|
||||
>
|
||||
<Button
|
||||
id="backup"
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => onBackup()}
|
||||
>
|
||||
<FormattedMessage id="actions.backup" />
|
||||
</Button>
|
||||
</Task>
|
||||
<FormattedMessage id="actions.backup" />
|
||||
</Button>
|
||||
</Setting>
|
||||
|
||||
<Task
|
||||
description={intl.formatMessage({
|
||||
id: "config.tasks.backup_and_download",
|
||||
})}
|
||||
<Setting
|
||||
headingID="actions.download_backup"
|
||||
subHeadingID="config.tasks.backup_and_download"
|
||||
>
|
||||
<Button
|
||||
id="backupDownload"
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => onBackup(true)}
|
||||
>
|
||||
<Button
|
||||
id="backupDownload"
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => onBackup(true)}
|
||||
>
|
||||
<FormattedMessage id="actions.download_backup" />
|
||||
</Button>
|
||||
</Task>
|
||||
</div>
|
||||
</Form.Group>
|
||||
<FormattedMessage id="actions.download_backup" />
|
||||
</Button>
|
||||
</Setting>
|
||||
</SettingSection>
|
||||
|
||||
<hr />
|
||||
|
||||
<Form.Group>
|
||||
<h5>{intl.formatMessage({ id: "config.tasks.migrations" })}</h5>
|
||||
|
||||
<div className="task-group">
|
||||
<Task
|
||||
description={intl.formatMessage({
|
||||
id: "config.tasks.migrate_hash_files",
|
||||
})}
|
||||
<SettingSection headingID="config.tasks.migrations">
|
||||
<Setting
|
||||
headingID="actions.rename_gen_files"
|
||||
subHeadingID="config.tasks.migrate_hash_files"
|
||||
>
|
||||
<Button
|
||||
id="migrateHashNaming"
|
||||
variant="danger"
|
||||
onClick={() => onMigrateHashNaming()}
|
||||
>
|
||||
<Button
|
||||
id="migrateHashNaming"
|
||||
variant="danger"
|
||||
onClick={() => onMigrateHashNaming()}
|
||||
>
|
||||
<FormattedMessage id="actions.rename_gen_files" />
|
||||
</Button>
|
||||
</Task>
|
||||
</div>
|
||||
</Form.Group>
|
||||
<FormattedMessage id="actions.rename_gen_files" />
|
||||
</Button>
|
||||
</Setting>
|
||||
</SettingSection>
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { useState } from "react";
|
||||
import { Form, Button, Collapse } from "react-bootstrap";
|
||||
import { Icon } from "src/components/Shared";
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { BooleanSetting, ModalSetting } from "../Inputs";
|
||||
import {
|
||||
VideoPreviewInput,
|
||||
VideoPreviewSettingsInput,
|
||||
} from "../GeneratePreviewOptions";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
interface IGenerateOptions {
|
||||
@@ -15,8 +18,6 @@ export const GenerateOptions: React.FC<IGenerateOptions> = ({
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [previewOptionsOpen, setPreviewOptionsOpen] = useState(false);
|
||||
|
||||
const previewOptions: GQL.GeneratePreviewOptionsInput =
|
||||
options.previewOptions ?? {};
|
||||
|
||||
@@ -24,275 +25,110 @@ export const GenerateOptions: React.FC<IGenerateOptions> = ({
|
||||
setOptionsState({ ...options, ...input });
|
||||
}
|
||||
|
||||
function setPreviewOptions(input: Partial<GQL.GeneratePreviewOptionsInput>) {
|
||||
setOptions({
|
||||
previewOptions: {
|
||||
...previewOptions,
|
||||
...input,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id="preview-task"
|
||||
checked={options.previews ?? false}
|
||||
label={intl.formatMessage({
|
||||
id: "dialogs.scene_gen.video_previews",
|
||||
})}
|
||||
onChange={() => setOptions({ previews: !options.previews })}
|
||||
/>
|
||||
<div className="d-flex flex-row">
|
||||
<div>↳</div>
|
||||
<Form.Check
|
||||
id="image-preview-task"
|
||||
checked={options.imagePreviews ?? false}
|
||||
disabled={!options.previews}
|
||||
label={intl.formatMessage({
|
||||
id: "dialogs.scene_gen.image_previews",
|
||||
})}
|
||||
onChange={() =>
|
||||
setOptions({ imagePreviews: !options.imagePreviews })
|
||||
}
|
||||
className="ml-2 flex-grow"
|
||||
/>
|
||||
</div>
|
||||
</Form.Group>
|
||||
<>
|
||||
<BooleanSetting
|
||||
id="preview-task"
|
||||
checked={options.previews ?? false}
|
||||
headingID="dialogs.scene_gen.video_previews"
|
||||
tooltipID="dialogs.scene_gen.video_previews_tooltip"
|
||||
onChange={(v) => setOptions({ previews: v })}
|
||||
/>
|
||||
<BooleanSetting
|
||||
className="sub-setting"
|
||||
id="image-preview-task"
|
||||
checked={options.imagePreviews ?? false}
|
||||
disabled={!options.previews}
|
||||
headingID="dialogs.scene_gen.image_previews"
|
||||
tooltipID="dialogs.scene_gen.image_previews_tooltip"
|
||||
onChange={(v) => setOptions({ imagePreviews: v })}
|
||||
/>
|
||||
|
||||
<Form.Group>
|
||||
<Button
|
||||
onClick={() => setPreviewOptionsOpen(!previewOptionsOpen)}
|
||||
className="minimal pl-0 no-focus"
|
||||
>
|
||||
<Icon icon={previewOptionsOpen ? "chevron-down" : "chevron-right"} />
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
id: "dialogs.scene_gen.preview_options",
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
<Form.Group>
|
||||
<Collapse in={previewOptionsOpen}>
|
||||
<Form.Group className="mt-2">
|
||||
<Form.Group id="preview-preset">
|
||||
<h6>
|
||||
{intl.formatMessage({
|
||||
id: "dialogs.scene_gen.preview_preset_head",
|
||||
})}
|
||||
</h6>
|
||||
<Form.Control
|
||||
className="w-auto input-control"
|
||||
as="select"
|
||||
value={previewOptions.previewPreset ?? GQL.PreviewPreset.Slow}
|
||||
onChange={(e) =>
|
||||
setPreviewOptions({
|
||||
previewPreset: e.currentTarget.value as GQL.PreviewPreset,
|
||||
})
|
||||
}
|
||||
>
|
||||
{Object.keys(GQL.PreviewPreset).map((p) => (
|
||||
<option value={p.toLowerCase()} key={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id: "dialogs.scene_gen.preview_preset_desc",
|
||||
})}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<ModalSetting<VideoPreviewSettingsInput>
|
||||
id="video-preview-settings"
|
||||
className="sub-setting"
|
||||
disabled={!options.previews}
|
||||
buttonText={`${intl.formatMessage({
|
||||
id: "dialogs.scene_gen.preview_generation_options",
|
||||
})}…`}
|
||||
value={{
|
||||
previewExcludeEnd: previewOptions.previewExcludeEnd,
|
||||
previewExcludeStart: previewOptions.previewExcludeStart,
|
||||
previewSegmentDuration: previewOptions.previewSegmentDuration,
|
||||
previewSegments: previewOptions.previewSegments,
|
||||
}}
|
||||
onChange={(v) => setOptions({ previewOptions: v })}
|
||||
renderField={(value, setValue) => (
|
||||
<VideoPreviewInput value={value ?? {}} setValue={setValue} />
|
||||
)}
|
||||
renderValue={() => {
|
||||
return <></>;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form.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>
|
||||
<BooleanSetting
|
||||
id="sprite-task"
|
||||
checked={options.sprites ?? false}
|
||||
headingID="dialogs.scene_gen.sprites"
|
||||
tooltipID="dialogs.scene_gen.sprites_tooltip"
|
||||
onChange={(v) => setOptions({ sprites: v })}
|
||||
/>
|
||||
<BooleanSetting
|
||||
id="marker-task"
|
||||
checked={options.markers ?? false}
|
||||
headingID="dialogs.scene_gen.markers"
|
||||
tooltipID="dialogs.scene_gen.markers_tooltip"
|
||||
onChange={(v) => setOptions({ markers: v })}
|
||||
/>
|
||||
<BooleanSetting
|
||||
id="marker-image-preview-task"
|
||||
className="sub-setting"
|
||||
checked={options.markerImagePreviews ?? false}
|
||||
disabled={!options.markers}
|
||||
headingID="dialogs.scene_gen.marker_image_previews"
|
||||
tooltipID="dialogs.scene_gen.marker_image_previews_tooltip"
|
||||
onChange={(v) =>
|
||||
setOptions({
|
||||
markerImagePreviews: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<BooleanSetting
|
||||
id="marker-screenshot-task"
|
||||
className="sub-setting"
|
||||
checked={options.markerScreenshots ?? false}
|
||||
disabled={!options.markers}
|
||||
headingID="dialogs.scene_gen.marker_screenshots"
|
||||
tooltipID="dialogs.scene_gen.marker_screenshots_tooltip"
|
||||
onChange={(v) => setOptions({ markerScreenshots: v })}
|
||||
/>
|
||||
|
||||
<Form.Group id="preview-segment-duration">
|
||||
<h6>
|
||||
{intl.formatMessage({
|
||||
id: "dialogs.scene_gen.preview_seg_duration_head",
|
||||
})}
|
||||
</h6>
|
||||
<Form.Control
|
||||
className="col col-sm-6 text-input"
|
||||
type="number"
|
||||
value={
|
||||
previewOptions.previewSegmentDuration?.toString() ?? ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
setPreviewOptions({
|
||||
previewSegmentDuration: Number.parseFloat(
|
||||
e.currentTarget.value
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id: "dialogs.scene_gen.preview_seg_duration_desc",
|
||||
})}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<BooleanSetting
|
||||
id="transcode-task"
|
||||
checked={options.transcodes ?? false}
|
||||
headingID="dialogs.scene_gen.transcodes"
|
||||
tooltipID="dialogs.scene_gen.transcodes_tooltip"
|
||||
onChange={(v) => setOptions({ transcodes: v })}
|
||||
/>
|
||||
<BooleanSetting
|
||||
id="phash-task"
|
||||
checked={options.phashes ?? false}
|
||||
headingID="dialogs.scene_gen.phash"
|
||||
onChange={(v) => setOptions({ phashes: v })}
|
||||
/>
|
||||
|
||||
<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"
|
||||
checked={options.sprites ?? false}
|
||||
label={intl.formatMessage({ id: "dialogs.scene_gen.sprites" })}
|
||||
onChange={() => setOptions({ sprites: !options.sprites })}
|
||||
/>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id="marker-task"
|
||||
checked={options.markers ?? false}
|
||||
label={intl.formatMessage({ id: "dialogs.scene_gen.markers" })}
|
||||
onChange={() => setOptions({ markers: !options.markers })}
|
||||
/>
|
||||
<div className="d-flex flex-row">
|
||||
<div>↳</div>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id="marker-image-preview-task"
|
||||
checked={options.markerImagePreviews ?? false}
|
||||
disabled={!options.markers}
|
||||
label={intl.formatMessage({
|
||||
id: "dialogs.scene_gen.marker_image_previews",
|
||||
})}
|
||||
onChange={() =>
|
||||
setOptions({
|
||||
markerImagePreviews: !options.markerImagePreviews,
|
||||
})
|
||||
}
|
||||
className="ml-2 flex-grow"
|
||||
/>
|
||||
<Form.Check
|
||||
id="marker-screenshot-task"
|
||||
checked={options.markerScreenshots ?? false}
|
||||
disabled={!options.markers}
|
||||
label={intl.formatMessage({
|
||||
id: "dialogs.scene_gen.marker_screenshots",
|
||||
})}
|
||||
onChange={() =>
|
||||
setOptions({ markerScreenshots: !options.markerScreenshots })
|
||||
}
|
||||
className="ml-2 flex-grow"
|
||||
/>
|
||||
</Form.Group>
|
||||
</div>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id="transcode-task"
|
||||
checked={options.transcodes ?? false}
|
||||
label={intl.formatMessage({ id: "dialogs.scene_gen.transcodes" })}
|
||||
onChange={() => setOptions({ transcodes: !options.transcodes })}
|
||||
/>
|
||||
<Form.Check
|
||||
id="phash-task"
|
||||
checked={options.phashes ?? false}
|
||||
label={intl.formatMessage({ id: "dialogs.scene_gen.phash" })}
|
||||
onChange={() => setOptions({ phashes: !options.phashes })}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id="interactive-heatmap-speed-task"
|
||||
checked={options.interactiveHeatmapsSpeeds ?? false}
|
||||
label={intl.formatMessage({
|
||||
id: "dialogs.scene_gen.interactive_heatmap_speed",
|
||||
})}
|
||||
onChange={() =>
|
||||
setOptions({
|
||||
interactiveHeatmapsSpeeds: !options.interactiveHeatmapsSpeeds,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id="overwrite"
|
||||
checked={options.overwrite ?? false}
|
||||
label={intl.formatMessage({ id: "dialogs.scene_gen.overwrite" })}
|
||||
onChange={() => setOptions({ overwrite: !options.overwrite })}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form.Group>
|
||||
</Form.Group>
|
||||
<BooleanSetting
|
||||
id="interactive-heatmap-speed-task"
|
||||
checked={options.interactiveHeatmapsSpeeds ?? false}
|
||||
headingID="dialogs.scene_gen.interactive_heatmap_speed"
|
||||
onChange={(v) => setOptions({ interactiveHeatmapsSpeeds: v })}
|
||||
/>
|
||||
<BooleanSetting
|
||||
id="overwrite"
|
||||
checked={options.overwrite ?? false}
|
||||
headingID="dialogs.scene_gen.overwrite"
|
||||
onChange={(v) => setOptions({ overwrite: v })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button, ProgressBar } from "react-bootstrap";
|
||||
import { Button, Card, ProgressBar } from "react-bootstrap";
|
||||
import {
|
||||
mutateStopJob,
|
||||
useJobQueue,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { Icon } from "src/components/Shared";
|
||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
type JobFragment = Pick<
|
||||
GQL.Job,
|
||||
@@ -153,6 +154,7 @@ const Task: React.FC<IJob> = ({ job }) => {
|
||||
};
|
||||
|
||||
export const JobTable: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const jobStatus = useJobQueue();
|
||||
const jobsSubscribe = useJobsSubscribe();
|
||||
|
||||
@@ -200,12 +202,17 @@ export const JobTable: React.FC = () => {
|
||||
}, [jobsSubscribe.data]);
|
||||
|
||||
return (
|
||||
<div className="job-table">
|
||||
<Card className="job-table">
|
||||
<ul>
|
||||
{!queue?.length ? (
|
||||
<span className="empty-queue-message">
|
||||
{intl.formatMessage({ id: "config.tasks.empty_queue" })}
|
||||
</span>
|
||||
) : undefined}
|
||||
{(queue ?? []).map((j) => (
|
||||
<Task job={j} key={j.id} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,10 @@ import { DirectorySelectionDialog } from "./DirectorySelectionDialog";
|
||||
import { ScanOptions } from "./ScanOptions";
|
||||
import { useToast } from "src/hooks";
|
||||
import { GenerateOptions } from "./GenerateOptions";
|
||||
import { Task } from "./Task";
|
||||
import { SettingSection } from "../SettingSection";
|
||||
import { BooleanSetting, Setting, SettingGroup } from "../Inputs";
|
||||
import { ManualLink } from "src/components/Help/Manual";
|
||||
import { Icon } from "src/components/Shared";
|
||||
|
||||
interface IAutoTagOptions {
|
||||
options: GQL.AutoTagMetadataInput;
|
||||
@@ -26,13 +29,11 @@ const AutoTagOptions: React.FC<IAutoTagOptions> = ({
|
||||
options,
|
||||
setOptions: setOptionsState,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { performers, studios, tags } = options;
|
||||
const wildcard = ["*"];
|
||||
|
||||
function toggle(v?: GQL.Maybe<string[]>) {
|
||||
if (!v?.length) {
|
||||
function set(v?: boolean) {
|
||||
if (v) {
|
||||
return wildcard;
|
||||
}
|
||||
return [];
|
||||
@@ -43,26 +44,26 @@ const AutoTagOptions: React.FC<IAutoTagOptions> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
<>
|
||||
<BooleanSetting
|
||||
id="autotag-performers"
|
||||
checked={!!performers?.length}
|
||||
label={intl.formatMessage({ id: "performers" })}
|
||||
onChange={() => setOptions({ performers: toggle(performers) })}
|
||||
headingID="performers"
|
||||
onChange={(v) => setOptions({ performers: set(v) })}
|
||||
/>
|
||||
<Form.Check
|
||||
<BooleanSetting
|
||||
id="autotag-studios"
|
||||
checked={!!studios?.length}
|
||||
label={intl.formatMessage({ id: "studios" })}
|
||||
onChange={() => setOptions({ studios: toggle(studios) })}
|
||||
headingID="studios"
|
||||
onChange={(v) => setOptions({ studios: set(v) })}
|
||||
/>
|
||||
<Form.Check
|
||||
<BooleanSetting
|
||||
id="autotag-tags"
|
||||
checked={!!tags?.length}
|
||||
label={intl.formatMessage({ id: "tags" })}
|
||||
onChange={() => setOptions({ tags: toggle(tags) })}
|
||||
headingID="tags"
|
||||
onChange={(v) => setOptions({ tags: set(v) })}
|
||||
/>
|
||||
</Form.Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -279,96 +280,123 @@ export const LibraryTasks: React.FC = () => {
|
||||
{renderAutoTagDialog()}
|
||||
{maybeRenderIdentifyDialog()}
|
||||
|
||||
<Form.Group>
|
||||
<h5>{intl.formatMessage({ id: "library" })}</h5>
|
||||
<SettingSection headingID="library">
|
||||
<SettingGroup
|
||||
settingProps={{
|
||||
heading: (
|
||||
<>
|
||||
<FormattedMessage id="actions.scan" />
|
||||
<ManualLink tab="Tasks">
|
||||
<Icon icon="question-circle" />
|
||||
</ManualLink>
|
||||
</>
|
||||
),
|
||||
subHeadingID: "config.tasks.scan_for_content_desc",
|
||||
}}
|
||||
topLevel={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
className="mr-2"
|
||||
onClick={() => runScan()}
|
||||
>
|
||||
<FormattedMessage id="actions.scan" />
|
||||
</Button>
|
||||
|
||||
<div className="task-group">
|
||||
<Task
|
||||
headingID="actions.scan"
|
||||
description={intl.formatMessage({
|
||||
id: "config.tasks.scan_for_content_desc",
|
||||
})}
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
className="mr-2"
|
||||
onClick={() => setDialogOpen({ scan: true })}
|
||||
>
|
||||
<FormattedMessage id="actions.selective_scan" />…
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
collapsible
|
||||
>
|
||||
<ScanOptions options={scanOptions} setOptions={setScanOptions} />
|
||||
</SettingGroup>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection>
|
||||
<Setting
|
||||
heading={
|
||||
<>
|
||||
<FormattedMessage id="config.tasks.identify.heading" />
|
||||
<ManualLink tab="Identify">
|
||||
<Icon icon="question-circle" />
|
||||
</ManualLink>
|
||||
</>
|
||||
}
|
||||
subHeadingID="config.tasks.identify.description"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => setDialogOpen({ identify: true })}
|
||||
>
|
||||
<ScanOptions options={scanOptions} setOptions={setScanOptions} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
className="mr-2"
|
||||
onClick={() => runScan()}
|
||||
>
|
||||
<FormattedMessage id="actions.scan" />
|
||||
</Button>
|
||||
<FormattedMessage id="actions.identify" />…
|
||||
</Button>
|
||||
</Setting>
|
||||
</SettingSection>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
className="mr-2"
|
||||
onClick={() => setDialogOpen({ scan: true })}
|
||||
>
|
||||
<FormattedMessage id="actions.selective_scan" />…
|
||||
</Button>
|
||||
</Task>
|
||||
<SettingSection>
|
||||
<SettingGroup
|
||||
settingProps={{
|
||||
heading: (
|
||||
<>
|
||||
<FormattedMessage id="actions.auto_tag" />
|
||||
<ManualLink tab="AutoTagging">
|
||||
<Icon icon="question-circle" />
|
||||
</ManualLink>
|
||||
</>
|
||||
),
|
||||
subHeadingID: "config.tasks.auto_tag_based_on_filenames",
|
||||
}}
|
||||
topLevel={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
className="mr-2"
|
||||
onClick={() => runAutoTag()}
|
||||
>
|
||||
<FormattedMessage id="actions.auto_tag" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => setDialogOpen({ autoTag: true })}
|
||||
>
|
||||
<FormattedMessage id="actions.selective_auto_tag" />…
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
collapsible
|
||||
>
|
||||
<AutoTagOptions
|
||||
options={autoTagOptions}
|
||||
setOptions={(o) => setAutoTagOptions(o)}
|
||||
/>
|
||||
</SettingGroup>
|
||||
</SettingSection>
|
||||
|
||||
<Task
|
||||
headingID="config.tasks.identify.heading"
|
||||
description={intl.formatMessage({
|
||||
id: "config.tasks.identify.description",
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => setDialogOpen({ identify: true })}
|
||||
>
|
||||
<FormattedMessage id="actions.identify" />…
|
||||
</Button>
|
||||
</Task>
|
||||
|
||||
<Task
|
||||
headingID="config.tasks.auto_tagging"
|
||||
description={intl.formatMessage({
|
||||
id: "config.tasks.auto_tag_based_on_filenames",
|
||||
})}
|
||||
>
|
||||
<AutoTagOptions
|
||||
options={autoTagOptions}
|
||||
setOptions={(o) => setAutoTagOptions(o)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
className="mr-2"
|
||||
onClick={() => runAutoTag()}
|
||||
>
|
||||
<FormattedMessage id="actions.auto_tag" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => setDialogOpen({ autoTag: true })}
|
||||
>
|
||||
<FormattedMessage id="actions.selective_auto_tag" />…
|
||||
</Button>
|
||||
</Task>
|
||||
</div>
|
||||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
|
||||
<Form.Group>
|
||||
<h5>{intl.formatMessage({ id: "config.tasks.generated_content" })}</h5>
|
||||
|
||||
<div className="task-group">
|
||||
<Task
|
||||
description={intl.formatMessage({
|
||||
id: "config.tasks.generate_desc",
|
||||
})}
|
||||
>
|
||||
<GenerateOptions
|
||||
options={generateOptions}
|
||||
setOptions={setGenerateOptions}
|
||||
/>
|
||||
<SettingSection headingID="config.tasks.generated_content">
|
||||
<SettingGroup
|
||||
settingProps={{
|
||||
heading: (
|
||||
<>
|
||||
<FormattedMessage id="actions.generate" />
|
||||
<ManualLink tab="Tasks">
|
||||
<Icon icon="question-circle" />
|
||||
</ManualLink>
|
||||
</>
|
||||
),
|
||||
subHeadingID: "config.tasks.generate_desc",
|
||||
}}
|
||||
topLevel={
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
@@ -376,9 +404,15 @@ export const LibraryTasks: React.FC = () => {
|
||||
>
|
||||
<FormattedMessage id="actions.generate" />
|
||||
</Button>
|
||||
</Task>
|
||||
</div>
|
||||
</Form.Group>
|
||||
}
|
||||
collapsible
|
||||
>
|
||||
<GenerateOptions
|
||||
options={generateOptions}
|
||||
setOptions={setGenerateOptions}
|
||||
/>
|
||||
</SettingGroup>
|
||||
</SettingSection>
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Button, Form } from "react-bootstrap";
|
||||
import { mutateRunPluginTask, usePlugins } from "src/core/StashService";
|
||||
import { useToast } from "src/hooks";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { Task } from "./Task";
|
||||
import { SettingSection } from "../SettingSection";
|
||||
import { Setting, SettingGroup } from "../Inputs";
|
||||
|
||||
type Plugin = Pick<GQL.Plugin, "id">;
|
||||
type PluginTask = Pick<GQL.PluginTask, "name" | "description">;
|
||||
@@ -25,19 +26,21 @@ export const PluginTasks: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
<h5>{intl.formatMessage({ id: "config.tasks.plugin_tasks" })}</h5>
|
||||
<SettingSection headingID="config.tasks.plugin_tasks">
|
||||
{taskPlugins.map((o) => {
|
||||
return (
|
||||
<Form.Group key={`${o.id}`}>
|
||||
<h6>{o.name}</h6>
|
||||
<div className="task-group">
|
||||
{renderPluginTasks(o, o.tasks ?? [])}
|
||||
</div>
|
||||
</Form.Group>
|
||||
<SettingGroup
|
||||
key={`${o.id}`}
|
||||
settingProps={{
|
||||
heading: o.name,
|
||||
}}
|
||||
collapsible
|
||||
>
|
||||
{renderPluginTasks(o, o.tasks ?? [])}
|
||||
</SettingGroup>
|
||||
);
|
||||
})}
|
||||
</Form.Group>
|
||||
</SettingSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +51,11 @@ export const PluginTasks: React.FC = () => {
|
||||
|
||||
return pluginTasks.map((o) => {
|
||||
return (
|
||||
<Task description={o.description} key={o.name}>
|
||||
<Setting
|
||||
heading={o.name}
|
||||
subHeading={o.description ?? undefined}
|
||||
key={o.name}
|
||||
>
|
||||
<Button
|
||||
onClick={() => onPluginTaskClicked(plugin, o)}
|
||||
variant="secondary"
|
||||
@@ -56,7 +63,7 @@ export const PluginTasks: React.FC = () => {
|
||||
>
|
||||
{o.name}
|
||||
</Button>
|
||||
</Task>
|
||||
</Setting>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useIntl } from "react-intl";
|
||||
import { BooleanSetting } from "../Inputs";
|
||||
|
||||
interface IScanOptions {
|
||||
options: GQL.ScanMetadataInput;
|
||||
@@ -12,8 +11,6 @@ export const ScanOptions: React.FC<IScanOptions> = ({
|
||||
options,
|
||||
setOptions: setOptionsState,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
useFileMetadata,
|
||||
stripFileExtension,
|
||||
@@ -29,80 +26,55 @@ export const ScanOptions: React.FC<IScanOptions> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
<>
|
||||
<BooleanSetting
|
||||
id="scan-generate-previews"
|
||||
headingID="config.tasks.generate_video_previews_during_scan"
|
||||
tooltipID="config.tasks.generate_video_previews_during_scan_tooltip"
|
||||
checked={scanGeneratePreviews ?? false}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_video_previews_during_scan",
|
||||
})}
|
||||
onChange={() =>
|
||||
setOptions({ scanGeneratePreviews: !scanGeneratePreviews })
|
||||
}
|
||||
onChange={(v) => setOptions({ scanGeneratePreviews: v })}
|
||||
/>
|
||||
<div className="d-flex flex-row">
|
||||
<div>↳</div>
|
||||
<Form.Check
|
||||
id="scan-generate-image-previews"
|
||||
checked={scanGenerateImagePreviews ?? false}
|
||||
disabled={!scanGeneratePreviews}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_previews_during_scan",
|
||||
})}
|
||||
onChange={() =>
|
||||
setOptions({
|
||||
scanGenerateImagePreviews: !scanGenerateImagePreviews,
|
||||
})
|
||||
}
|
||||
className="ml-2 flex-grow"
|
||||
/>
|
||||
</div>
|
||||
<Form.Check
|
||||
<BooleanSetting
|
||||
id="scan-generate-image-previews"
|
||||
className="sub-setting"
|
||||
headingID="config.tasks.generate_previews_during_scan"
|
||||
tooltipID="config.tasks.generate_previews_during_scan_tooltip"
|
||||
checked={scanGenerateImagePreviews ?? false}
|
||||
disabled={!scanGeneratePreviews}
|
||||
onChange={(v) => setOptions({ scanGenerateImagePreviews: v })}
|
||||
/>
|
||||
|
||||
<BooleanSetting
|
||||
id="scan-generate-sprites"
|
||||
headingID="config.tasks.generate_sprites_during_scan"
|
||||
checked={scanGenerateSprites ?? false}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_sprites_during_scan",
|
||||
})}
|
||||
onChange={() =>
|
||||
setOptions({ scanGenerateSprites: !scanGenerateSprites })
|
||||
}
|
||||
onChange={(v) => setOptions({ scanGenerateSprites: v })}
|
||||
/>
|
||||
<Form.Check
|
||||
<BooleanSetting
|
||||
id="scan-generate-phashes"
|
||||
checked={scanGeneratePhashes ?? false}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_phashes_during_scan",
|
||||
})}
|
||||
onChange={() =>
|
||||
setOptions({ scanGeneratePhashes: !scanGeneratePhashes })
|
||||
}
|
||||
headingID="config.tasks.generate_phashes_during_scan"
|
||||
tooltipID="config.tasks.generate_phashes_during_scan_tooltip"
|
||||
onChange={(v) => setOptions({ scanGeneratePhashes: v })}
|
||||
/>
|
||||
<Form.Check
|
||||
<BooleanSetting
|
||||
id="scan-generate-thumbnails"
|
||||
checked={scanGenerateThumbnails ?? false}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_thumbnails_during_scan",
|
||||
})}
|
||||
onChange={() =>
|
||||
setOptions({ scanGenerateThumbnails: !scanGenerateThumbnails })
|
||||
}
|
||||
headingID="config.tasks.generate_thumbnails_during_scan"
|
||||
onChange={(v) => setOptions({ scanGenerateThumbnails: v })}
|
||||
/>
|
||||
<Form.Check
|
||||
<BooleanSetting
|
||||
id="strip-file-extension"
|
||||
checked={stripFileExtension ?? false}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.dont_include_file_extension_as_part_of_the_title",
|
||||
})}
|
||||
onChange={() => setOptions({ stripFileExtension: !stripFileExtension })}
|
||||
headingID="config.tasks.dont_include_file_extension_as_part_of_the_title"
|
||||
onChange={(v) => setOptions({ stripFileExtension: v })}
|
||||
/>
|
||||
<Form.Check
|
||||
<BooleanSetting
|
||||
id="use-file-metadata"
|
||||
checked={useFileMetadata ?? false}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.set_name_date_details_from_metadata_if_present",
|
||||
})}
|
||||
onChange={() => setOptions({ useFileMetadata: !useFileMetadata })}
|
||||
headingID="config.tasks.set_name_date_details_from_metadata_if_present"
|
||||
onChange={(v) => setOptions({ useFileMetadata: v })}
|
||||
/>
|
||||
</Form.Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,18 +19,19 @@ export const SettingsTasksPanel: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4>{intl.formatMessage({ id: "config.tasks.job_queue" })}</h4>
|
||||
<div id="tasks-panel">
|
||||
<div className="tasks-panel-queue">
|
||||
<h1>{intl.formatMessage({ id: "config.tasks.job_queue" })}</h1>
|
||||
<JobTable />
|
||||
</div>
|
||||
|
||||
<JobTable />
|
||||
|
||||
<hr />
|
||||
|
||||
<LibraryTasks />
|
||||
<hr />
|
||||
<DataManagementTasks setIsBackupRunning={setIsBackupRunning} />
|
||||
<hr />
|
||||
<PluginTasks />
|
||||
</>
|
||||
<div className="tasks-panel-tasks">
|
||||
<LibraryTasks />
|
||||
<hr />
|
||||
<DataManagementTasks setIsBackupRunning={setIsBackupRunning} />
|
||||
<hr />
|
||||
<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 {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
@@ -71,9 +255,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.job-table {
|
||||
.job-table.card {
|
||||
background-color: $card-bg;
|
||||
height: 10em;
|
||||
margin-bottom: 30px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 15px;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
@@ -179,3 +366,40 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
opacity: 50%;
|
||||
position: fixed;
|
||||
right: 30px;
|
||||
z-index: 1051;
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
top: 30px;
|
||||
}
|
||||
@include media-breakpoint-up(sm) {
|
||||
bottom: 30px;
|
||||
}
|
||||
|
||||
.fa-icon {
|
||||
animation: fadeOut 2s forwards;
|
||||
animation-delay: 2s;
|
||||
color: $success;
|
||||
height: 2rem;
|
||||
margin: 0;
|
||||
width: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-queue-message {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
@@ -570,7 +570,7 @@ export const Setup: React.FC = () => {
|
||||
</section>
|
||||
<section className="mt-5">
|
||||
<div className="d-flex justify-content-center">
|
||||
<Link to="/settings?tab=configuration">
|
||||
<Link to="/settings?tab=library">
|
||||
<Button variant="success mx-2 p-5" onClick={() => goBack(2)}>
|
||||
<FormattedMessage id="actions.finish" />
|
||||
</Button>
|
||||
|
||||
@@ -4,14 +4,20 @@ import { Button, Modal } from "react-bootstrap";
|
||||
import { FolderSelect } from "./FolderSelect";
|
||||
|
||||
interface IProps {
|
||||
defaultValue?: string;
|
||||
onClose: (directory?: string) => void;
|
||||
}
|
||||
|
||||
export const FolderSelectDialog: React.FC<IProps> = (props: IProps) => {
|
||||
const [currentDirectory, setCurrentDirectory] = useState<string>("");
|
||||
export const FolderSelectDialog: React.FC<IProps> = ({
|
||||
defaultValue: currentValue,
|
||||
onClose,
|
||||
}) => {
|
||||
const [currentDirectory, setCurrentDirectory] = useState<string>(
|
||||
currentValue ?? ""
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal show onHide={() => props.onClose()} title="">
|
||||
<Modal show onHide={() => onClose()} title="">
|
||||
<Modal.Header>Select Directory</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div className="dialog-content">
|
||||
@@ -22,11 +28,11 @@ export const FolderSelectDialog: React.FC<IProps> = (props: IProps) => {
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="success"
|
||||
onClick={() => props.onClose(currentDirectory)}
|
||||
>
|
||||
<FormattedMessage id="actions.add" />
|
||||
<Button variant="secondary" onClick={() => onClose()}>
|
||||
<FormattedMessage id="actions.cancel" />
|
||||
</Button>
|
||||
<Button variant="success" onClick={() => onClose(currentDirectory)}>
|
||||
<FormattedMessage id="actions.confirm" />
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
@@ -403,7 +403,7 @@ export const PerformerSelect: React.FC<IFilterProps> = (props) => {
|
||||
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
const defaultCreatable =
|
||||
!configuration?.interface.disabledDropdownCreate.performer ?? true;
|
||||
!configuration?.interface.disableDropdownCreate.performer ?? true;
|
||||
|
||||
const performers = data?.allPerformers ?? [];
|
||||
|
||||
@@ -443,7 +443,7 @@ export const StudioSelect: React.FC<
|
||||
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
const defaultCreatable =
|
||||
!configuration?.interface.disabledDropdownCreate.studio ?? true;
|
||||
!configuration?.interface.disableDropdownCreate.studio ?? true;
|
||||
|
||||
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
|
||||
const studios = useMemo(
|
||||
@@ -584,7 +584,7 @@ export const TagSelect: React.FC<IFilterProps & { excludeIds?: string[] }> = (
|
||||
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
const defaultCreatable =
|
||||
!configuration?.interface.disabledDropdownCreate.tag ?? true;
|
||||
!configuration?.interface.disableDropdownCreate.tag ?? true;
|
||||
|
||||
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
|
||||
const tags = useMemo(
|
||||
|
||||
@@ -640,7 +640,7 @@ export const PerformerTagger: React.FC<ITaggerProps> = ({ performers }) => {
|
||||
<h5 className="text-center">
|
||||
Please see{" "}
|
||||
<HashLink
|
||||
to="/settings?tab=configuration#stashbox"
|
||||
to="/settings?tab=metadata-providers#stash-boxes"
|
||||
scroll={(el) =>
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
}
|
||||
|
||||
@@ -737,16 +737,14 @@ export const useTagsMerge = () =>
|
||||
update: deleteCache(tagMutationImpactedQueries),
|
||||
});
|
||||
|
||||
export const useConfigureGeneral = (input: GQL.ConfigGeneralInput) =>
|
||||
export const useConfigureGeneral = () =>
|
||||
GQL.useConfigureGeneralMutation({
|
||||
variables: { input },
|
||||
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
|
||||
update: deleteCache([GQL.ConfigurationDocument]),
|
||||
});
|
||||
|
||||
export const useConfigureInterface = (input: GQL.ConfigInterfaceInput) =>
|
||||
export const useConfigureInterface = () =>
|
||||
GQL.useConfigureInterfaceMutation({
|
||||
variables: { input },
|
||||
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
|
||||
update: deleteCache([GQL.ConfigurationDocument]),
|
||||
});
|
||||
@@ -781,9 +779,8 @@ export const useRemoveTempDLNAIP = () => GQL.useRemoveTempDlnaipMutation();
|
||||
|
||||
export const useLoggingSubscribe = () => GQL.useLoggingSubscribeSubscription();
|
||||
|
||||
export const useConfigureScraping = (input: GQL.ConfigScrapingInput) =>
|
||||
export const useConfigureScraping = () =>
|
||||
GQL.useConfigureScrapingMutation({
|
||||
variables: { input },
|
||||
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
|
||||
update: deleteCache([GQL.ConfigurationDocument]),
|
||||
});
|
||||
|
||||
@@ -10,7 +10,17 @@ Stash currently identifies files by performing a quick file hash. This means tha
|
||||
|
||||
Stash currently ignores duplicate files. If two files contain identical content, only the first one it comes across is used.
|
||||
|
||||
The "Set name, data, details from metadata" option will parse the files metadata (where supported) and set the scene attributes accordingly. It has previously been noted that this information is frequently incorrect, so only use this option where you are certain that the metadata is correct in the files.
|
||||
The scan task accepts the following options:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| Generate previews | Generates video previews which play when hovering over a scene. |
|
||||
| Generate animated image previews | Generates animated webp previews. Only required if the Preview Type is set to Animated Image. Requires Generate previews to be enabled. |
|
||||
| Generate sprites | Generates sprites for the scene scrubber. |
|
||||
| Generate perceptual hashes | Generates perceptual hashes for scene deduplication and identification. |
|
||||
| Generate thumbnails for images | Generates thumbnails for image files. |
|
||||
| Don't include file extension in title | By default, scenes, images and galleries have their title created using the file basename. When the flag is enabled, the file extension is stripped when setting the title. |
|
||||
| Set name, date, details from embedded file metadata. | Parse the video file metadata (where supported) and set the scene attributes accordingly. It has previously been noted that this information is frequently incorrect, so only use this option where you are certain that the metadata is correct in the files. |
|
||||
|
||||
# Auto Tagging
|
||||
See the [Auto Tagging](/help/AutoTagging.md) page.
|
||||
@@ -28,6 +38,20 @@ The scanning function automatically generates a screenshot of each scene. The ge
|
||||
* Transcoded versions of scenes. See below
|
||||
* Image thumbnails of galleries
|
||||
|
||||
The generate task accepts the following options:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| Previews | Generates video previews which play when hovering over a scene. |
|
||||
| Animated image previews | Generates animated webp previews. Only required if the Preview Type is set to Animated Image. Requires Generate previews to be enabled. |
|
||||
| Scene Scrubber Sprites | Generates sprites for the scene scrubber. |
|
||||
| Markers Previews | Generates 20 second videos which begin at the marker timecode. |
|
||||
| Marker Animated Image Previews | Generates animated webp previews for markers. Only required if the Preview Type is set to Animated Image. Requires Markers to be enabled. |
|
||||
| Marker Screenshots | Generates static JPG images for markers. Only required if Preview Type is set to Static Image. Requires Marker Previews to be enabled. |
|
||||
| Transcodes | MP4 conversions of unsupported video formats. Allows direct streaming instead of live transcoding. |
|
||||
| Perceptual hashes | Generates perceptual hashes for scene deduplication and identification. |
|
||||
| Overwrite existing generated files | By default, where a generated file exists, it is not regenerated. When this flag is enabled, then the generated files are regenerated. |
|
||||
|
||||
## Transcodes
|
||||
|
||||
Web browsers support a limited number of video and audio codecs and containers. Stash will directly stream video files where the browser supports the codecs and container. Originally, stash did not support viewing scene videos where the browser did not support the codecs/container, and generating transcodes was a way of viewing these files.
|
||||
|
||||
@@ -159,6 +159,7 @@
|
||||
"build_hash": "Build hash:",
|
||||
"build_time": "Build time:",
|
||||
"check_for_new_version": "Check for new version",
|
||||
"latest_version": "Latest Version",
|
||||
"latest_version_build_hash": "Latest Version Build Hash:",
|
||||
"new_version_notice": "[NEW]",
|
||||
"stash_discord": "Join our {url} channel",
|
||||
@@ -167,12 +168,19 @@
|
||||
"stash_wiki": "Stash {url} page",
|
||||
"version": "Version"
|
||||
},
|
||||
"application_paths": {
|
||||
"heading": "Application Paths"
|
||||
},
|
||||
"categories": {
|
||||
"about": "About",
|
||||
"interface": "Interface",
|
||||
"logs": "Logs",
|
||||
"metadata_providers": "Metadata Providers",
|
||||
"plugins": "Plugins",
|
||||
"scraping": "Scraping",
|
||||
"security": "Security",
|
||||
"services": "Services",
|
||||
"system": "System",
|
||||
"tasks": "Tasks",
|
||||
"tools": "Tools"
|
||||
},
|
||||
@@ -195,6 +203,10 @@
|
||||
"api_key_desc": "API key for external systems. Only required when username/password is configured. Username must be saved before generating API key.",
|
||||
"authentication": "Authentication",
|
||||
"clear_api_key": "Clear API key",
|
||||
"credentials": {
|
||||
"description": "Credentials to restrict access to stash.",
|
||||
"heading": "Credentials"
|
||||
},
|
||||
"generate_api_key": "Generate API key",
|
||||
"log_file": "Log file",
|
||||
"log_file_desc": "Path to the file to output logging to. Blank to disable file logging. Requires restart.",
|
||||
@@ -224,8 +236,6 @@
|
||||
"create_galleries_from_folders_label": "Create galleries from folders containing images",
|
||||
"db_path_head": "Database Path",
|
||||
"directory_locations_to_your_content": "Directory locations to your content",
|
||||
"exclude_image": "Exclude Image",
|
||||
"exclude_video": "Exclude Video",
|
||||
"excluded_image_gallery_patterns_desc": "Regexps of image and gallery files/paths to exclude from Scan and add to Clean",
|
||||
"excluded_image_gallery_patterns_head": "Excluded Image/Gallery Patterns",
|
||||
"excluded_video_patterns_desc": "Regexps of video files/paths to exclude from Scan and add to Clean",
|
||||
@@ -262,6 +272,11 @@
|
||||
"video_ext_head": "Video Extensions",
|
||||
"video_head": "Video"
|
||||
},
|
||||
"library": {
|
||||
"exclusions": "Exclusions",
|
||||
"gallery_and_image_options": "Gallery and Image options",
|
||||
"media_content_extensions": "Media content extensions"
|
||||
},
|
||||
"logs": {
|
||||
"log_level": "Log Level"
|
||||
},
|
||||
@@ -289,6 +304,9 @@
|
||||
"name": "Name",
|
||||
"title": "Stash-box Endpoints"
|
||||
},
|
||||
"system": {
|
||||
"transcoding": "Transcoding"
|
||||
},
|
||||
"tasks": {
|
||||
"added_job_to_queue": "Added {operation_name} to job queue",
|
||||
"auto_tag": {
|
||||
@@ -304,17 +322,21 @@
|
||||
"data_management": "Data management",
|
||||
"defaults_set": "Defaults have been set and will be used when clicking the {action} button on the Tasks page.",
|
||||
"dont_include_file_extension_as_part_of_the_title": "Don't include file extension as part of the title",
|
||||
"empty_queue": "No tasks are currently running.",
|
||||
"export_to_json": "Exports the database content into JSON format in the metadata directory.",
|
||||
"generate": {
|
||||
"generating_scenes": "Generating for {num} {scene}",
|
||||
"generating_from_paths": "Generating for scenes from the following paths"
|
||||
},
|
||||
"generate_desc": "Generate supporting image, sprite, video, vtt and other files.",
|
||||
"generate_phashes_during_scan": "Generate perceptual hashes during scan (for deduplication and scene identification)",
|
||||
"generate_previews_during_scan": "Generate image previews during scan (animated WebP previews, only required if Preview Type is set to Animated Image)",
|
||||
"generate_sprites_during_scan": "Generate sprites during scan (for the scene scrubber)",
|
||||
"generate_thumbnails_during_scan": "Generate thumbnails for images during scan.",
|
||||
"generate_video_previews_during_scan": "Generate previews during scan (video previews which play when hovering over a scene)",
|
||||
"generate_phashes_during_scan": "Generate perceptual hashes",
|
||||
"generate_phashes_during_scan_tooltip": "For deduplication and scene identification.",
|
||||
"generate_previews_during_scan": "Generate animated image previews",
|
||||
"generate_previews_during_scan_tooltip": "Generate animated WebP previews, only required if Preview Type is set to Animated Image.",
|
||||
"generate_sprites_during_scan": "Generate scrubber sprites",
|
||||
"generate_thumbnails_during_scan": "Generate thumbnails for images",
|
||||
"generate_video_previews_during_scan": "Generate previews",
|
||||
"generate_video_previews_during_scan_tooltip": "Generate video previews which play when hovering over a scene",
|
||||
"generated_content": "Generated Content",
|
||||
"identify": {
|
||||
"and_create_missing": "and create missing",
|
||||
@@ -338,7 +360,7 @@
|
||||
},
|
||||
"import_from_exported_json": "Import from exported JSON in the metadata directory. Wipes the existing database.",
|
||||
"incremental_import": "Incremental import from a supplied export zip file.",
|
||||
"job_queue": "Job Queue",
|
||||
"job_queue": "Task Queue",
|
||||
"maintenance": "Maintenance",
|
||||
"migrate_hash_files": "Used after changing the Generated file naming hash to rename existing generated files to the new hash format.",
|
||||
"migrations": "Migrations",
|
||||
@@ -349,7 +371,7 @@
|
||||
"scanning_all_paths": "Scanning all paths"
|
||||
},
|
||||
"scan_for_content_desc": "Scan for new content and add it to the database.",
|
||||
"set_name_date_details_from_metadata_if_present": "Set name, date, details from embedded file metadata (if present)"
|
||||
"set_name_date_details_from_metadata_if_present": "Set name, date, details from embedded file metadata"
|
||||
},
|
||||
"tools": {
|
||||
"scene_duplicate_checker": "Scene Duplicate Checker",
|
||||
@@ -371,6 +393,7 @@
|
||||
"scene_tools": "Scene Tools"
|
||||
},
|
||||
"ui": {
|
||||
"basic_settings": "Basic Settings",
|
||||
"custom_css": {
|
||||
"description": "Page must be reloaded for changes to take effect.",
|
||||
"heading": "Custom CSS",
|
||||
@@ -405,6 +428,7 @@
|
||||
"heading": "Handy Connection Key"
|
||||
},
|
||||
"images": {
|
||||
"heading": "Images",
|
||||
"options": {
|
||||
"write_image_thumbnails": {
|
||||
"description": "Write image thumbnails to disk when generated on-the-fly",
|
||||
@@ -412,6 +436,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"interactive_options": "Interactive Options",
|
||||
"language": {
|
||||
"heading": "Language"
|
||||
},
|
||||
@@ -559,17 +584,22 @@
|
||||
},
|
||||
"overwrite_filter_confirm": "Are you sure you want to overwrite existing saved query {entityName}?",
|
||||
"scene_gen": {
|
||||
"image_previews": "Image Previews (animated WebP previews, only required if Preview Type is set to Animated Image)",
|
||||
"image_previews": "Animated Image Previews",
|
||||
"image_previews_tooltip": "Animated WebP previews, only required if Preview Type is set to Animated Image.",
|
||||
"interactive_heatmap_speed": "Generate heatmaps and speeds for interactive scenes",
|
||||
"marker_image_previews": "Marker Previews (animated WebP previews, only required if Preview Type is set to Animated Image)",
|
||||
"marker_screenshots": "Marker Screenshots (static JPG image, only required if Preview Type is set to Static Image)",
|
||||
"markers": "Markers (20 second videos which begin at the given timecode)",
|
||||
"marker_image_previews": "Marker Animated Image Previews",
|
||||
"marker_image_previews_tooltip": "Animated marker WebP previews, only required if Preview Type is set to Animated Image.",
|
||||
"marker_screenshots": "Marker Screenshots",
|
||||
"marker_screenshots_tooltip": "Marker static JPG images, only required if Preview Type is set to Static Image.",
|
||||
"markers": "Marker Previews",
|
||||
"markers_tooltip": "20 second videos which begin at the given timecode.",
|
||||
"overwrite": "Overwrite existing generated files",
|
||||
"phash": "Perceptual hashes (for deduplication)",
|
||||
"preview_exclude_end_time_desc": "Exclude the last x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.",
|
||||
"preview_exclude_end_time_head": "Exclude end time",
|
||||
"preview_exclude_start_time_desc": "Exclude the first x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.",
|
||||
"preview_exclude_start_time_head": "Exclude start time",
|
||||
"preview_generation_options": "Preview Generation Options",
|
||||
"preview_options": "Preview Options",
|
||||
"preview_preset_desc": "The preset regulates size, quality and encoding time of preview generation. Presets beyond “slow” have diminishing returns and are not recommended.",
|
||||
"preview_preset_head": "Preview encoding preset",
|
||||
@@ -577,9 +607,12 @@
|
||||
"preview_seg_count_head": "Number of segments in preview",
|
||||
"preview_seg_duration_desc": "Duration of each preview segment, in seconds.",
|
||||
"preview_seg_duration_head": "Preview segment duration",
|
||||
"sprites": "Sprites (for the scene scrubber)",
|
||||
"transcodes": "Transcodes (MP4 conversions of unsupported video formats)",
|
||||
"video_previews": "Previews (video previews which play when hovering over a scene)"
|
||||
"sprites": "Scene Scrubber Sprites",
|
||||
"sprites_tooltip": "Sprites (for the scene scrubber)",
|
||||
"transcodes": "Transcodes",
|
||||
"transcodes_tooltip": "MP4 conversions of unsupported video formats",
|
||||
"video_previews": "Previews",
|
||||
"video_previews_tooltip": "Video previews which play when hovering over a scene"
|
||||
},
|
||||
"scenes_found": "{count} scenes found",
|
||||
"scrape_entity_query": "{entity_type} Scrape Query",
|
||||
@@ -853,6 +886,7 @@
|
||||
"up-dir": "Up a directory",
|
||||
"updated_at": "Updated At",
|
||||
"url": "URL",
|
||||
"videos": "Videos",
|
||||
"weight": "Weight",
|
||||
"years_old": "years old"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user