fix(site-builder): never save before a successful load; guard empty overwrites

Refs #28
This commit is contained in:
shadowdao committed 2026-10-07 09:57:11 -07:00
1 parent 9ab01021be
commit 66e669fa01
13 files changed
+498 -37

No files matched your search

+3
View File
@@ -6,6 +6,7 @@ import { componentResolver } from './components/resolver';
import { WhpConfig } from './types';
import { EditorConfigProvider } from './state/EditorConfigContext';
import { SiteDesignProvider } from './state/SiteDesignContext';
import { SaveGuardProvider } from './state/SaveGuardContext';
import { PageProvider } from './state/PageContext';
import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext';
import { SitesmithModal } from './panels/sitesmith/SitesmithModal';
@@ -23,6 +24,7 @@ const SitesmithModalMount: React.FC = () => {
export const App: React.FC<AppProps> = ({ whpConfig }) => {
return (
<EditorConfigProvider config={whpConfig}>
<SaveGuardProvider>
<SiteDesignProvider>
<Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
<PageProvider>
@@ -33,6 +35,7 @@ export const App: React.FC<AppProps> = ({ whpConfig }) => {
</PageProvider>
</Editor>
</SiteDesignProvider>
</SaveGuardProvider>
</EditorConfigProvider>
);
};
+2
View File
@@ -12,6 +12,7 @@ import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
import { useIsMobile } from '../hooks/useIsMobile';
import { MobileChromeProvider } from '../state/MobileChromeContext';
import { LayerFocusProvider } from '../panels/left/LayerFocusContext';
import { LoadGate } from './LoadGate';
import { DeviceMode } from '../types';
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
@@ -86,6 +87,7 @@ export const EditorShell: React.FC = () => {
<MobileChromeProvider>
<LayerFocusProvider>
<div className="editor-app">
<LoadGate />
<TopBar
device={device}
onDeviceChange={setDevice}
@@ -0,0 +1,223 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/**
* Data-safety: a failed initial load must never let autosave overwrite the
* saved site with a blank canvas. Drives the real LoadGate + TopBar +
* SiteDesignPanel against a mocked `fetch` (the backend contract) and fake
* timers; only `@craftjs/core`'s useEditor and sibling chrome are stubbed.
*/
const undoMock = vi.fn();
const deserializeMock = vi.fn();
vi.mock('@craftjs/core', () => ({
useEditor: (collector?: (state: unknown, query: unknown) => Record<string, unknown>) => {
const query = {
serialize: () => '{}',
history: { canUndo: () => false, canRedo: () => false },
};
const actions = { history: { undo: undoMock, redo: vi.fn() }, deserialize: deserializeMock };
const collected = collector ? collector({}, query) : {};
return { actions, query, ...collected };
},
}));
vi.mock('../panels/topbar/TemplateModal', () => ({ TemplateModal: () => null }));
vi.mock('../panels/topbar/HeadCodeModal', () => ({ HeadCodeModal: () => null }));
vi.mock('../panels/sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null }));
import { LoadGate } from './LoadGate';
import { TopBar } from '../panels/topbar/TopBar';
import { SiteDesignPanel } from '../panels/right/SiteDesignPanel';
import { EditorConfigProvider } from '../state/EditorConfigContext';
import { SaveGuardProvider } from '../state/SaveGuardContext';
import { PageProvider } from '../state/PageContext';
import { SiteDesignProvider } from '../state/SiteDesignContext';
import { WhpConfig } from '../types';
const whpConfig: WhpConfig = {
user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
};
const jsonResp = (body: unknown, status = 200) => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
});
let container: HTMLDivElement;
let root: Root;
let fetchMock: ReturnType<typeof vi.fn>;
const saveCalls = () => fetchMock.mock.calls.filter(([u]) => String(u).includes('action=save'));
const saveBody = (i: number) => JSON.parse(saveCalls()[i][1].body);
const q = (sel: string) => container.querySelector(sel) as HTMLElement | null;
const flush = () => act(async () => { await Promise.resolve(); await Promise.resolve(); });
function mount() {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<SaveGuardProvider>
<SiteDesignProvider>
<PageProvider>
<LoadGate />
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
<SiteDesignPanel />
</PageProvider>
</SiteDesignProvider>
</SaveGuardProvider>
</EditorConfigProvider>,
);
});
}
beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(console, 'error').mockImplementation(() => {});
undoMock.mockClear();
});
afterEach(() => {
act(() => { root.unmount(); });
container.remove();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('load gate', () => {
test('a failed load blocks autosave and manual save, and shows the failure message', async () => {
fetchMock.mockRejectedValue(new Error('network down'));
mount();
await flush();
expect(q('[data-testid="load-gate"]')!.textContent).toContain(
"We couldn't load your site. Your saved work has not been changed.",
);
await act(async () => { vi.advanceTimersByTime(30000); });
expect(saveCalls()).toHaveLength(0);
expect((q('button[aria-label="Save Draft"]') as HTMLButtonElement).disabled).toBe(true);
expect((q('button[aria-label="Publish to live site"]') as HTMLButtonElement).disabled).toBe(true);
});
test('non-ok, non-JSON and success:false responses all count as failed', async () => {
for (const bad of [
jsonResp({ success: true, project: null }, 500),
{ ok: true, status: 200, json: async () => { throw new SyntaxError('bad json'); } },
jsonResp({ success: false, message: 'nope' }),
]) {
fetchMock.mockReset();
fetchMock.mockResolvedValue(bad);
mount();
await flush();
expect(q('[data-testid="load-retry"]')).not.toBeNull();
act(() => { root.unmount(); });
container.remove();
}
mount(); // so afterEach has something to unmount
});
test('shows a loading overlay while the load is in flight', async () => {
fetchMock.mockReturnValue(new Promise(() => {}));
mount();
await flush();
expect(q('[data-testid="load-gate"]')!.textContent).toContain('Loading your site…');
});
test('Retry recovers and re-enables saving', async () => {
fetchMock.mockRejectedValueOnce(new Error('boom'));
fetchMock.mockResolvedValue(jsonResp({ success: true, project: null, rev: null }));
mount();
await flush();
act(() => { q('[data-testid="load-retry"]')!.click(); });
await flush();
expect(q('[data-testid="load-gate"]')).toBeNull();
expect((q('button[aria-label="Save Draft"]') as HTMLButtonElement).disabled).toBe(false);
});
test('project:null counts as loaded and autosave then runs', async () => {
fetchMock.mockImplementation(async (url: string) =>
String(url).includes('action=load')
? jsonResp({ success: true, project: null, rev: null })
: jsonResp({ success: true }));
mount();
await flush();
expect(q('[data-testid="load-gate"]')).toBeNull();
await act(async () => { vi.advanceTimersByTime(30000); });
expect(saveCalls()).toHaveLength(1);
});
});
describe('empty-overwrite guard', () => {
const loadedThen = (saveResponses: unknown[]) => {
const saves = [...saveResponses];
fetchMock.mockImplementation(async (url: string) =>
String(url).includes('action=load')
? jsonResp({ success: true, project: null, rev: null })
: (saves.shift() ?? jsonResp({ success: true })));
};
test('Reset Entire Site makes the next save send force_empty (once)', async () => {
loadedThen([]);
mount();
await flush();
act(() => { q('[data-action="open-site-reset"]')!.click(); });
const input = q('[data-testid="site-reset-domain"]') as HTMLInputElement;
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
act(() => { setter.call(input, 'example.com'); input.dispatchEvent(new Event('input', { bubbles: true })); });
act(() => { q('[data-action="confirm-site-reset"]')!.click(); });
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
await flush();
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
await flush();
expect(saveBody(0).force_empty).toBe(true);
expect(saveBody(1).force_empty).toBeUndefined();
});
test('a 409 shows the banner, pauses autosave, and Save anyway re-sends with force_empty', async () => {
loadedThen([
jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'refused' }, 409),
jsonResp({ success: true }),
]);
mount();
await flush();
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
await flush();
expect(q('[data-testid="empty-save-banner"]')!.textContent).toContain(
'This would replace your saved site with an empty page.',
);
expect(saveBody(0).force_empty).toBeUndefined();
await act(async () => { vi.advanceTimersByTime(60000); });
expect(saveCalls()).toHaveLength(1); // autosave paused
await act(async () => { q('[data-testid="empty-save-anyway"]')!.click(); });
await flush();
expect(saveCalls()).toHaveLength(2);
expect(saveBody(1).force_empty).toBe(true);
expect(q('[data-testid="empty-save-banner"]')).toBeNull();
});
test('Undo on the banner runs Craft history undo and dismisses it', async () => {
loadedThen([jsonResp({ success: false, error: 'empty_overwrite_refused', message: 'r' }, 409)]);
mount();
await flush();
await act(async () => { q('button[aria-label="Save Draft"]')!.click(); });
await flush();
act(() => { q('[data-testid="empty-save-undo"]')!.click(); });
expect(undoMock).toHaveBeenCalledTimes(1);
expect(q('[data-testid="empty-save-banner"]')).toBeNull();
});
});
+49
View File
@@ -0,0 +1,49 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { useEditorConfig } from '../state/EditorConfigContext';
import { useSaveGuard } from '../state/SaveGuardContext';
import { useWhpApi } from '../hooks/useWhpApi';
/**
* Owns the initial project load (WHP mode only) and blocks the whole editor
* until it succeeds. On failure nothing is editable and nothing can save, so
* a failed load can never be autosaved over the customer's real site.
*/
export const LoadGate: React.FC = () => {
const { isWHP } = useEditorConfig();
const { loadState, setLoadState } = useSaveGuard();
const { load } = useWhpApi();
const startedRef = useRef(false);
const runLoad = useCallback(() => {
setLoadState('loading');
load()
.then(() => setLoadState('loaded'))
.catch((e) => {
console.error('Failed to load project from WHP API:', e);
setLoadState('failed');
});
}, [load, setLoadState]);
useEffect(() => {
if (!isWHP || startedRef.current) return;
startedRef.current = true;
runLoad();
}, [isWHP, runLoad]);
if (!isWHP || loadState === 'loaded') return null;
return (
<div className="load-gate" role={loadState === 'failed' ? 'alert' : 'status'} data-testid="load-gate">
{loadState === 'loading' ? (
<p><i className="fa fa-spinner fa-spin" aria-hidden="true" /> Loading your site…</p>
) : (
<>
<p>We couldn't load your site. Your saved work has not been changed.</p>
<button type="button" className="topbar-btn primary" onClick={runLoad} data-testid="load-retry">
Retry
</button>
</>
)}
</div>
);
};
+2
View File
@@ -99,6 +99,7 @@ describe('useWhpApi load() restores PageData.seo (PKG-H §5)', () => {
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
project: {
@@ -131,6 +132,7 @@ describe('useWhpApi load() restores PageData.seo (PKG-H §5)', () => {
test('a legacy project with no seo on any page loads without adding seo fields', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
project: {
@@ -121,6 +121,7 @@ describe('useWhpApi load() repairs an orphaned node on the FIRST page before des
test('initial load with an orphaned first-page craftState reattaches it and warns', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
project: {
@@ -181,6 +182,7 @@ describe('useWhpApi load() falls back to EMPTY_CANVAS when the first page state
test('a deserialize failure on the first page logs console.error and retries with EMPTY_CANVAS', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
project: {
+50 -8
View File
@@ -6,6 +6,7 @@ import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext';
import { exportBodyHtml } from '../utils/html-export';
import { repairOrphanNodes } from '../utils/orphan-repair';
import { PageData } from '../types';
import { useSaveGuard } from '../state/SaveGuardContext';
export interface BuildSavePayloadInput {
siteId: number;
@@ -195,6 +196,10 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
};
}
export interface SaveOptions {
forceEmpty?: boolean;
}
export function useWhpApi() {
const { query, actions } = useEditor();
const { whpConfig, isWHP } = useEditorConfig();
@@ -212,14 +217,21 @@ export function useWhpApi() {
} = usePages();
const { design, updateDesign } = useSiteDesign();
const save = useCallback(async () => {
const { loadState, forceEmptyNextSave } = useSaveGuard();
const save = useCallback(async (opts?: SaveOptions) => {
if (!isWHP || !whpConfig) return null;
// Never save before a successful load: the canvas may be blank only
// because the load failed, and saving it would destroy the real site.
if (loadState !== 'loaded') return null;
// Serialize whatever is currently on the live canvas (a page, the
// header, or the footer — depending on activePageId/isEditingHeader/
// isEditingFooter).
const liveCraftState = query.serialize();
const forceEmpty = !!opts?.forceEmpty || forceEmptyNextSave.current;
const payload = buildSavePayload({
siteId: whpConfig.siteId,
siteName: whpConfig.siteName,
@@ -240,16 +252,36 @@ export function useWhpApi() {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify(payload),
body: JSON.stringify(forceEmpty ? { ...payload, force_empty: true } : payload),
});
return resp.json();
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
// 409 empty_overwrite_refused is an expected outcome the UI handles, so
// return it as a structured result instead of throwing.
if (resp.status === 409) {
const body = await resp.json().catch(() => null);
if (body?.error === 'empty_overwrite_refused') {
return {
success: false,
error: 'empty_overwrite_refused',
message: body.message || 'This would replace your saved site with an empty page.',
};
}
return body ?? { success: false, error: 'conflict' };
}
const result = await resp.json();
// The one-shot force_empty is spent only once a save actually went through.
if (forceEmpty && result?.success) forceEmptyNextSave.current = false;
return result;
}, [isWHP, whpConfig, loadState, forceEmptyNextSave, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
const publish = useCallback(async () => {
if (!isWHP || !whpConfig) return null;
if (loadState !== 'loaded') return null;
// First save to ensure staging is up to date
await save();
const saved = await save();
if (saved?.error === 'empty_overwrite_refused') return saved;
// Then publish from staging to live
const resp = await fetch(
@@ -264,7 +296,7 @@ export function useWhpApi() {
},
);
return resp.json();
}, [isWHP, whpConfig, save]);
}, [isWHP, whpConfig, loadState, save]);
const load = useCallback(async () => {
if (!isWHP || !whpConfig) return null;
@@ -272,9 +304,19 @@ export function useWhpApi() {
const resp = await fetch(
`${whpConfig.apiUrl}?action=load&site_id=${whpConfig.siteId}`,
);
const data = await resp.json();
if (!resp.ok) throw new Error(`Load failed: HTTP ${resp.status}`);
let data;
try {
data = await resp.json();
} catch {
throw new Error('Load failed: response was not valid JSON');
}
if (!data || data.success !== true) {
throw new Error(`Load failed: ${data?.message || data?.error || 'server reported failure'}`);
}
if (data.success && data.project) {
// project: null is a success (brand-new site, nothing saved yet).
if (data.project) {
const proj = data.project;
// Restore site design tokens (colors/fonts/headCode) so the editor
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
import { usePages } from '../../state/PageContext';
import { useEditorConfig } from '../../state/EditorConfigContext';
import { useSaveGuard } from '../../state/SaveGuardContext';
import { SerializedTreeNode } from '../../types/sitesmith';
import { FONT_FAMILIES } from '../../constants/presets';
import { AssetPicker } from '../../ui/AssetPicker';
@@ -224,6 +225,7 @@ export const SiteDesignPanel: React.FC = () => {
const { design, updateDesign, resetToDefaults } = useSiteDesign();
const { replaceAllPages, setHeader, setFooter } = usePages();
const { whpConfig } = useEditorConfig();
const { armForceEmptyNextSave } = useSaveGuard();
// Standalone mode (no WHP_CONFIG) has no site domain to confirm against --
// an empty typed input would then trivially "match" an empty siteDomain,
// arming the destructive confirm button with no guard at all. The entry
@@ -240,6 +242,9 @@ export const SiteDesignPanel: React.FC = () => {
// future refactor that drops it).
if (!siteResetConfirmMatches(siteResetTyped, siteDomain)) return;
// The user explicitly confirmed wiping the site, so the next save must
// be allowed to replace the saved site with an empty one.
armForceEmptyNextSave();
replaceAllPages([{ name: 'Home', tree: BLANK_PAGE_TREE }]);
setHeader(BLANK_HEADER_TREE);
setFooter(BLANK_FOOTER_TREE);
@@ -0,0 +1,17 @@
import React from 'react';
export interface EmptySaveBannerProps {
onSaveAnyway: () => void;
onUndo: () => void;
}
/** Persistent banner shown when the server refused a save that would replace
* the saved site with an empty page. Autosave stays paused while it's up. */
export const EmptySaveBanner: React.FC<EmptySaveBannerProps> = ({ onSaveAnyway, onUndo }) => (
<div className="empty-save-banner" role="alert" data-testid="empty-save-banner">
<i className="fa fa-exclamation-triangle" aria-hidden="true" />
<span className="empty-save-banner-text">This would replace your saved site with an empty page.</span>
<button type="button" onClick={onSaveAnyway} data-testid="empty-save-anyway">Save anyway</button>
<button type="button" onClick={onUndo} data-testid="empty-save-undo">Undo</button>
</div>
);
+7 -1
View File
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { useEditor } from '@craftjs/core';
import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { useSaveGuard } from '../../state/SaveGuardContext';
import {
allTemplates,
TemplateDefinition,
@@ -48,6 +49,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
const { actions, query } = useEditor();
const { pages, addPage, switchPage, deletePage, editHeader, editFooter } = usePages();
const { updateDesign } = useSiteDesign();
const { armForceEmptyNextSave } = useSaveGuard();
const [activeTab, setActiveTab] = useState<FilterTab>('all');
const [confirmTemplate, setConfirmTemplate] = useState<TemplateDefinition | null>(null);
@@ -183,6 +185,10 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
const tpl = confirmTemplate;
setConfirmTemplate(null);
// A blank template replaces the whole site with an empty one; the user
// just confirmed that, so the next save must not be refused as accidental.
if (tpl.pages.every((p) => !p.content.components?.length)) armForceEmptyNextSave();
try {
// 1. Optionally apply design tokens
if (applyDesign && tpl.design) {
@@ -237,7 +243,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
if (mountedRef.current) setLoading(false);
onClose();
}
}, [confirmTemplate, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose]);
}, [confirmTemplate, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose, armForceEmptyNextSave]);
// Mobile-A2: portaled to `document.body` -- TopBar.tsx mounts this modal
// as a child of `.topbar`, which (as a flex item with its own z-index)
+42 -28
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { useEditorConfig } from '../../state/EditorConfigContext';
import { useWhpApi } from '../../hooks/useWhpApi';
import { useWhpApi, SaveOptions } from '../../hooks/useWhpApi';
import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { useIsMobile } from '../../hooks/useIsMobile';
@@ -12,6 +12,8 @@ import { HeadCodeModal } from './HeadCodeModal';
import { ReportIssueModal } from './ReportIssueModal';
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
import { PublishWarnings } from './PublishWarnings';
import { EmptySaveBanner } from './EmptySaveBanner';
import { useSaveGuard } from '../../state/SaveGuardContext';
import { SitesmithButton } from '../sitesmith/SitesmithButton';
import { useSitesmithModal } from '../../state/SitesmithContext';
@@ -29,7 +31,9 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
canUndo: query.history.canUndo(),
canRedo: query.history.canRedo(),
}));
const { save, publish, load } = useWhpApi();
const { save, publish } = useWhpApi();
const { loadState, emptyRefusal, setEmptyRefusal } = useSaveGuard();
const canSave = loadState === 'loaded';
const { headerPage, footerPage, pages, activePageId } = usePages();
const { design } = useSiteDesign();
@@ -46,26 +50,18 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
const { open: openSitesmith } = useSitesmithModal();
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasLoadedRef = useRef(false);
// Load saved state on mount
useEffect(() => {
if (!isWHP || hasLoadedRef.current) return;
hasLoadedRef.current = true;
load().catch((e) => {
console.warn('Failed to load project from WHP API:', e);
});
}, [isWHP, load]);
// Auto-save every 30 seconds
useEffect(() => {
if (!isWHP) return;
// Paused until the site has loaded, and while the empty-save banner is up.
if (!isWHP || !canSave || emptyRefusal) return;
const interval = setInterval(() => {
save()
.then((result) => {
if (result?.success) {
if (result?.error === 'empty_overwrite_refused') {
setEmptyRefusal(result.message);
} else if (result?.success) {
setSaveStatus('saved');
setIsDraft(true);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
@@ -78,13 +74,18 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
}, 30000);
return () => clearInterval(interval);
}, [isWHP, save]);
}, [isWHP, canSave, emptyRefusal, setEmptyRefusal, save]);
const handleSave = useCallback(async () => {
const handleSave = useCallback(async (opts?: SaveOptions) => {
if (!canSave) return;
setSaveStatus('saving');
try {
const result = await save();
if (result?.success) {
const result = await save(opts);
if (result?.error === 'empty_overwrite_refused') {
setEmptyRefusal(result.message);
setSaveStatus('idle');
} else if (result?.success) {
setEmptyRefusal(null);
setSaveStatus('saved');
setIsDraft(true);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
@@ -100,14 +101,18 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
}
}, [save]);
}, [save, canSave, setEmptyRefusal]);
const handlePublish = useCallback(async () => {
if (!canSave) return;
setPublishStatus('publishing');
setPublishWarnings([]);
try {
const result = await publish();
if (result?.success) {
if (result?.error === 'empty_overwrite_refused') {
setEmptyRefusal(result.message);
setPublishStatus('idle');
} else if (result?.success) {
setPublishStatus('published');
setIsDraft(false);
// The site published; these are fixable problems, not failures. Held
@@ -127,7 +132,14 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
}
}, [publish]);
}, [publish, canSave, setEmptyRefusal]);
const emptyBanner = emptyRefusal ? (
<EmptySaveBanner
onSaveAnyway={() => { void handleSave({ forceEmpty: true }); }}
onUndo={() => { actions.history.undo(); setEmptyRefusal(null); }}
/>
) : null;
// Cleanup timeouts on unmount
useEffect(() => {
@@ -230,6 +242,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
return (
<nav className="topbar topbar-mobile">
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
{emptyBanner}
<div className="topbar-left">
{isWHP && (
<a
@@ -265,8 +278,8 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
<span className="topbar-divider" />
<button
className="topbar-btn icon-only primary"
onClick={handleSave}
disabled={saveStatus === 'saving'}
onClick={() => handleSave()}
disabled={saveStatus === 'saving' || !canSave}
aria-label="Save Draft"
data-tooltip="Save"
>
@@ -276,7 +289,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
<button
className="topbar-btn icon-only publish"
onClick={handlePublish}
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
disabled={publishStatus === 'publishing' || saveStatus === 'saving' || !canSave}
aria-label="Publish to live site"
data-tooltip="Publish"
>
@@ -320,6 +333,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
return (
<nav className="topbar">
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
{emptyBanner}
<div className="topbar-left">
{isWHP && (
<a href={whpConfig!.backUrl} className="topbar-btn back-btn" aria-label="Back to Panel">
@@ -424,8 +438,8 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
<button
className="topbar-btn primary"
onClick={handleSave}
disabled={saveStatus === 'saving'}
onClick={() => handleSave()}
disabled={saveStatus === 'saving' || !canSave}
title="Save Draft"
aria-label="Save Draft"
>
@@ -440,7 +454,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
<button
className="topbar-btn publish"
onClick={handlePublish}
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
disabled={publishStatus === 'publishing' || saveStatus === 'saving' || !canSave}
title="Publish to live site"
aria-label="Publish to live site"
>
+51
View File
@@ -0,0 +1,51 @@
import React, { createContext, useContext, useMemo, useRef, useState, ReactNode } from 'react';
import { useEditorConfig } from './EditorConfigContext';
export type LoadState = 'loading' | 'loaded' | 'failed';
interface SaveGuardContextValue {
/** Nothing may be saved until this is 'loaded' -- a failed load leaves the
* canvas blank, and saving that would overwrite the customer's site. */
loadState: LoadState;
setLoadState: (s: LoadState) => void;
/** Server refused a save that would replace a non-empty site with an empty
* one (409 empty_overwrite_refused). Holds the message while the banner is up. */
emptyRefusal: string | null;
setEmptyRefusal: (message: string | null) => void;
/** One-shot: the next save sends force_empty (consumed by the next successful save). */
forceEmptyNextSave: React.MutableRefObject<boolean>;
armForceEmptyNextSave: () => void;
}
// Default (no provider: standalone mode and isolated unit tests) is
// permissive -- the gate only applies inside <SaveGuardProvider> in WHP mode.
const defaultValue: SaveGuardContextValue = {
loadState: 'loaded',
setLoadState: () => {},
emptyRefusal: null,
setEmptyRefusal: () => {},
forceEmptyNextSave: { current: false },
armForceEmptyNextSave: () => {},
};
const SaveGuardContext = createContext<SaveGuardContextValue>(defaultValue);
export const useSaveGuard = () => useContext(SaveGuardContext);
export const SaveGuardProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { isWHP } = useEditorConfig();
const [loadState, setLoadState] = useState<LoadState>(isWHP ? 'loading' : 'loaded');
const [emptyRefusal, setEmptyRefusal] = useState<string | null>(null);
const forceEmptyNextSave = useRef(false);
const value = useMemo<SaveGuardContextValue>(() => ({
loadState,
setLoadState,
emptyRefusal,
setEmptyRefusal,
forceEmptyNextSave,
armForceEmptyNextSave: () => { forceEmptyNextSave.current = true; },
}), [loadState, emptyRefusal]);
return <SaveGuardContext.Provider value={value}>{children}</SaveGuardContext.Provider>;
};
+45
View File
@@ -1929,3 +1929,48 @@ body {
background: var(--color-bg-hover);
color: var(--color-text);
}
/* Blocking overlay while the initial project load runs / has failed. Covers
everything below the topbar so nothing is editable behind it. */
.load-gate {
position: fixed;
top: 48px;
left: 0;
right: 0;
bottom: 0;
z-index: var(--z-modal);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: rgba(15, 23, 42, 0.92);
color: #e2e8f0;
font-size: 14px;
}
.load-gate p { margin: 0; }
.empty-save-banner {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 41;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #450a0a;
border-bottom: 1px solid #b91c1c;
color: #fecaca;
font-size: 12px;
}
.empty-save-banner-text { flex: 1; }
.empty-save-banner button {
background: none;
border: 1px solid #fecaca;
border-radius: 4px;
color: #fecaca;
cursor: pointer;
padding: 2px 10px;
}