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:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 12:04:17 -07:00
1 parent 5430f13877
commit a9c8f4d65a
8 files changed
+947

No files matched your search

+2
View File
@@ -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 />
</>
);
+20
View File
@@ -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([]);
});
});
+64
View File
@@ -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();
});
});
+289
View File
@@ -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)} />;
};
+62
View File
@@ -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; }
+116
View File
@@ -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();
});
});
+165
View File
@@ -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;
}