feat(site-builder): version history modal with preview and restore
Adds a site.history command (WHP mode, only when history_list answers) opening a modal of saves/publishes by day. Preview opens the snapshot re-exported with its own design; Restore site / Restore one page load the snapshot as unsaved state (history cleared, dirty marked) so the user still clicks Save. Refs #51 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5430f13877
commit
a9c8f4d65a
8 files changed
+947
No files matched your search
@@ -3,6 +3,7 @@ import { NoticeHost } from './NoticeHost';
|
||||
import { EmptyContainerAdd } from './EmptyContainerAdd';
|
||||
import { DropIndicator } from './DropIndicator';
|
||||
import { RichTextToolbar } from './RichTextToolbar';
|
||||
import { HistoryHost } from '../panels/history/HistoryModal';
|
||||
|
||||
/**
|
||||
* Single mount point for editor-wide overlays rendered next to the shell.
|
||||
@@ -15,5 +16,6 @@ export const CanvasOverlays: React.FC = () => (
|
||||
<EmptyContainerAdd />
|
||||
<DropIndicator />
|
||||
<RichTextToolbar />
|
||||
<HistoryHost />
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { parseHistoryItems } from './useHistoryApi';
|
||||
|
||||
describe('parseHistoryItems', () => {
|
||||
test('keeps well-formed rows in order and drops malformed ones', () => {
|
||||
const out = parseHistoryItems([
|
||||
{ id: 'a.json.gz', kind: 'save', at: '2026-10-07T17:15:01.042Z', size: 10 },
|
||||
{ id: '', kind: 'save', at: '2026-10-07T17:15:01.042Z', size: 1 },
|
||||
{ id: 'b', kind: 'weird', at: '2026-10-07T17:15:01.042Z', size: 1 },
|
||||
{ id: 'c', kind: 'publish', at: 'not a date', size: 1 },
|
||||
null,
|
||||
{ id: 'd.json.gz', kind: 'publish', at: '2026-10-06T10:00:00Z' },
|
||||
]);
|
||||
expect(out.map((i) => i.id)).toEqual(['a.json.gz', 'd.json.gz']);
|
||||
expect(out[1]).toMatchObject({ kind: 'publish', size: 0 });
|
||||
});
|
||||
test('non-array gives []', () => {
|
||||
expect(parseHistoryItems({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useEditorConfig } from '../state/EditorConfigContext';
|
||||
import type { Snapshot } from '../utils/history-restore';
|
||||
|
||||
export interface HistoryItem {
|
||||
id: string;
|
||||
kind: 'save' | 'publish';
|
||||
/** ISO timestamp of the save/publish that produced this snapshot. */
|
||||
at: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** Keep only well-formed rows; a malformed row must not break the list. */
|
||||
export function parseHistoryItems(raw: unknown): HistoryItem[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: HistoryItem[] = [];
|
||||
for (const r of raw) {
|
||||
if (!r || typeof r !== 'object') continue;
|
||||
const { id, kind, at, size } = r as Record<string, unknown>;
|
||||
if (typeof id !== 'string' || !id) continue;
|
||||
if (kind !== 'save' && kind !== 'publish') continue;
|
||||
if (typeof at !== 'string' || Number.isNaN(Date.parse(at))) continue;
|
||||
out.push({ id, kind, at, size: typeof size === 'number' ? size : 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only version-history client. `null` from either call means "not
|
||||
* available" (old backend without the actions, a failed request, a bad
|
||||
* envelope): callers hide the feature rather than show an error.
|
||||
*/
|
||||
export function useHistoryApi() {
|
||||
const { isWHP, whpConfig } = useEditorConfig();
|
||||
|
||||
const get = useCallback(async (query: string): Promise<any | null> => {
|
||||
if (!isWHP || !whpConfig) return null;
|
||||
try {
|
||||
const resp = await fetch(`${whpConfig.apiUrl}?${query}&site_id=${whpConfig.siteId}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRF-Token': whpConfig.csrfToken },
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return data && data.success === true ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [isWHP, whpConfig]);
|
||||
|
||||
const listHistory = useCallback(async (): Promise<HistoryItem[] | null> => {
|
||||
const data = await get('action=history_list');
|
||||
if (!data || !Array.isArray(data.items)) return null;
|
||||
return parseHistoryItems(data.items);
|
||||
}, [get]);
|
||||
|
||||
const getSnapshot = useCallback(async (id: string): Promise<Snapshot | null> => {
|
||||
const data = await get(`action=history_get&id=${encodeURIComponent(id)}`);
|
||||
const p = data?.project;
|
||||
return p && typeof p === 'object' && !Array.isArray(p) ? (p as Snapshot) : null;
|
||||
}, [get]);
|
||||
|
||||
return useMemo(() => ({ listHistory, getSnapshot }), [listHistory, getSnapshot]);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, test, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness, type EditorHarness } from '../../test-utils/editorHarness';
|
||||
import { EditorConfigProvider } from '../../state/EditorConfigContext';
|
||||
import { SiteDesignProvider, useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { SaveGuardProvider, useSaveGuard } from '../../state/SaveGuardContext';
|
||||
import { ProjectExtrasProvider, useProjectExtras } from '../../state/ProjectExtrasContext';
|
||||
import { NoticeProvider } from '../../state/NoticeContext';
|
||||
import { PageProvider, usePages } from '../../state/PageContext';
|
||||
import { HistoryHost, HistoryModal, openHistoryModal } from './HistoryModal';
|
||||
import { getCommand, __resetCommandsForTests } from '../../editor/commands';
|
||||
import type { WhpConfig } from '../../types';
|
||||
|
||||
const cfg: WhpConfig = { user: 'u', apiUrl: '/api/sb', csrfToken: 't', siteId: 7, siteDomain: 'x.com', siteName: 'X', backUrl: '/', isRoot: false };
|
||||
|
||||
const state = (text: string) => JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container', custom: {}, hidden: false, nodes: ['h'], linkedNodes: {}, parent: null },
|
||||
h: { type: { resolvedName: 'Heading' }, isCanvas: false, props: { text, level: 'h1' }, displayName: 'Heading', custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT' },
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ id: '20261007T171501Z-042-save.json.gz', kind: 'save', at: '2026-10-07T17:15:01.042Z', size: 100 },
|
||||
{ id: '20261007T090000Z-001-publish.json.gz', kind: 'publish', at: '2026-10-07T09:00:00.001Z', size: 90 },
|
||||
];
|
||||
const snapshot = {
|
||||
pages_craft_state: [
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: state('Snapshot home') },
|
||||
{ id: 'p2', name: 'About', slug: 'about', craftState: state('Snapshot about') },
|
||||
],
|
||||
design: { primaryColor: '#abcdef' },
|
||||
extras: { presets: ['snap'] },
|
||||
};
|
||||
|
||||
type Live = { pages: ReturnType<typeof usePages>; design: ReturnType<typeof useSiteDesign>; guard: ReturnType<typeof useSaveGuard>; extras: ReturnType<typeof useProjectExtras> };
|
||||
let live: Live;
|
||||
const Probe: React.FC = () => {
|
||||
const { loadState, setLoadState } = useSaveGuard();
|
||||
React.useEffect(() => { if (loadState !== 'loaded') setLoadState('loaded'); }, [loadState, setLoadState]);
|
||||
live = { pages: usePages(), design: useSiteDesign(), guard: useSaveGuard(), extras: useProjectExtras() };
|
||||
return null;
|
||||
};
|
||||
|
||||
let h: EditorHarness;
|
||||
function mount(config: WhpConfig | null, ui: React.ReactNode) {
|
||||
h = renderEditorHarness({ initialState: state('Current home') });
|
||||
h.mountChild(
|
||||
<EditorConfigProvider config={config}>
|
||||
<NoticeProvider>
|
||||
<SaveGuardProvider>
|
||||
<ProjectExtrasProvider>
|
||||
<SiteDesignProvider>
|
||||
<PageProvider>
|
||||
<Probe />
|
||||
{ui}
|
||||
</PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</ProjectExtrasProvider>
|
||||
</SaveGuardProvider>
|
||||
</NoticeProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const flush = async () => { await h.act(async () => { await new Promise((r) => setTimeout(r, 0)); }); };
|
||||
const click = async (el: Element | null | undefined) => { expect(el).toBeTruthy(); await h.act(async () => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); }); await flush(); };
|
||||
const btn = (label: string, root: ParentNode = document.body) => Array.from(root.querySelectorAll('button')).find((b) => b.textContent === label);
|
||||
const row = (i: number) => document.body.querySelectorAll('.wsb-history-row')[i] as HTMLElement;
|
||||
|
||||
function mockApi(opts: { list?: unknown; snap?: unknown; listOk?: boolean } = {}) {
|
||||
const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => {
|
||||
if (url.includes('action=history_list')) {
|
||||
if (opts.listOk === false) return { ok: false, status: 400, json: async () => ({ success: false }) };
|
||||
return { ok: true, json: async () => opts.list ?? { success: true, items } };
|
||||
}
|
||||
if (url.includes('action=history_get')) return { ok: true, json: async () => ({ success: true, project: opts.snap ?? snapshot }) };
|
||||
return { ok: false, json: async () => ({}) };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
beforeEach(() => { __resetCommandsForTests(); });
|
||||
afterEach(() => { h?.unmount(); vi.unstubAllGlobals(); __resetCommandsForTests(); });
|
||||
|
||||
const canvasText = () => h.container.textContent ?? '';
|
||||
|
||||
describe('HistoryModal', () => {
|
||||
test('lists rows with kind badge and exact-time tooltip', async () => {
|
||||
mockApi();
|
||||
mount(cfg, <HistoryModal open onClose={() => {}} />);
|
||||
await flush();
|
||||
const rows = document.body.querySelectorAll('.wsb-history-row');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].querySelector('.wsb-history-badge')!.textContent).toBe('Save');
|
||||
expect(rows[1].querySelector('.wsb-history-badge')!.textContent).toBe('Publish');
|
||||
expect(rows[0].querySelector('.wsb-history-time')!.getAttribute('title')).toBe(new Date(items[0].at).toLocaleString());
|
||||
});
|
||||
|
||||
test('Restore site loads the snapshot as unsaved state', async () => {
|
||||
const fetchMock = mockApi();
|
||||
const onClose = vi.fn();
|
||||
mount(cfg, <HistoryModal open onClose={onClose} />);
|
||||
await flush();
|
||||
expect(canvasText()).toContain('Current home');
|
||||
expect(live.guard.isDirty()).toBe(false);
|
||||
|
||||
await click(btn('Restore site…', row(0)));
|
||||
expect(document.body.textContent).toContain('Nothing is saved until you click Save');
|
||||
expect(live.guard.isDirty()).toBe(false);
|
||||
await click(btn('Load this version'));
|
||||
|
||||
expect(live.pages.pages.map((p) => ({ id: p.id, slug: p.slug, craftState: p.craftState }))).toEqual([
|
||||
{ id: 'home', slug: 'index', craftState: snapshot.pages_craft_state[0].craftState },
|
||||
{ id: 'p2', slug: 'about', craftState: snapshot.pages_craft_state[1].craftState },
|
||||
]);
|
||||
expect(live.pages.activePageId).toBe('home');
|
||||
expect(canvasText()).toContain('Snapshot home');
|
||||
expect(canvasText()).not.toContain('Current home');
|
||||
expect(live.guard.isDirty()).toBe(true);
|
||||
expect(live.design.design.primaryColor).toBe('#abcdef');
|
||||
expect(live.extras.all()).toEqual({ presets: ['snap'] });
|
||||
expect(h.query.history.canUndo()).toBe(false);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
// read-only: only GETs were issued
|
||||
expect(fetchMock.mock.calls.every(([, init]) => !init || !(init as any).method || (init as any).method === 'GET')).toBe(true);
|
||||
});
|
||||
|
||||
test('Restore one page replaces only that page', async () => {
|
||||
mockApi();
|
||||
mount(cfg, <HistoryModal open onClose={() => {}} />);
|
||||
await flush();
|
||||
// Make the current site have two pages, with the second distinct.
|
||||
await h.act(async () => {
|
||||
live.pages.setPagesCraftState([
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: state('Current home') },
|
||||
{ id: 'p2', name: 'About', slug: 'about', craftState: state('Current about') },
|
||||
]);
|
||||
});
|
||||
|
||||
await click(btn('Restore one page…', row(0)));
|
||||
await click(btn('About', document.body.querySelector('.wsb-history-pages')!));
|
||||
|
||||
expect(live.pages.pages[0].craftState).toBe(state('Current home'));
|
||||
expect(live.pages.pages[1].craftState).toBe(snapshot.pages_craft_state[1].craftState);
|
||||
expect(canvasText()).toContain('Current home');
|
||||
expect(live.guard.isDirty()).toBe(true);
|
||||
expect(live.design.design.primaryColor).not.toBe('#abcdef');
|
||||
});
|
||||
|
||||
test('Restore one page on the active page puts it on the canvas', async () => {
|
||||
mockApi();
|
||||
mount(cfg, <HistoryModal open onClose={() => {}} />);
|
||||
await flush();
|
||||
await click(btn('Restore one page…', row(0)));
|
||||
await click(btn('Home', document.body.querySelector('.wsb-history-pages')!));
|
||||
expect(canvasText()).toContain('Snapshot home');
|
||||
expect(live.guard.isDirty()).toBe(true);
|
||||
});
|
||||
|
||||
test('an unreadable snapshot is refused and nothing changes', async () => {
|
||||
mockApi({ snap: { pages_craft_state: [{ id: 'home', name: 'Home', slug: 'index', craftState: '{"nope":1}' }] } });
|
||||
mount(cfg, <HistoryModal open onClose={() => {}} />);
|
||||
await flush();
|
||||
await click(btn('Restore site…', row(0)));
|
||||
expect(btn('Load this version')).toBeUndefined();
|
||||
expect(canvasText()).toContain('Current home');
|
||||
expect(live.guard.isDirty()).toBe(false);
|
||||
});
|
||||
|
||||
test('Preview of a multi-page snapshot asks which page, then opens a blob tab', async () => {
|
||||
mockApi();
|
||||
const fakeWin = { closed: false, close: vi.fn(), location: { href: '' } };
|
||||
const openSpy = vi.fn(() => fakeWin);
|
||||
vi.stubGlobal('open', openSpy);
|
||||
const createUrl = vi.fn(() => 'blob:preview');
|
||||
(URL as any).createObjectURL = createUrl;
|
||||
mount(cfg, <HistoryModal open onClose={() => {}} />);
|
||||
await flush();
|
||||
await click(btn('Preview', row(0)));
|
||||
expect(fakeWin.close).toHaveBeenCalled();
|
||||
await click(btn('About', document.body.querySelector('.wsb-history-pages')!));
|
||||
expect(createUrl).toHaveBeenCalledTimes(1);
|
||||
const blob = (createUrl.mock.calls[0] as any)[0] as Blob;
|
||||
expect(await blob.text()).toContain('Snapshot about');
|
||||
expect(openSpy).toHaveBeenLastCalledWith('blob:preview', '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HistoryHost command registration', () => {
|
||||
test('registers site.history when the API answers, and the command opens the modal', async () => {
|
||||
mockApi();
|
||||
mount(cfg, <HistoryHost />);
|
||||
await flush();
|
||||
const cmd = getCommand('site.history');
|
||||
expect(cmd).toBeTruthy();
|
||||
expect(cmd!.group).toBe('site');
|
||||
expect(document.body.querySelector('.wsb-history-modal')).toBeNull();
|
||||
await h.act(async () => { openHistoryModal(); });
|
||||
await flush();
|
||||
expect(document.body.querySelector('.wsb-history-modal')).not.toBeNull();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['non-ok http', { listOk: false }],
|
||||
['success:false envelope', { list: { success: false, message: 'Unknown action' } }],
|
||||
['items missing', { list: { success: true } }],
|
||||
])('hides the command when the API is unavailable (%s)', async (_n, opts) => {
|
||||
mockApi(opts as any);
|
||||
mount(cfg, <HistoryHost />);
|
||||
await flush();
|
||||
expect(getCommand('site.history')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('network failure hides the command', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
||||
mount(cfg, <HistoryHost />);
|
||||
await flush();
|
||||
expect(getCommand('site.history')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('standalone mode: no command and no API call', async () => {
|
||||
const fetchMock = mockApi();
|
||||
mount(null, <HistoryHost />);
|
||||
await flush();
|
||||
expect(getCommand('site.history')).toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { Modal } from '../../ui/Modal';
|
||||
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||
import { usePages, EMPTY_CANVAS } from '../../state/PageContext';
|
||||
import { DEFAULT_SITE_DESIGN, useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { useProjectExtras } from '../../state/ProjectExtrasContext';
|
||||
import { useSaveGuard } from '../../state/SaveGuardContext';
|
||||
import { useNotices } from '../../state/NoticeContext';
|
||||
import { useRegisterCommands } from '../../editor/commands';
|
||||
import { useHistoryApi, type HistoryItem } from '../../hooks/useHistoryApi';
|
||||
import { repairOrphanNodes } from '../../utils/orphan-repair';
|
||||
import {
|
||||
buildSnapshotPreviewHtml,
|
||||
planPageRestore,
|
||||
snapshotDesign,
|
||||
snapshotExtras,
|
||||
snapshotToPages,
|
||||
validateSnapshot,
|
||||
type Snapshot,
|
||||
} from '../../utils/history-restore';
|
||||
|
||||
/** Module-level open channel (like the shortcuts cheat sheet) so a command can open the modal. */
|
||||
const openListeners = new Set<() => void>();
|
||||
export function openHistoryModal(): void {
|
||||
openListeners.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
const fmtExact = (iso: string) => new Date(iso).toLocaleString();
|
||||
const dayKey = (iso: string) => new Date(iso).toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
export function relativeTime(iso: string, now = Date.now()): string {
|
||||
const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
|
||||
if (s < 60) return 'just now';
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m} min ago`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 24) return `${h} h ago`;
|
||||
const d = Math.round(h / 24);
|
||||
return `${d} d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a snapshot into the editor as UNSAVED state. Nothing is sent to the
|
||||
* server: the user still clicks Save (which backs the current state up first).
|
||||
* Uses the same PageContext/extras/design setters as load(), but puts the
|
||||
* canvas deserialize first: it is the one step that can throw, and failing
|
||||
* before anything else changed leaves the editor untouched.
|
||||
*/
|
||||
export function useRestoreSnapshot() {
|
||||
const { actions } = useEditor();
|
||||
const { updateDesign } = useSiteDesign();
|
||||
const extras = useProjectExtras();
|
||||
const { markDirty } = useSaveGuard();
|
||||
const { notify } = useNotices();
|
||||
const pagesApi = usePages();
|
||||
const pagesRef = useRef(pagesApi);
|
||||
pagesRef.current = pagesApi;
|
||||
|
||||
const putOnCanvas = useCallback((craftState: string | null) => {
|
||||
const { state } = repairOrphanNodes(craftState || EMPTY_CANVAS);
|
||||
actions.deserialize(state);
|
||||
// Deserialize is an undoable step; Undo must not return to the pre-restore canvas.
|
||||
actions.history.clear();
|
||||
}, [actions]);
|
||||
|
||||
const restoreSite = useCallback((snapshot: Snapshot, at: string): boolean => {
|
||||
const valid = validateSnapshot(snapshot);
|
||||
if (!valid.ok) { notify({ kind: 'error', message: valid.reason }); return false; }
|
||||
const snapPages = snapshotToPages(snapshot);
|
||||
try {
|
||||
putOnCanvas(snapPages[0].craftState);
|
||||
} catch (e) {
|
||||
console.error('Restore failed:', e);
|
||||
notify({ kind: 'error', message: 'This version could not be loaded.' });
|
||||
return false;
|
||||
}
|
||||
const p = pagesRef.current;
|
||||
extras.replaceAll(snapshotExtras(snapshot));
|
||||
const design = snapshotDesign(snapshot);
|
||||
if (design) updateDesign({ ...DEFAULT_SITE_DESIGN, ...design });
|
||||
const asStr = (v: unknown) => (typeof v === 'string' ? v : v && typeof v === 'object' ? JSON.stringify(v) : null);
|
||||
const header = asStr(snapshot.header_craft_state);
|
||||
const footer = asStr(snapshot.footer_craft_state);
|
||||
if (header) p.setHeaderCraftState(header);
|
||||
if (footer) p.setFooterCraftState(footer);
|
||||
p.setPagesCraftState(snapPages);
|
||||
p.setActivePageIdDirect(snapPages[0].id);
|
||||
markDirty();
|
||||
notify({ kind: 'success', message: `Version from ${fmtExact(at)} loaded - click Save to keep it` });
|
||||
return true;
|
||||
}, [extras, updateDesign, markDirty, notify, putOnCanvas]);
|
||||
|
||||
const restorePage = useCallback((snapshot: Snapshot, pageIndex: number, at: string): boolean => {
|
||||
const valid = validateSnapshot(snapshot);
|
||||
if (!valid.ok) { notify({ kind: 'error', message: valid.reason }); return false; }
|
||||
const snapPage = snapshotToPages(snapshot)[pageIndex];
|
||||
if (!snapPage) { notify({ kind: 'error', message: 'That page is not in this version.' }); return false; }
|
||||
const p = pagesRef.current;
|
||||
const plan = planPageRestore(p.pages, snapPage);
|
||||
const onCanvas = plan.targetId === p.activePageId && !p.isEditingHeader && !p.isEditingFooter;
|
||||
if (onCanvas) {
|
||||
try {
|
||||
putOnCanvas(snapPage.craftState);
|
||||
} catch (e) {
|
||||
console.error('Restore failed:', e);
|
||||
notify({ kind: 'error', message: 'This page could not be loaded.' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
p.setPagesCraftState(plan.pages);
|
||||
markDirty();
|
||||
notify({ kind: 'success', message: `Page "${snapPage.name}" from ${fmtExact(at)} loaded - click Save to keep it` });
|
||||
return true;
|
||||
}, [markDirty, notify, putOnCanvas]);
|
||||
|
||||
return { restoreSite, restorePage };
|
||||
}
|
||||
|
||||
type Mode = 'preview' | 'restore-page' | 'restore-site';
|
||||
interface Pending { id: string; mode: Mode; snapshot: Snapshot; at: string }
|
||||
|
||||
export const HistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||
const { listHistory, getSnapshot } = useHistoryApi();
|
||||
const { restoreSite, restorePage } = useRestoreSnapshot();
|
||||
const { notify } = useNotices();
|
||||
const [items, setItems] = useState<HistoryItem[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
const cache = useRef(new Map<string, Snapshot>());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) { setPending(null); return; }
|
||||
let live = true;
|
||||
setLoading(true);
|
||||
listHistory().then((list) => { if (live) { setItems(list ?? []); setLoading(false); } });
|
||||
return () => { live = false; };
|
||||
}, [open, listHistory]);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const out: { day: string; rows: HistoryItem[] }[] = [];
|
||||
for (const it of items ?? []) {
|
||||
const d = dayKey(it.at);
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.day === d) last.rows.push(it); else out.push({ day: d, rows: [it] });
|
||||
}
|
||||
return out;
|
||||
}, [items]);
|
||||
|
||||
const openPreview = useCallback((snapshot: Snapshot, pageIndex: number, win: Window | null) => {
|
||||
try {
|
||||
const html = buildSnapshotPreviewHtml(snapshot, pageIndex, window.location.origin);
|
||||
const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
|
||||
if (win && !win.closed) win.location.href = url; else window.open(url, '_blank');
|
||||
} catch (e) {
|
||||
console.error('History preview failed:', e);
|
||||
win?.close();
|
||||
notify({ kind: 'error', message: 'This version could not be previewed.' });
|
||||
}
|
||||
}, [notify]);
|
||||
|
||||
const begin = useCallback(async (item: HistoryItem, mode: Mode) => {
|
||||
// Open the tab inside the click so the popup blocker allows it, then point it at the preview.
|
||||
const win = mode === 'preview' ? window.open('', '_blank') : null;
|
||||
setBusyId(item.id);
|
||||
let snap = cache.current.get(item.id) ?? null;
|
||||
if (!snap) {
|
||||
snap = await getSnapshot(item.id);
|
||||
if (snap) cache.current.set(item.id, snap);
|
||||
}
|
||||
setBusyId(null);
|
||||
if (!snap) {
|
||||
win?.close();
|
||||
notify({ kind: 'error', message: 'Could not load that version.' });
|
||||
return;
|
||||
}
|
||||
const valid = validateSnapshot(snap);
|
||||
if (!valid.ok) { win?.close(); notify({ kind: 'error', message: valid.reason }); return; }
|
||||
const pageCount = snapshotToPages(snap).length;
|
||||
if (mode === 'preview' && pageCount === 1) { openPreview(snap, 0, win); return; }
|
||||
win?.close();
|
||||
setPending({ id: item.id, mode, snapshot: snap, at: item.at });
|
||||
}, [getSnapshot, notify, openPreview]);
|
||||
|
||||
const pages = pending ? snapshotToPages(pending.snapshot) : [];
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} backdropProps={{ role: 'dialog', 'aria-modal': true, 'aria-label': 'Version history' }}>
|
||||
<div className="wsb-history-modal">
|
||||
<div className="wsb-history-header">
|
||||
<h2 className="wsb-history-title">Version history</h2>
|
||||
<button type="button" className="wsb-history-close" aria-label="Close" onClick={onClose}>
|
||||
<i className="fa fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="wsb-history-body">
|
||||
{loading && <p className="wsb-history-empty">Loading...</p>}
|
||||
{!loading && items && items.length === 0 && <p className="wsb-history-empty">No earlier versions yet. One is kept each time you save.</p>}
|
||||
{days.map((g) => (
|
||||
<section key={g.day} className="wsb-history-day">
|
||||
<h3 className="wsb-history-day-title">{g.day}</h3>
|
||||
<ul className="wsb-history-list">
|
||||
{g.rows.map((it) => (
|
||||
<li key={it.id} className="wsb-history-row" data-history-id={it.id}>
|
||||
<div className="wsb-history-row-main">
|
||||
<span className={`wsb-history-badge wsb-history-badge-${it.kind}`}>{it.kind === 'publish' ? 'Publish' : 'Save'}</span>
|
||||
<span className="wsb-history-time" title={fmtExact(it.at)}>{relativeTime(it.at)}</span>
|
||||
<span className="wsb-history-actions">
|
||||
<button type="button" disabled={busyId === it.id} onClick={() => begin(it, 'preview')}>Preview</button>
|
||||
<button type="button" disabled={busyId === it.id} onClick={() => begin(it, 'restore-page')}>Restore one page…</button>
|
||||
<button type="button" disabled={busyId === it.id} onClick={() => begin(it, 'restore-site')}>Restore site…</button>
|
||||
</span>
|
||||
</div>
|
||||
{pending && pending.id === it.id && (
|
||||
<div className="wsb-history-pending" data-mode={pending.mode}>
|
||||
{pending.mode === 'restore-site' ? (
|
||||
<>
|
||||
<p>Loads this version into the editor. Nothing is saved until you click Save.</p>
|
||||
<button type="button" className="wsb-history-primary" onClick={() => { if (restoreSite(pending.snapshot, pending.at)) onClose(); else setPending(null); }}>Load this version</button>
|
||||
<button type="button" onClick={() => setPending(null)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>{pending.mode === 'preview' ? 'Preview which page?' : 'Restore which page? Nothing is saved until you click Save.'}</p>
|
||||
<ul className="wsb-history-pages">
|
||||
{pages.map((p, i) => (
|
||||
<li key={`${p.id}-${i}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (pending.mode === 'preview') { openPreview(pending.snapshot, i, null); setPending(null); }
|
||||
else if (restorePage(pending.snapshot, i, pending.at)) onClose();
|
||||
else setPending(null);
|
||||
}}
|
||||
>{p.name}</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" onClick={() => setPending(null)}>Cancel</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Mounted once (CanvasOverlays). In WHP mode it probes the history API; only a
|
||||
* working backend registers the `site.history` command, so an old backend (or
|
||||
* standalone mode) never shows an entry that leads nowhere.
|
||||
*/
|
||||
export const HistoryHost: React.FC = () => {
|
||||
const { isWHP } = useEditorConfig();
|
||||
const { listHistory } = useHistoryApi();
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWHP) { setAvailable(false); return; }
|
||||
let live = true;
|
||||
listHistory().then((list) => { if (live) setAvailable(list !== null); });
|
||||
return () => { live = false; };
|
||||
}, [isWHP, listHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => setOpen(true);
|
||||
openListeners.add(cb);
|
||||
return () => { openListeners.delete(cb); };
|
||||
}, []);
|
||||
|
||||
useRegisterCommands(
|
||||
isWHP && available
|
||||
? [{ id: 'site.history', title: 'Version history…', group: 'site', keywords: ['restore', 'undo', 'previous', 'versions'], run: () => openHistoryModal() }]
|
||||
: [],
|
||||
[isWHP, available],
|
||||
);
|
||||
|
||||
if (!isWHP || !available) return null;
|
||||
return <HistoryModal open={open} onClose={() => setOpen(false)} />;
|
||||
};
|
||||
@@ -2731,3 +2731,65 @@ body {
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ==== #51 version history ==== */
|
||||
.wsb-history-modal {
|
||||
width: min(640px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.wsb-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.wsb-history-title { margin: 0; font-size: 15px; font-weight: 600; }
|
||||
.wsb-history-close { background: transparent; border: 0; color: var(--color-text-muted); cursor: pointer; padding: 4px 6px; }
|
||||
.wsb-history-close:hover { color: var(--color-text); }
|
||||
.wsb-history-body { padding: 8px 16px 16px; overflow-y: auto; }
|
||||
.wsb-history-empty { color: var(--color-text-muted); font-size: 13px; }
|
||||
.wsb-history-day-title {
|
||||
margin: 14px 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-dim);
|
||||
}
|
||||
.wsb-history-list, .wsb-history-pages { list-style: none; margin: 0; padding: 0; }
|
||||
.wsb-history-row { padding: 6px 0; border-bottom: 1px solid var(--color-border); font-size: 13px; }
|
||||
.wsb-history-row-main { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.wsb-history-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.wsb-history-badge-publish { color: var(--color-accent); border-color: var(--color-accent); }
|
||||
.wsb-history-time { color: var(--color-text); }
|
||||
.wsb-history-actions { margin-left: auto; display: inline-flex; gap: 6px; flex-wrap: wrap; }
|
||||
.wsb-history-modal button:not(.wsb-history-close) {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
padding: 3px 9px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wsb-history-modal button:disabled { opacity: 0.5; cursor: default; }
|
||||
.wsb-history-modal .wsb-history-primary { border-color: var(--color-accent); }
|
||||
.wsb-history-pending { margin-top: 8px; padding: 8px 10px; background: var(--color-bg-hover); border-radius: var(--radius-sm); }
|
||||
.wsb-history-pending p { margin: 0 0 8px; }
|
||||
.wsb-history-pending > button { margin-right: 6px; }
|
||||
.wsb-history-pages { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import {
|
||||
snapshotToPages, validateSnapshot, planPageRestore, buildSnapshotPreviewHtml, snapshotExtras, snapshotDesign,
|
||||
} from './history-restore';
|
||||
import type { PageData } from '../types';
|
||||
|
||||
const state = (text: string) => JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container', custom: {}, hidden: false, nodes: ['h'], linkedNodes: {}, parent: null },
|
||||
h: { type: { resolvedName: 'Heading' }, isCanvas: false, props: { text, level: 'h1' }, displayName: 'Heading', custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT' },
|
||||
});
|
||||
|
||||
const snap = (over: Record<string, unknown> = {}) => ({
|
||||
pages_craft_state: [
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: state('Old home') },
|
||||
{ id: 'p2', name: 'About', slug: 'about', craftState: state('Old about'), seo: { metaTitle: 'About us' } },
|
||||
],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('snapshotToPages', () => {
|
||||
test('maps rows and stringifies object craftState', () => {
|
||||
const pages = snapshotToPages(snap({ pages_craft_state: [{ id: 'a', name: 'A', slug: 'a', craftState: JSON.parse(state('x')) }] }));
|
||||
expect(pages).toHaveLength(1);
|
||||
expect(typeof pages[0].craftState).toBe('string');
|
||||
expect(pages[0].craftState).toContain('"ROOT"');
|
||||
});
|
||||
test.each([[undefined], [null], ['x'], [{}]])('non-array %p gives []', (v) => {
|
||||
expect(snapshotToPages({ pages_craft_state: v })).toEqual([]);
|
||||
});
|
||||
test('absent extras / design are tolerated', () => {
|
||||
expect(snapshotExtras(snap())).toEqual({});
|
||||
expect(snapshotDesign(snap())).toBeNull();
|
||||
expect(snapshotDesign(snap({ head_code: '<x>' }))).toEqual({ headCode: '<x>' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSnapshot', () => {
|
||||
test('accepts a healthy snapshot', () => {
|
||||
expect(validateSnapshot(snap()).ok).toBe(true);
|
||||
});
|
||||
test('rejects no pages', () => {
|
||||
expect(validateSnapshot({ pages_craft_state: [] }).ok).toBe(false);
|
||||
});
|
||||
test('rejects a page without ROOT', () => {
|
||||
const r = validateSnapshot(snap({ pages_craft_state: [{ id: 'a', name: 'A', slug: 'a', craftState: '{"x":1}' }] }));
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
test('rejects unparseable page and bad header', () => {
|
||||
expect(validateSnapshot(snap({ pages_craft_state: [{ id: 'a', name: 'A', slug: 'a', craftState: '{nope' }] })).ok).toBe(false);
|
||||
expect(validateSnapshot(snap({ header_craft_state: '{"a":1}' })).ok).toBe(false);
|
||||
});
|
||||
test('null page craftState is allowed (same as load)', () => {
|
||||
expect(validateSnapshot(snap({ pages_craft_state: [{ id: 'a', name: 'A', slug: 'a', craftState: null }] })).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planPageRestore', () => {
|
||||
const current: PageData[] = [
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: 'cur-home' },
|
||||
{ id: 'p2', name: 'About now', slug: 'about', craftState: 'cur-about' },
|
||||
];
|
||||
test('matches by id, keeps identity, replaces content and seo', () => {
|
||||
const plan = planPageRestore(current, { id: 'p2', name: 'About', slug: 'zzz', craftState: 'snap', seo: { metaTitle: 'T' } });
|
||||
expect(plan.mode).toBe('id');
|
||||
expect(plan.targetId).toBe('p2');
|
||||
expect(plan.pages[1]).toEqual({ id: 'p2', name: 'About now', slug: 'about', craftState: 'snap', seo: { metaTitle: 'T' } });
|
||||
expect(plan.pages[0]).toBe(current[0]);
|
||||
});
|
||||
test('falls back to slug when the id is gone', () => {
|
||||
const plan = planPageRestore(current, { id: 'old', name: 'About', slug: 'about', craftState: 'snap' });
|
||||
expect(plan.mode).toBe('slug');
|
||||
expect(plan.targetId).toBe('p2');
|
||||
expect(plan.pages).toHaveLength(2);
|
||||
expect(plan.pages[1].craftState).toBe('snap');
|
||||
});
|
||||
test('appends a page that no longer exists', () => {
|
||||
const plan = planPageRestore(current, { id: 'gone', name: 'Deleted', slug: 'deleted', craftState: 'snap' });
|
||||
expect(plan.mode).toBe('append');
|
||||
expect(plan.targetId).toBe('gone');
|
||||
expect(plan.pages.map((p) => p.id)).toEqual(['home', 'p2', 'gone']);
|
||||
expect(current).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSnapshotPreviewHtml', () => {
|
||||
test('contains the snapshot page body, header/footer, and the snapshot design primary', () => {
|
||||
const html = buildSnapshotPreviewHtml(
|
||||
snap({
|
||||
design: { primaryColor: '#123abc' },
|
||||
header_craft_state: state('Snapshot header'),
|
||||
footer_craft_state: state('Snapshot footer'),
|
||||
}),
|
||||
1,
|
||||
'https://panel.example',
|
||||
);
|
||||
expect(html).toContain('Old about');
|
||||
expect(html).not.toContain('Old home');
|
||||
expect(html).toContain('#123abc');
|
||||
expect(html).toContain('Snapshot header');
|
||||
expect(html).toContain('Snapshot footer');
|
||||
expect(html.indexOf('Snapshot header')).toBeLessThan(html.indexOf('Old about'));
|
||||
expect(html.indexOf('Old about')).toBeLessThan(html.indexOf('Snapshot footer'));
|
||||
expect(html).toContain('<title>About us</title>');
|
||||
});
|
||||
test('absolutises /api/ asset urls', () => {
|
||||
const withImg = JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container', custom: {}, hidden: false, nodes: ['i'], linkedNodes: {}, parent: null },
|
||||
i: { type: { resolvedName: 'ImageBlock' }, isCanvas: false, props: { src: '/api/assets-proxy/a.png', alt: 'a' }, displayName: 'ImageBlock', custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT' },
|
||||
});
|
||||
const html = buildSnapshotPreviewHtml({ pages_craft_state: [{ id: 'a', name: 'A', slug: 'index', craftState: withImg }] }, 0, 'https://panel.example');
|
||||
expect(html).toContain('src="https://panel.example/api/assets-proxy/a.png"');
|
||||
});
|
||||
test('throws for a missing page index', () => {
|
||||
expect(() => buildSnapshotPreviewHtml(snap(), 9, 'https://x')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { PageData, PageSeo } from '../types';
|
||||
import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext';
|
||||
import { exportBodyHtml, exportToHtml, buildAnimationScript } from './html-export';
|
||||
|
||||
/**
|
||||
* Pure helpers for version history (#51). A snapshot is the gunzipped previous
|
||||
* `project.json`; nothing here touches React or Craft, so restore and preview
|
||||
* are table-testable. Fields that older snapshots lack (`extras`, `design`,
|
||||
* header/footer state) are treated as absent.
|
||||
*/
|
||||
|
||||
export interface SnapshotPage {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
craftState: string | null;
|
||||
seo?: PageSeo;
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
pages_craft_state?: unknown;
|
||||
header_craft_state?: unknown;
|
||||
footer_craft_state?: unknown;
|
||||
design?: unknown;
|
||||
head_code?: unknown;
|
||||
extras?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const asState = (v: unknown): string | null => {
|
||||
if (typeof v === 'string') return v || null;
|
||||
if (v && typeof v === 'object') return JSON.stringify(v);
|
||||
return null;
|
||||
};
|
||||
|
||||
/** The snapshot's pages as editor PageData-shaped rows (craftState always a string or null). */
|
||||
export function snapshotToPages(snapshot: Snapshot): SnapshotPage[] {
|
||||
const raw = snapshot?.pages_craft_state;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((p) => p && typeof p === 'object')
|
||||
.map((p: any, i) => ({
|
||||
id: typeof p.id === 'string' && p.id ? p.id : `restored_${i}`,
|
||||
name: typeof p.name === 'string' ? p.name : `Page ${i + 1}`,
|
||||
slug: typeof p.slug === 'string' ? p.slug : `page-${i + 1}`,
|
||||
craftState: asState(p.craftState),
|
||||
seo: p.seo && typeof p.seo === 'object' ? (p.seo as PageSeo) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function snapshotExtras(snapshot: Snapshot): Record<string, unknown> {
|
||||
const e = snapshot?.extras;
|
||||
return e && typeof e === 'object' && !Array.isArray(e) ? (e as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export function snapshotDesign(snapshot: Snapshot): Partial<SiteDesign> | null {
|
||||
if (snapshot?.design && typeof snapshot.design === 'object') return snapshot.design as Partial<SiteDesign>;
|
||||
if (typeof snapshot?.head_code === 'string') return { headCode: snapshot.head_code };
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasRoot = (state: string): boolean => {
|
||||
try {
|
||||
const parsed = JSON.parse(state);
|
||||
return !!parsed && typeof parsed === 'object' && !!parsed.ROOT;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export type SnapshotValidation = { ok: true } | { ok: false; reason: string };
|
||||
|
||||
/** Same rule as load(): at least one page, and every stored page state has a ROOT. */
|
||||
export function validateSnapshot(snapshot: Snapshot): SnapshotValidation {
|
||||
if (!snapshot || typeof snapshot !== 'object') return { ok: false, reason: 'The version is empty.' };
|
||||
const pages = snapshotToPages(snapshot);
|
||||
if (pages.length === 0) return { ok: false, reason: 'The version has no pages.' };
|
||||
for (const p of pages) {
|
||||
if (p.craftState && !hasRoot(p.craftState)) return { ok: false, reason: `Page "${p.name}" in this version is unreadable.` };
|
||||
}
|
||||
for (const key of ['header_craft_state', 'footer_craft_state'] as const) {
|
||||
const s = asState(snapshot[key]);
|
||||
if (s && !hasRoot(s)) return { ok: false, reason: `The ${key === 'header_craft_state' ? 'header' : 'footer'} in this version is unreadable.` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export interface PageRestorePlan {
|
||||
pages: PageData[];
|
||||
/** Id of the page that now holds the snapshot content. */
|
||||
targetId: string;
|
||||
mode: 'id' | 'slug' | 'append';
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one page with its snapshot version: match by id, else slug, else
|
||||
* append as a new page. A matched page keeps its identity (id, name, slug) so
|
||||
* links and the landing-page rule stay put; only content and SEO come back.
|
||||
*/
|
||||
export function planPageRestore(currentPages: PageData[], snapshotPage: SnapshotPage): PageRestorePlan {
|
||||
let idx = currentPages.findIndex((p) => p.id === snapshotPage.id);
|
||||
let mode: PageRestorePlan['mode'] = 'id';
|
||||
if (idx === -1) {
|
||||
idx = currentPages.findIndex((p) => p.slug === snapshotPage.slug);
|
||||
mode = 'slug';
|
||||
}
|
||||
if (idx === -1) {
|
||||
const added: PageData = { id: snapshotPage.id, name: snapshotPage.name, slug: snapshotPage.slug, craftState: snapshotPage.craftState, seo: snapshotPage.seo };
|
||||
return { pages: [...currentPages, added], targetId: added.id, mode: 'append' };
|
||||
}
|
||||
const pages = currentPages.map((p, i) => (i === idx ? { ...p, craftState: snapshotPage.craftState, seo: snapshotPage.seo } : p));
|
||||
return { pages, targetId: currentPages[idx].id, mode };
|
||||
}
|
||||
|
||||
const zoneHtml = (state: unknown, zone: 'header' | 'footer'): string => {
|
||||
const s = asState(state);
|
||||
if (!s) return '';
|
||||
try {
|
||||
return exportBodyHtml(s, zone).html;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A full HTML document for one snapshot page, built through the same export
|
||||
* path as Save and Preview (header + body + footer, snapshot design and SEO),
|
||||
* with `/api/` asset URLs made absolute so a blob: tab can load them.
|
||||
*/
|
||||
export function buildSnapshotPreviewHtml(snapshot: Snapshot, pageIndex: number, apiOrigin: string): string {
|
||||
const pages = snapshotToPages(snapshot);
|
||||
const page = pages[pageIndex];
|
||||
if (!page) throw new Error('No such page in this version.');
|
||||
const state = page.craftState ?? '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
||||
|
||||
const bodyHtml = exportBodyHtml(state).html;
|
||||
const headerHtml = zoneHtml(snapshot.header_craft_state, 'header');
|
||||
const footerHtml = zoneHtml(snapshot.footer_craft_state, 'footer');
|
||||
const composed = headerHtml + bodyHtml + footerHtml;
|
||||
const composedBody = composed + buildAnimationScript(composed);
|
||||
|
||||
const design = snapshotDesign(snapshot);
|
||||
const fullDesign = { ...DEFAULT_SITE_DESIGN, ...(design || {}) } as SiteDesign;
|
||||
const seo = page.seo;
|
||||
let html = exportToHtml(state, {
|
||||
title: seo?.metaTitle || page.name || 'Preview',
|
||||
includeFonts: true,
|
||||
headCode: fullDesign.headCode,
|
||||
description: seo?.metaDescription,
|
||||
ogTitle: seo?.ogTitle,
|
||||
ogImage: seo?.ogImage,
|
||||
twitterCard: seo?.twitterCard,
|
||||
noindex: seo?.noindex,
|
||||
favicon: fullDesign.favicon,
|
||||
design: fullDesign,
|
||||
}).html;
|
||||
|
||||
// Function replacer: user content may contain `$&` / `$1`.
|
||||
const m = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
if (m) html = html.replace(m[1], () => composedBody);
|
||||
|
||||
html = html.replace(/src="\/api\//g, `src="${apiOrigin}/api/`);
|
||||
html = html.replace(/url\('\/api\//g, `url('${apiOrigin}/api/`);
|
||||
return html;
|
||||
}
|
||||
Reference in new issue
Block a user