diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 13a3dcdd6e..8ce70d82b7 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo } from "react"; +import React, { useEffect, useRef, useState, useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Button, @@ -26,10 +26,14 @@ import { useToast } from "src/hooks/Toast"; import ImageUtils from "src/utils/image"; import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { useFormik } from "formik"; -import { Prompt } from "react-router-dom"; +import { + faCheck, + faSearch, + faPlus, +} from "@fortawesome/free-solid-svg-icons"; +import { defaultAutoSaveDelay } from "src/core/config"; import { useConfigurationContext } from "src/hooks/Config"; import { IGroupEntry, SceneGroupTable } from "./SceneGroupTable"; -import { faSearch, faPlus } from "@fortawesome/free-solid-svg-icons"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import { lazyComponent } from "src/utils/lazyComponent"; @@ -134,10 +138,18 @@ export const SceneEditPanel: React.FC = ({ }, [scene.studio]); const { configuration: stashConfig } = useConfigurationContext(); + const autoSaveEnabled = stashConfig?.ui.autoSave ?? true; + const autoSaveDelay = (stashConfig?.ui.autoSaveDelay ?? defaultAutoSaveDelay) * 1000; // Network state const [isLoading, setIsLoading] = useState(false); + const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">( + "idle" + ); + const [saveProgressKey, setSaveProgressKey] = useState(0); + const autoSaveTimer = useRef>(); + const schema = yup.object({ title: yup.string().ensure(), code: yup.string().ensure(), @@ -273,6 +285,35 @@ export const SceneEditPanel: React.FC = ({ } }); + // Auto-save for existing scenes: debounce after any value or error change. + // formik.values is required in deps to reset the debounce timer on each keystroke. + // biome-ignore lint/correctness/useExhaustiveDependencies: formik.values drives debounce reset + useEffect(() => { + if (isNew || !autoSaveEnabled || !formik.dirty) return; + if ( + Object.keys(formik.errors).length > 0 || + customFieldsError !== undefined + ) + return; + + setSaveProgressKey((k) => k + 1); + clearTimeout(autoSaveTimer.current); + autoSaveTimer.current = setTimeout(() => { + formik.submitForm(); + }, autoSaveDelay); + + return () => clearTimeout(autoSaveTimer.current); + }, [ + isNew, + autoSaveEnabled, + autoSaveDelay, + formik.dirty, + formik.errors, + formik.values, + formik.submitForm, + customFieldsError, + ]); + function onSetGroups(items: Group[]) { setGroups(items); @@ -294,14 +335,28 @@ export const SceneEditPanel: React.FC = ({ } async function onSave(input: InputValues, andNew?: boolean) { - setIsLoading(true); - try { - await onSubmit(input, andNew); - formik.resetForm(); - } catch (e) { - Toast.error(e); + if (isNew) { + setIsLoading(true); + try { + await onSubmit(input, andNew); + formik.resetForm(); + } catch (e) { + Toast.error(e); + } finally { + setIsLoading(false); + } + } else { + setSaveStatus("saving"); + try { + await onSubmit(input, andNew); + formik.resetForm(); + setSaveStatus("saved"); + setTimeout(() => setSaveStatus("idle"), 2000); + } catch (e) { + Toast.error(e); + setSaveStatus("idle"); + } } - setIsLoading(false); } async function onSaveAndNewClick() { @@ -750,11 +805,6 @@ export const SceneEditPanel: React.FC = ({ return (
- - {renderScrapeQueryModal()} {maybeRenderScrapeDialog()} {isStashIDSearchOpen && ( @@ -772,69 +822,103 @@ export const SceneEditPanel: React.FC = ({ /> )}
- -
- {isNew ? ( - formik.submitForm()} - > - onSaveAndNewClick()}> - - - - ) : ( - - )} - {onDelete && ( - +
+ +
+ {isNew ? ( + formik.submitForm()} + > + onSaveAndNewClick()}> + + + + ) : null} + {onDelete && ( + + )} +
+ {!isNew && ( +
+ {autoSaveEnabled && ( + + {saveStatus === "saving" && ( + + + {"…"} + + )} + {saveStatus === "saved" && ( + + + + + )} + + )} + + + } + stashBoxes={stashConfig?.general.stashBoxes ?? []} + scrapers={queryableScrapers} + onScraperClicked={onScrapeQueryClicked} + onReloadScrapers={onReloadScrapers} + /> + +
)} -
- {!isNew && ( -
- - + {!isNew && autoSaveEnabled && ( + <> + {formik.dirty && saveStatus === "idle" && ( +
- } - stashBoxes={stashConfig?.general.stashBoxes ?? []} - scrapers={queryableScrapers} - onScraperClicked={onScrapeQueryClicked} - onReloadScrapers={onReloadScrapers} - /> - -
+ )} + {saveStatus === "saving" && ( +
+ )} + {saveStatus === "saved" && ( +
+ )} + )} - +
{renderInputField("title")} diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index dda699744e..1d7dd86dda 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -560,6 +560,36 @@ input[type="range"].blue-slider { @media (min-width: 575px) and (max-width: 1199px) { top: 3rem; } + + .auto-save-progress { + animation: auto-save-fill var(--auto-save-delay, 1000ms) linear forwards; + background-color: var(--primary, #007bff); + height: 2px; + transform: scaleX(0); + transform-origin: left center; + + &--saving { + animation: none; + transform: scaleX(1); + } + + &--saved { + animation: none; + background-color: var(--success, #28a745); + transform: scaleX(1); + transition: background-color 0.2s ease; + } + } + } + + @keyframes auto-save-fill { + from { + transform: scaleX(0); + } + + to { + transform: scaleX(1); + } } .form-group[data-field="urls"] .string-list-input input.form-control { diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 649fdd0778..a650fe3190 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -42,7 +42,11 @@ import { defaultImageWallDirection, defaultImageWallMargin, } from "src/utils/imageWall"; -import { defaultMaxOptionsShown, defaultPreviewVolume } from "src/core/config"; +import { + defaultAutoSaveDelay, + defaultMaxOptionsShown, + defaultPreviewVolume, +} from "src/core/config"; import { PatchComponent } from "src/patch"; const allMenuItems = [ @@ -799,6 +803,21 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( value={ui.maxOptionsShown ?? defaultMaxOptionsShown} onChange={(v) => saveUI({ maxOptionsShown: v })} /> + saveUI({ autoSave: v })} + /> + {(ui.autoSave ?? true) && ( + saveUI({ autoSaveDelay: v })} + /> + )}