Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 159 additions & 75 deletions ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -134,10 +138,18 @@ export const SceneEditPanel: React.FC<IProps> = ({
}, [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<ReturnType<typeof setTimeout>>();

const schema = yup.object({
title: yup.string().ensure(),
code: yup.string().ensure(),
Expand Down Expand Up @@ -273,6 +285,35 @@ export const SceneEditPanel: React.FC<IProps> = ({
}
});

// 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);

Expand All @@ -294,14 +335,28 @@ export const SceneEditPanel: React.FC<IProps> = ({
}

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() {
Expand Down Expand Up @@ -750,11 +805,6 @@ export const SceneEditPanel: React.FC<IProps> = ({

return (
<div id="scene-edit-details">
<Prompt
when={formik.dirty}
message={intl.formatMessage({ id: "dialogs.unsaved_changes" })}
/>

{renderScrapeQueryModal()}
{maybeRenderScrapeDialog()}
{isStashIDSearchOpen && (
Expand All @@ -772,69 +822,103 @@ export const SceneEditPanel: React.FC<IProps> = ({
/>
)}
<Form noValidate onSubmit={formik.handleSubmit}>
<Row className="form-container edit-buttons-container px-3 pt-3">
<div className="edit-buttons mb-3 pl-0">
{isNew ? (
<SplitButton
id="scene-save-split-button"
className="edit-button"
variant="primary"
disabled={
!isEqual(formik.errors, {}) || customFieldsError !== undefined
}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => formik.submitForm()}
>
<Dropdown.Item onClick={() => onSaveAndNewClick()}>
<FormattedMessage id="actions.save_and_new" />
</Dropdown.Item>
</SplitButton>
) : (
<Button
className="edit-button"
variant="primary"
disabled={
(!isNew && !formik.dirty) ||
!isEqual(formik.errors, {}) ||
customFieldsError !== undefined
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
)}
{onDelete && (
<Button
className="edit-button"
variant="danger"
onClick={() => onDelete()}
>
<FormattedMessage id="actions.delete" />
</Button>
<div className="edit-buttons-container">
<Row className="form-container px-3 pt-3">
<div className="edit-buttons mb-3 pl-0">
{isNew ? (
<SplitButton
id="scene-save-split-button"
className="edit-button"
variant="primary"
disabled={
!isEqual(formik.errors, {}) ||
customFieldsError !== undefined
}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => formik.submitForm()}
>
<Dropdown.Item onClick={() => onSaveAndNewClick()}>
<FormattedMessage id="actions.save_and_new" />
</Dropdown.Item>
</SplitButton>
) : null}
{onDelete && (
<Button
className="edit-button"
variant="danger"
onClick={() => onDelete()}
>
<FormattedMessage id="actions.delete" />
</Button>
)}
</div>
{!isNew && (
<div className="ml-auto text-right d-flex align-items-center">
{autoSaveEnabled && (
<span className="scene-edit-save-status mr-3">
{saveStatus === "saving" && (
<span className="text-muted">
<FormattedMessage id="actions.save" />
{"…"}
</span>
)}
{saveStatus === "saved" && (
<span className="text-success">
<Icon icon={faCheck} className="mr-1" />
<FormattedMessage
id="toast.saved_entity"
values={{
entity: intl
.formatMessage({ id: "scene" })
.toLocaleLowerCase(),
}}
/>
</span>
)}
</span>
)}
<ButtonGroup className="scraper-group">
<ScraperMenu
toggle={intl.formatMessage({ id: "actions.scrape_with" })}
stashBoxes={stashConfig?.general.stashBoxes ?? []}
scrapers={fragmentScrapers}
onScraperClicked={onScrapeClicked}
onReloadScrapers={onReloadScrapers}
/>
<ScraperMenu
variant="secondary"
toggle={<Icon icon={faSearch} />}
stashBoxes={stashConfig?.general.stashBoxes ?? []}
scrapers={queryableScrapers}
onScraperClicked={onScrapeQueryClicked}
onReloadScrapers={onReloadScrapers}
/>
</ButtonGroup>
</div>
)}
</div>
{!isNew && (
<div className="ml-auto text-right d-flex">
<ButtonGroup className="scraper-group">
<ScraperMenu
toggle={intl.formatMessage({ id: "actions.scrape_with" })}
stashBoxes={stashConfig?.general.stashBoxes ?? []}
scrapers={fragmentScrapers}
onScraperClicked={onScrapeClicked}
onReloadScrapers={onReloadScrapers}
</Row>
{!isNew && autoSaveEnabled && (
<>
{formik.dirty && saveStatus === "idle" && (
<div
key={saveProgressKey}
className="auto-save-progress"
style={
{
"--auto-save-delay": `${autoSaveDelay}ms`,
} as React.CSSProperties
}
/>
<ScraperMenu
variant="secondary"
toggle={<Icon icon={faSearch} />}
stashBoxes={stashConfig?.general.stashBoxes ?? []}
scrapers={queryableScrapers}
onScraperClicked={onScrapeQueryClicked}
onReloadScrapers={onReloadScrapers}
/>
</ButtonGroup>
</div>
)}
{saveStatus === "saving" && (
<div className="auto-save-progress auto-save-progress--saving" />
)}
{saveStatus === "saved" && (
<div className="auto-save-progress auto-save-progress--saved" />
)}
</>
)}
</Row>
</div>
<Row className="form-container px-3">
<Col lg={7} xl={12}>
{renderInputField("title")}
Expand Down
30 changes: 30 additions & 0 deletions ui/v2.5/src/components/Scenes/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -799,6 +803,21 @@ export const SettingsInterfacePanel: React.FC = PatchComponent(
value={ui.maxOptionsShown ?? defaultMaxOptionsShown}
onChange={(v) => saveUI({ maxOptionsShown: v })}
/>
<BooleanSetting
id="auto_save"
headingID="config.ui.editing.auto_save.heading"
subHeadingID="config.ui.editing.auto_save.description"
checked={ui.autoSave ?? true}
onChange={(v) => saveUI({ autoSave: v })}
/>
{(ui.autoSave ?? true) && (
<NumberSetting
id="auto_save_delay"
headingID="config.ui.editing.auto_save.delay.label"
value={ui.autoSaveDelay ?? defaultAutoSaveDelay}
onChange={(v) => saveUI({ autoSaveDelay: v })}
/>
)}
<SelectSetting
id="rating_system"
headingID="config.ui.editing.rating_system.type.label"
Expand Down
6 changes: 6 additions & 0 deletions ui/v2.5/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type FrontPageContent = ISavedFilterRow | ICustomFilter;

export const defaultMaxOptionsShown = 200;
export const defaultPreviewVolume = 25;
export const defaultAutoSaveDelay = 1;

export interface IUIConfig {
// unknown to prevent direct access - use getFrontPageContent
Expand Down Expand Up @@ -95,6 +96,11 @@ export interface IUIConfig {
// upper limit of 1000
maxOptionsShown?: number;

// auto-save settings for edit panels
autoSave?: boolean;
// delay in seconds before auto-saving (default 1)
autoSaveDelay?: number;

imageWallOptions?: ImageWallOptions;

lastNoteSeen?: number;
Expand Down
7 changes: 7 additions & 0 deletions ui/v2.5/src/locales/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,13 @@
}
},
"editing": {
"auto_save": {
"delay": {
"label": "Auto-save delay (seconds)"
},
"description": "Automatically save changes to scenes without requiring a manual save.",
"heading": "Auto-save"
},
"disable_dropdown_create": {
"description": "Remove the ability to create new objects from the dropdown selectors.",
"heading": "Disable dropdown create"
Expand Down