Settings UI refactor (#2086)

* Full width settings page
* Group settings
* Make config fields optional
* auto save on change
* Add settings context
* Refactor stash library section
* Restructure settings
* Refactor tasks page
* Add collapse buttons for setting groups
* Add collapse buttons in library
* Add loading indicator
* Simplify task options. Add details to manual
* Add manual links to tasks page
* Add help tooltips
* Refactor about page
* Refactor log page
* Refactor tools panel
* Refactor plugin page
* Refactor task queue
* Improve disabled styling
This commit is contained in:
WithoutPants
2021-12-14 15:06:05 +11:00
committed by GitHub
parent b4b955efc8
commit d176e9f192
44 changed files with 3540 additions and 3022 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,467 @@
import React, { useState } from "react";
import { Button, Collapse, Form, Modal, ModalProps } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { PropsWithChildren } from "react-router/node_modules/@types/react";
import { Icon } from "../Shared";
import { StringListInput } from "../Shared/StringListInput";
interface ISetting {
id?: string;
className?: string;
heading?: React.ReactNode;
headingID?: string;
subHeadingID?: string;
subHeading?: React.ReactNode;
tooltipID?: string;
onClick?: React.MouseEventHandler<HTMLDivElement>;
disabled?: boolean;
}
export const Setting: React.FC<PropsWithChildren<ISetting>> = ({
id,
className,
heading,
headingID,
subHeadingID,
subHeading,
children,
tooltipID,
onClick,
disabled,
}) => {
const intl = useIntl();
function renderHeading() {
if (headingID) {
return intl.formatMessage({ id: headingID });
}
return heading;
}
function renderSubHeading() {
if (subHeadingID) {
return (
<div className="sub-heading">
{intl.formatMessage({ id: subHeadingID })}
</div>
);
}
if (subHeading) {
return <div className="sub-heading">{subHeading}</div>;
}
}
const tooltip = tooltipID ? intl.formatMessage({ id: tooltipID }) : undefined;
const disabledClassName = disabled ? "disabled" : "";
return (
<div
className={`setting ${className ?? ""} ${disabledClassName}`}
id={id}
onClick={onClick}
>
<div>
<h3 title={tooltip}>{renderHeading()}</h3>
{renderSubHeading()}
</div>
<div>{children}</div>
</div>
);
};
interface ISettingGroup {
settingProps?: ISetting;
topLevel?: JSX.Element;
collapsible?: boolean;
collapsedDefault?: boolean;
}
export const SettingGroup: React.FC<PropsWithChildren<ISettingGroup>> = ({
settingProps,
topLevel,
collapsible,
collapsedDefault,
children,
}) => {
const [open, setOpen] = useState(!collapsedDefault);
function renderCollapseButton() {
if (!collapsible) return;
return (
<Button
className="setting-group-collapse-button"
variant="minimal"
onClick={() => setOpen(!open)}
>
<Icon className="fa-fw" icon={open ? "chevron-up" : "chevron-down"} />
</Button>
);
}
function onDivClick(e: React.MouseEvent<HTMLDivElement>) {
if (!collapsible) return;
// ensure button was not clicked
let target: HTMLElement | null = e.target as HTMLElement;
while (target && target !== e.currentTarget) {
if (
target.nodeName.toLowerCase() === "button" ||
target.nodeName.toLowerCase() === "a"
) {
// button clicked, swallow event
return;
}
target = target.parentElement;
}
setOpen(!open);
}
return (
<div className={`setting-group ${collapsible ? "collapsible" : ""}`}>
<Setting {...settingProps} onClick={onDivClick}>
{topLevel}
{renderCollapseButton()}
</Setting>
<Collapse in={open}>
<div className="collapsible-section">{children}</div>
</Collapse>
</div>
);
};
interface IBooleanSetting extends ISetting {
id: string;
checked?: boolean;
onChange: (v: boolean) => void;
}
export const BooleanSetting: React.FC<IBooleanSetting> = (props) => {
const { id, disabled, checked, onChange, ...settingProps } = props;
return (
<Setting {...settingProps} disabled={disabled}>
<Form.Switch
id={id}
disabled={disabled}
checked={checked ?? false}
onChange={() => onChange(!checked)}
/>
</Setting>
);
};
interface ISelectSetting extends ISetting {
value?: string | number | string[] | undefined;
onChange: (v: string) => void;
}
export const SelectSetting: React.FC<PropsWithChildren<ISelectSetting>> = ({
id,
headingID,
subHeadingID,
value,
children,
onChange,
}) => {
return (
<Setting headingID={headingID} subHeadingID={subHeadingID} id={id}>
<Form.Control
className="input-control"
as="select"
value={value ?? ""}
onChange={(e) => onChange(e.currentTarget.value)}
>
{children}
</Form.Control>
</Setting>
);
};
interface IDialogSetting<T> extends ISetting {
buttonText?: string;
buttonTextID?: string;
value?: T;
renderValue?: (v: T | undefined) => JSX.Element;
onChange: () => void;
}
export const ChangeButtonSetting = <T extends {}>(props: IDialogSetting<T>) => {
const {
id,
className,
headingID,
subHeadingID,
subHeading,
value,
onChange,
renderValue,
buttonText,
buttonTextID,
disabled,
} = props;
const intl = useIntl();
const disabledClassName = disabled ? "disabled" : "";
return (
<div className={`setting ${className ?? ""} ${disabledClassName}`} id={id}>
<div>
<h3>{headingID ? intl.formatMessage({ id: headingID }) : undefined}</h3>
<div className="value">
{renderValue ? renderValue(value) : undefined}
</div>
{subHeadingID ? (
<div className="sub-heading">
{intl.formatMessage({ id: subHeadingID })}
</div>
) : subHeading ? (
<div className="sub-heading">{subHeading}</div>
) : undefined}
</div>
<div>
<Button onClick={() => onChange()} disabled={disabled}>
{buttonText ? (
buttonText
) : (
<FormattedMessage id={buttonTextID ?? "actions.edit"} />
)}
</Button>
</div>
</div>
);
};
export interface ISettingModal<T> {
heading?: string;
headingID?: string;
subHeadingID?: string;
subHeading?: React.ReactNode;
value: T | undefined;
close: (v?: T) => void;
renderField: (value: T | undefined, setValue: (v?: T) => void) => JSX.Element;
modalProps?: ModalProps;
}
export const SettingModal = <T extends {}>(props: ISettingModal<T>) => {
const {
heading,
headingID,
subHeading,
subHeadingID,
value,
close,
renderField,
modalProps,
} = props;
const intl = useIntl();
const [currentValue, setCurrentValue] = useState<T | undefined>(value);
return (
<Modal show onHide={() => close()} id="setting-dialog" {...modalProps}>
<Form
onSubmit={(e) => {
close(currentValue);
e.preventDefault();
}}
>
<Modal.Header closeButton>
{headingID ? <FormattedMessage id={headingID} /> : heading}
</Modal.Header>
<Modal.Body>
{renderField(currentValue, setCurrentValue)}
{subHeadingID ? (
<div className="sub-heading">
{intl.formatMessage({ id: subHeadingID })}
</div>
) : subHeading ? (
<div className="sub-heading">{subHeading}</div>
) : undefined}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => close()}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
onClick={() => close(currentValue)}
>
Confirm
</Button>
</Modal.Footer>
</Form>
</Modal>
);
};
interface IModalSetting<T> extends ISetting {
value: T | undefined;
buttonText?: string;
buttonTextID?: string;
onChange: (v: T) => void;
renderField: (value: T | undefined, setValue: (v?: T) => void) => JSX.Element;
renderValue?: (v: T | undefined) => JSX.Element;
modalProps?: ModalProps;
}
export const ModalSetting = <T extends {}>(props: IModalSetting<T>) => {
const {
id,
className,
value,
headingID,
subHeadingID,
subHeading,
onChange,
renderField,
renderValue,
buttonText,
buttonTextID,
modalProps,
disabled,
} = props;
const [showModal, setShowModal] = useState(false);
return (
<>
{showModal ? (
<SettingModal<T>
headingID={headingID}
subHeadingID={subHeadingID}
subHeading={subHeading}
value={value}
renderField={renderField}
close={(v) => {
if (v !== undefined) onChange(v);
setShowModal(false);
}}
{...modalProps}
/>
) : undefined}
<ChangeButtonSetting<T>
id={id}
className={className}
disabled={disabled}
buttonText={buttonText}
buttonTextID={buttonTextID}
headingID={headingID}
subHeadingID={subHeadingID}
subHeading={subHeading}
value={value}
onChange={() => setShowModal(true)}
renderValue={renderValue}
/>
</>
);
};
interface IStringSetting extends ISetting {
value: string | undefined;
onChange: (v: string) => void;
}
export const StringSetting: React.FC<IStringSetting> = (props) => {
return (
<ModalSetting<string>
{...props}
renderField={(value, setValue) => (
<Form.Control
className="text-input"
value={value ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setValue(e.currentTarget.value)
}
/>
)}
renderValue={(value) => <span>{value}</span>}
/>
);
};
interface INumberSetting extends ISetting {
value: number | undefined;
onChange: (v: number) => void;
}
export const NumberSetting: React.FC<INumberSetting> = (props) => {
return (
<ModalSetting<number>
{...props}
renderField={(value, setValue) => (
<Form.Control
className="text-input"
type="number"
value={value ?? 0}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setValue(Number.parseInt(e.currentTarget.value || "0", 10))
}
/>
)}
renderValue={(value) => <span>{value}</span>}
/>
);
};
interface IStringListSetting extends ISetting {
value: string[] | undefined;
defaultNewValue?: string;
onChange: (v: string[]) => void;
}
export const StringListSetting: React.FC<IStringListSetting> = (props) => {
return (
<ModalSetting<string[]>
{...props}
renderField={(value, setValue) => (
<StringListInput
value={value ?? []}
setValue={setValue}
defaultNewValue={props.defaultNewValue}
/>
)}
renderValue={(value) => (
<div>
{value?.map((v, i) => (
// eslint-disable-next-line react/no-array-index-key
<div key={i}>{v}</div>
))}
</div>
)}
/>
);
};
interface IConstantSetting<T> extends ISetting {
value?: T;
renderValue?: (v: T | undefined) => JSX.Element;
}
export const ConstantSetting = <T extends {}>(props: IConstantSetting<T>) => {
const { id, headingID, subHeading, subHeadingID, renderValue, value } = props;
const intl = useIntl();
return (
<div className="setting" id={id}>
<div>
<h3>{headingID ? intl.formatMessage({ id: headingID }) : undefined}</h3>
<div className="value">{renderValue ? renderValue(value) : value}</div>
{subHeadingID ? (
<div className="sub-heading">
{intl.formatMessage({ id: subHeadingID })}
</div>
) : subHeading ? (
<div className="sub-heading">{subHeading}</div>
) : undefined}
</div>
<div />
</div>
);
};

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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