diff --git a/craft/src/editor/CanvasOverlays.tsx b/craft/src/editor/CanvasOverlays.tsx index f7e1a50..1282de9 100644 --- a/craft/src/editor/CanvasOverlays.tsx +++ b/craft/src/editor/CanvasOverlays.tsx @@ -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 = () => ( + ); diff --git a/craft/src/hooks/useHistoryApi.test.ts b/craft/src/hooks/useHistoryApi.test.ts new file mode 100644 index 0000000..2b365c3 --- /dev/null +++ b/craft/src/hooks/useHistoryApi.test.ts @@ -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([]); + }); +}); diff --git a/craft/src/hooks/useHistoryApi.ts b/craft/src/hooks/useHistoryApi.ts new file mode 100644 index 0000000..284a898 --- /dev/null +++ b/craft/src/hooks/useHistoryApi.ts @@ -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; + 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 => { + 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 => { + 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 => { + 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]); +} diff --git a/craft/src/panels/history/HistoryModal.test.tsx b/craft/src/panels/history/HistoryModal.test.tsx new file mode 100644 index 0000000..34b9111 --- /dev/null +++ b/craft/src/panels/history/HistoryModal.test.tsx @@ -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; design: ReturnType; guard: ReturnType; extras: ReturnType }; +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( + + + + + + + + {ui} + + + + + + , + ); +} + +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, {}} />); + 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, ); + 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, {}} />); + 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, {}} />); + 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, {}} />); + 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, {}} />); + 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, ); + 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, ); + 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, ); + await flush(); + expect(getCommand('site.history')).toBeUndefined(); + }); + + test('standalone mode: no command and no API call', async () => { + const fetchMock = mockApi(); + mount(null, ); + await flush(); + expect(getCommand('site.history')).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + diff --git a/craft/src/panels/history/HistoryModal.tsx b/craft/src/panels/history/HistoryModal.tsx new file mode 100644 index 0000000..dc70cfa --- /dev/null +++ b/craft/src/panels/history/HistoryModal.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [busyId, setBusyId] = useState(null); + const [pending, setPending] = useState(null); + const cache = useRef(new Map()); + + 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 ( + +
+
+

Version history

+