feat(site-builder): block registry, notices, local prefs, project extras, overlay slot

Add an exported block registry (keywords, search, insertBlock) consumed by
BlocksPanel, a NoticeProvider/NoticeHost toast stack, a guarded localStorage
prefs helper, a ProjectExtras store persisted as project.json 'extras', and a
CanvasOverlays mount point.

Refs #38
Refs #41
Refs #49
Refs #48
Refs #57
Refs #58

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 11:14:51 -07:00
1 parent 97ccbee461
commit 21c2776c88
16 files changed
+869 -215

No files matched your search

+8
View File
@@ -7,6 +7,9 @@ import { WhpConfig } from './types';
import { EditorConfigProvider } from './state/EditorConfigContext';
import { SiteDesignProvider } from './state/SiteDesignContext';
import { SaveGuardProvider, useSaveGuard } from './state/SaveGuardContext';
import { NoticeProvider } from './state/NoticeContext';
import { ProjectExtrasProvider } from './state/ProjectExtrasContext';
import { CanvasOverlays } from './editor/CanvasOverlays';
import { PageProvider } from './state/PageContext';
import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext';
import { SitesmithModal } from './panels/sitesmith/SitesmithModal';
@@ -36,16 +39,21 @@ export const App: React.FC<AppProps> = ({ whpConfig }) => {
return (
<EditorConfigProvider config={whpConfig}>
<SaveGuardProvider>
<NoticeProvider>
<ProjectExtrasProvider>
<SiteDesignProvider>
<SaveAwareEditor>
<PageProvider>
<SitesmithProvider>
<EditorShell />
<CanvasOverlays />
<SitesmithModalMount />
</SitesmithProvider>
</PageProvider>
</SaveAwareEditor>
</SiteDesignProvider>
</ProjectExtrasProvider>
</NoticeProvider>
</SaveGuardProvider>
</EditorConfigProvider>
);
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import { NoticeHost } from './NoticeHost';
/**
* Single mount point for editor-wide overlays rendered next to the shell.
* Wave-1 overlays (drop indicator, empty-container add, spacing handles)
* mount here, one line each.
*/
export const CanvasOverlays: React.FC = () => (
<>
<NoticeHost />
</>
);
+77
View File
@@ -0,0 +1,77 @@
import { describe, test, expect, vi, afterEach, beforeEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { NoticeProvider, useNotices } from '../state/NoticeContext';
import { NoticeHost } from './NoticeHost';
let container: HTMLDivElement;
let root: Root;
let api: ReturnType<typeof useNotices>;
const Probe: React.FC = () => { api = useNotices(); return null; };
beforeEach(() => {
vi.useFakeTimers();
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(<NoticeProvider><Probe /><NoticeHost /></NoticeProvider>);
});
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
describe('NoticeHost', () => {
test('success renders role=status and disappears after timeoutMs', () => {
act(() => { api.notify({ kind: 'success', message: 'Saved', timeoutMs: 1000 }); });
const el = container.querySelector('[role="status"]');
expect(el?.textContent).toContain('Saved');
act(() => { vi.advanceTimersByTime(999); });
expect(container.textContent).toContain('Saved');
act(() => { vi.advanceTimersByTime(2); });
expect(container.textContent).not.toContain('Saved');
});
test('default timeout is 4000ms for info', () => {
act(() => { api.notify({ kind: 'info', message: 'FYI' }); });
act(() => { vi.advanceTimersByTime(3900); });
expect(container.textContent).toContain('FYI');
act(() => { vi.advanceTimersByTime(200); });
expect(container.textContent).not.toContain('FYI');
});
test('error is role=alert and sticky until dismissed', () => {
let id = '';
act(() => { id = api.notify({ kind: 'error', message: 'Boom' }); });
expect(container.querySelector('[role="alert"]')?.textContent).toContain('Boom');
act(() => { vi.advanceTimersByTime(60000); });
expect(container.textContent).toContain('Boom');
act(() => { (container.querySelector('.notice-dismiss') as HTMLButtonElement).click(); });
expect(container.textContent).not.toContain('Boom');
expect(id).toBeTruthy();
});
test('action button invokes onAction', () => {
const onAction = vi.fn();
act(() => { api.notify({ kind: 'info', message: 'Undo?', actionLabel: 'Undo', onAction, timeoutMs: 0 }); });
act(() => { (container.querySelector('.notice-action') as HTMLButtonElement).click(); });
expect(onAction).toHaveBeenCalledTimes(1);
});
test('without a provider notify is a console-only no-op', () => {
const spy = vi.spyOn(console, 'info').mockImplementation(() => {});
let r: Root; const c = document.createElement('div');
let a!: ReturnType<typeof useNotices>;
const P: React.FC = () => { a = useNotices(); return null; };
act(() => { r = createRoot(c); r.render(<P />); });
expect(() => a.notify({ kind: 'info', message: 'x' })).not.toThrow();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
act(() => r.unmount());
});
});
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import { useNotices } from '../state/NoticeContext';
/** Renders the active notice stack (bottom-left; above the bars on mobile). */
export const NoticeHost: React.FC = () => {
const { notices, dismiss } = useNotices();
if (notices.length === 0) return null;
return (
<div className="notice-host">
{notices.map((n) => (
<div
key={n.id}
className={`notice-item ${n.kind}`}
role={n.kind === 'error' ? 'alert' : 'status'}
>
<span className="notice-text">{n.message}</span>
{n.actionLabel && (
<button
type="button"
className="notice-action"
onClick={() => { n.onAction?.(); dismiss(n.id); }}
>
{n.actionLabel}
</button>
)}
<button type="button" className="notice-dismiss" aria-label="Dismiss" onClick={() => dismiss(n.id)}>
<i className="fa fa-times" aria-hidden="true" />
</button>
</div>
))}
</div>
);
};
+23 -6
View File
@@ -5,6 +5,7 @@ import { act } from 'react-dom/test-utils';
import { EditorConfigProvider } from '../state/EditorConfigContext';
import { PageProvider, usePages } from '../state/PageContext';
import { SiteDesignProvider } from '../state/SiteDesignContext';
import { ProjectExtrasProvider, useProjectExtras } from '../state/ProjectExtrasContext';
import { useWhpApi } from './useWhpApi';
import { WhpConfig } from '../types';
@@ -42,6 +43,7 @@ let root: Root;
interface Captured {
load: ReturnType<typeof useWhpApi>['load'];
pages: ReturnType<typeof usePages>['pages'];
extras: ReturnType<typeof useProjectExtras>;
}
function render(): { get: () => Captured } {
@@ -52,7 +54,7 @@ function render(): { get: () => Captured } {
const Consumer: React.FC = () => {
const { load } = useWhpApi();
const { pages } = usePages();
captured = { load, pages };
captured = { load, pages, extras: useProjectExtras() };
return null;
};
@@ -60,11 +62,13 @@ function render(): { get: () => Captured } {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<SiteDesignProvider>
<PageProvider>
<Consumer />
</PageProvider>
</SiteDesignProvider>
<ProjectExtrasProvider>
<SiteDesignProvider>
<PageProvider>
<Consumer />
</PageProvider>
</SiteDesignProvider>
</ProjectExtrasProvider>
</EditorConfigProvider>,
);
});
@@ -158,4 +162,17 @@ describe('useWhpApi load() restores PageData.seo (PKG-H §5)', () => {
unmount();
});
test('load() hydrates project.extras (and a project without extras yields {})', async () => {
const respond = (project: unknown) => vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, project }) });
vi.stubGlobal('fetch', respond({ pages_craft_state: [], extras: { stylePresets: [{ id: 'p1' }] } }));
const harness = render();
await act(async () => { await harness.get().load(); });
expect(harness.get().extras.get('stylePresets', [])).toEqual([{ id: 'p1' }]);
vi.stubGlobal('fetch', respond({ pages_craft_state: [] }));
await act(async () => { await harness.get().load(); });
expect(harness.get().extras.all()).toEqual({});
unmount();
});
});
+11
View File
@@ -206,4 +206,15 @@ describe('buildSavePayload', () => {
expect(payload.design.favicon).toBe('/uploads/favicon.png');
});
test('extras are passed through to the payload and default to {}', () => {
const base = {
siteId: 1, siteName: 'S', liveCraftState: 'L', pages: [pageA], headerPage, footerPage,
activePageId: 'home', isEditingHeader: false, isEditingFooter: false,
headCode: DEFAULT_SITE_DESIGN.headCode, design: DEFAULT_SITE_DESIGN,
};
expect(buildSavePayload({ ...base, extras: { stylePresets: [{ id: 'p' }] } }).extras)
.toEqual({ stylePresets: [{ id: 'p' }] });
expect(buildSavePayload(base).extras).toEqual({});
});
});
+13 -2
View File
@@ -7,6 +7,7 @@ import { exportBodyHtml } from '../utils/html-export';
import { repairOrphanNodes } from '../utils/orphan-repair';
import { PageData } from '../types';
import { useSaveGuard } from '../state/SaveGuardContext';
import { useProjectExtras } from '../state/ProjectExtrasContext';
/**
* Per-state export memo. A craft-state string fully determines its exported
@@ -39,6 +40,8 @@ export interface BuildSavePayloadInput {
headCode: string;
/** Full site design tokens object -- lets load() restore colors/fonts/headCode. */
design: SiteDesign;
/** Feature data that is not Craft state; stored as project.json `extras`. Defaults to {}. */
extras?: Record<string, unknown>;
}
/**
@@ -68,6 +71,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
isEditingFooter,
headCode,
design,
extras,
} = input;
const isPageActive = !isEditingHeader && !isEditingFooter;
@@ -230,6 +234,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
pages_craft_state: pagesGrapesjs,
head_code: headCode,
design,
extras: extras ?? {},
};
}
@@ -298,6 +303,7 @@ export function useWhpApi() {
setActivePageIdDirect,
} = usePages();
const { design, updateDesign } = useSiteDesign();
const projectExtras = useProjectExtras();
const {
loadState, forceEmptyNextSave, beginSave, endSave,
@@ -340,6 +346,7 @@ export function useWhpApi() {
isEditingFooter,
headCode: design.headCode,
design,
extras: projectExtras.all(),
});
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
@@ -387,7 +394,7 @@ export function useWhpApi() {
} finally {
endSave(ticket, succeeded);
}
}, [whpConfig, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query]);
}, [whpConfig, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, projectExtras]);
/**
* Save, one at a time. Every save (Save button, autosave, Keep mine, the
@@ -490,6 +497,10 @@ export function useWhpApi() {
if (data.project) {
const proj = data.project;
// Extras first: hydrating them never marks dirty, and they must be in
// place before pages deserialise so the first dirty baseline includes them.
projectExtras.replaceAll(proj.extras && typeof proj.extras === 'object' && !Array.isArray(proj.extras) ? proj.extras : {});
// Restore site design tokens (colors/fonts/headCode) so the editor
// reflects what was last saved. Prefer the full `design` object when
// present; fall back to just `head_code` for older project.json files
@@ -584,7 +595,7 @@ export function useWhpApi() {
}
}
return data;
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter, updateDesign, setRev]);
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter, updateDesign, setRev, projectExtras]);
const uploadAsset = useCallback(
async (file: File) => {
+12 -207
View File
@@ -2,149 +2,12 @@ import React, { useCallback, useState } from 'react';
import { useEditor } from '@craftjs/core';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useMobileChrome } from '../../state/MobileChromeContext';
import { Container } from '../../components/layout/Container';
import { Section } from '../../components/layout/Section';
import { ColumnLayout } from '../../components/layout/ColumnLayout';
import { BackgroundSection } from '../../components/layout/BackgroundSection';
import { Heading } from '../../components/basic/Heading';
import { TextBlock } from '../../components/basic/TextBlock';
import { ButtonLink } from '../../components/basic/ButtonLink';
import { Logo } from '../../components/basic/Logo';
import { Menu } from '../../components/basic/Menu';
import { Footer } from '../../components/basic/Footer';
import { Divider } from '../../components/basic/Divider';
import { Spacer } from '../../components/basic/Spacer';
import { Icon } from '../../components/basic/Icon';
import { HtmlBlock } from '../../components/basic/HtmlBlock';
import { ImageBlock } from '../../components/media/ImageBlock';
import { VideoBlock } from '../../components/media/VideoBlock';
import { MapEmbed } from '../../components/media/MapEmbed';
import { HeroSimple } from '../../components/sections/HeroSimple';
import { FeaturesGrid } from '../../components/sections/FeaturesGrid';
import { CallToAction } from '../../components/sections/CallToAction';
import { Countdown } from '../../components/sections/Countdown';
import { Testimonials } from '../../components/sections/Testimonials';
import { Accordion } from '../../components/sections/Accordion';
import { Tabs } from '../../components/sections/Tabs';
import { PricingTable } from '../../components/sections/PricingTable';
import { Gallery } from '../../components/sections/Gallery';
import { ContentSlider } from '../../components/sections/ContentSlider';
import { NumberCounter } from '../../components/sections/NumberCounter';
import { FormContainer } from '../../components/forms/FormContainer';
import { InputField } from '../../components/forms/InputField';
import { TextareaField } from '../../components/forms/TextareaField';
import { FormButton } from '../../components/forms/FormButton';
import { ContactForm } from '../../components/forms/ContactForm';
import { SubscribeForm } from '../../components/forms/SubscribeForm';
import { StarRating } from '../../components/basic/StarRating';
import { SocialLinks } from '../../components/basic/SocialLinks';
import { BLOCKS, BLOCK_CATEGORIES, BlockDef, insertBlock } from './blocks-registry';
interface BlockDef {
id: string;
label: string;
icon: string;
component: React.ReactElement;
}
interface CategoryDef {
id: string;
label: string;
blocks: BlockDef[];
}
const categories: CategoryDef[] = [
{
id: 'basic',
label: 'Basic',
blocks: [
{ id: 'heading', label: 'Heading', icon: 'fa-header',
component: <Heading text="Your Heading" level="h2" style={{ fontSize: '36px', fontWeight: '700', fontFamily: 'Inter, sans-serif', color: '#1f2937', marginBottom: '16px' }} /> },
{ id: 'text', label: 'Text', icon: 'fa-paragraph',
component: <TextBlock text="Add your text here. Click to edit." style={{ fontSize: '16px', lineHeight: '1.6', color: '#4b5563', fontFamily: 'Inter, sans-serif' }} /> },
{ id: 'button', label: 'Button', icon: 'fa-square',
component: <ButtonLink text="Click Me" href="#" style={{ display: 'inline-block', padding: '14px 32px', background: '#3b82f6', color: '#ffffff', textDecoration: 'none', borderRadius: '8px', fontWeight: '600', fontSize: '16px', fontFamily: 'Inter, sans-serif' }} /> },
{ id: 'logo', label: 'Logo', icon: 'fa-bookmark', component: <Logo /> },
{ id: 'menu', label: 'Menu', icon: 'fa-bars', component: <Menu /> },
{ id: 'footer', label: 'Footer', icon: 'fa-window-minimize', component: <Footer /> },
{ id: 'divider', label: 'Divider', icon: 'fa-minus', component: <Divider /> },
{ id: 'spacer', label: 'Spacer', icon: 'fa-arrows-v', component: <Spacer /> },
{ id: 'icon', label: 'Icon', icon: 'fa-star', component: <Icon icon="fa-star" size="48px" color="#3b82f6" /> },
{ id: 'star-rating', label: 'Star Rating', icon: 'fa-star-half-o', component: <StarRating /> },
{ id: 'social-links', label: 'Social Links', icon: 'fa-share-alt', component: <SocialLinks /> },
{ id: 'html-block', label: 'HTML', icon: 'fa-code', component: <HtmlBlock code="<div>Custom HTML</div>" /> },
],
},
{
id: 'layout',
label: 'Layout',
blocks: [
{ id: 'section', label: 'Section', icon: 'fa-window-maximize',
component: <Section style={{ padding: '60px 20px', backgroundColor: '#ffffff' }} /> },
{ id: 'container', label: 'Container', icon: 'fa-square-o',
component: <Container style={{ padding: '20px', minHeight: '100px' }} /> },
{ id: 'columns-1', label: '1 Column', icon: 'fa-stop',
component: <ColumnLayout columns={1} split="100" /> },
{ id: 'columns-2', label: '2 Columns', icon: 'fa-th-large',
component: <ColumnLayout columns={2} split="50-50" /> },
{ id: 'columns-3', label: '3 Columns', icon: 'fa-th',
component: <ColumnLayout columns={3} split="33-33-33" /> },
{ id: 'columns-4', label: '4 Columns', icon: 'fa-th',
component: <ColumnLayout columns={4} split="25-25-25-25" /> },
{ id: 'columns-5', label: '5 Columns', icon: 'fa-th',
component: <ColumnLayout columns={5} split="20-20-20-20-20" /> },
{ id: 'columns-6', label: '6 Columns', icon: 'fa-th',
component: <ColumnLayout columns={6} split="16-16-16-16-16-16" /> },
{ id: 'sidebar-left', label: 'Sidebar Left', icon: 'fa-columns',
component: <ColumnLayout columns={2} split="30-70" /> },
{ id: 'sidebar-right', label: 'Sidebar Right', icon: 'fa-columns',
component: <ColumnLayout columns={2} split="70-30" /> },
{ id: 'bg-section', label: 'BG Section', icon: 'fa-picture-o', component: <BackgroundSection /> },
],
},
{
id: 'sections',
label: 'Sections',
blocks: [
{ id: 'hero-simple', label: 'Hero', icon: 'fa-star', component: <HeroSimple /> },
{ id: 'features-grid', label: 'Features', icon: 'fa-th-large', component: <FeaturesGrid /> },
{ id: 'cta-section', label: 'CTA', icon: 'fa-bullhorn', component: <CallToAction /> },
{ id: 'accordion', label: 'Accordion', icon: 'fa-list', component: <Accordion /> },
{ id: 'tabs', label: 'Tabs', icon: 'fa-folder-o', component: <Tabs /> },
{ id: 'pricing-table', label: 'Pricing', icon: 'fa-usd', component: <PricingTable /> },
{ id: 'gallery', label: 'Gallery', icon: 'fa-th', component: <Gallery /> },
{ id: 'countdown', label: 'Countdown', icon: 'fa-clock-o',
component: <Countdown targetDate={new Date(Date.now() + 30 * 86400000).toISOString()} /> },
{ id: 'testimonials', label: 'Testimonials', icon: 'fa-quote-left',
component: <Testimonials testimonials={[{ quote: "Great service!", name: "John", title: "CEO", rating: 5 }]} layout="grid" columns={3} /> },
{ id: 'content-slider', label: 'Slider', icon: 'fa-sliders', component: <ContentSlider /> },
{ id: 'number-counter', label: 'Counters', icon: 'fa-sort-numeric-asc', component: <NumberCounter /> },
],
},
{
id: 'media',
label: 'Media',
blocks: [
{ id: 'image', label: 'Image', icon: 'fa-image',
component: <ImageBlock alt="Image" style={{ maxWidth: '100%', height: 'auto', display: 'block', borderRadius: '8px' }} /> },
{ id: 'video', label: 'Video', icon: 'fa-play-circle',
component: <VideoBlock videoUrl="" isBackground={false} /> },
{ id: 'map-embed', label: 'Map', icon: 'fa-map-marker',
component: <MapEmbed address="New York, NY" zoom={14} height="400px" /> },
],
},
{
id: 'forms',
label: 'Forms',
blocks: [
{ id: 'contact-form', label: 'Contact Form', icon: 'fa-envelope', component: <ContactForm /> },
{ id: 'subscribe-form', label: 'Subscribe', icon: 'fa-paper-plane', component: <SubscribeForm /> },
{ id: 'form-container', label: 'Form', icon: 'fa-wpforms', component: <FormContainer /> },
{ id: 'input-field', label: 'Input', icon: 'fa-i-cursor', component: <InputField /> },
{ id: 'textarea-field', label: 'Textarea', icon: 'fa-align-left', component: <TextareaField /> },
{ id: 'form-button', label: 'Submit', icon: 'fa-paper-plane', component: <FormButton /> },
],
},
];
// The business category is empty until its blocks land; hide empty groups.
const categories = BLOCK_CATEGORIES
.map((c) => ({ ...c, blocks: BLOCKS.filter((b) => b.category === c.id) }))
.filter((c) => c.blocks.length > 0);
export const BlocksPanel: React.FC = () => {
const { connectors, actions, query } = useEditor();
@@ -162,70 +25,12 @@ export const BlocksPanel: React.FC = () => {
setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] }));
};
/** Default insertion target when there's no usable selection to anchor
* to: the first real Craft.js canvas in the document (falls back to
* ROOT). Extracted from the pre-existing onDoubleClick handler so
* tap-to-add (mobile, item 3) and double-click (desktop, unchanged) share
* the exact same fallback. */
const findDefaultCanvasId = useCallback((): string => {
try {
const serialized = JSON.parse(query.serialize());
const nodeIds = Object.keys(serialized);
for (const id of nodeIds) {
if (serialized[id].isCanvas && id !== 'ROOT') return id;
}
} catch {
// Fall through to ROOT below.
}
return 'ROOT';
}, [query]);
/**
* Builds a fresh node tree for `block` and inserts it into the canvas,
* returning the new node's id (or null on failure). Shared by desktop's
* double-click (unchanged behavior/position: always appended to
* `findDefaultCanvasId()`) and mobile's tap-to-add (item 3), which instead
* prefers inserting as a sibling right after the current selection --
* `insertAfterSelection: true` only from the mobile tap handler below.
*/
const addBlockNode = useCallback((block: BlockDef, insertAfterSelection: boolean): string | null => {
try {
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
let inserted = false;
if (insertAfterSelection) {
try {
const selectedIds = query.getEvent('selected').all();
const selectedId = selectedIds.length > 0 ? selectedIds[0] : null;
if (selectedId && selectedId !== 'ROOT') {
const selectedNode = query.node(selectedId).get();
const parentId = selectedNode?.data?.parent;
if (parentId) {
const parent = query.node(parentId).get();
const siblings: string[] = parent?.data?.nodes || [];
const idx = siblings.indexOf(selectedId);
if (idx !== -1) {
actions.addNodeTree(tree, parentId, idx + 1);
inserted = true;
}
}
}
} catch {
// Selection isn't a valid sibling target (e.g. lives in a
// linkedNodes slot) -- fall through to the default canvas below.
}
}
if (!inserted) {
actions.addNodeTree(tree, findDefaultCanvasId());
}
return tree.rootNodeId;
} catch (e) {
console.error('Failed to add block:', e);
return null;
}
}, [query, actions, findDefaultCanvasId]);
/** Shared by desktop double-click (append to the default canvas, no
* selection change) and mobile tap-to-add (insert after the selection;
* the select/scroll step is deferred below). Returns the new node id. */
const addBlockNode = useCallback((block: BlockDef, insertAfterSelection: boolean): string | null =>
insertBlock(query, actions, block.id, { afterSelection: insertAfterSelection, select: false }),
[query, actions]);
/**
* Mobile tap-to-add (item 3): a single tap on a block tile inserts it,
@@ -276,7 +81,7 @@ export const BlocksPanel: React.FC = () => {
<div
key={block.id}
className="block-item"
ref={(ref) => { if (ref) connectors.create(ref, block.component); }}
ref={(ref) => { if (ref) connectors.create(ref, block.create()); }}
onDoubleClick={() => addBlockNode(block, false)}
onClick={isMobile ? () => handleTapToAdd(block) : undefined}
title={isMobile ? `Tap to add ${block.label}` : `Drag or double-click to add ${block.label}`}
@@ -0,0 +1,95 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
import { BLOCKS, BLOCK_CATEGORIES, getBlock, searchBlocks, insertBlock } from './blocks-registry';
import { BlocksPanel } from './BlocksPanel';
let h: EditorHarness | null = null;
afterEach(() => { h?.unmount(); h = null; });
const rootChildren = (hh: EditorHarness): string[] => JSON.parse(hh.getSerialized()).ROOT.nodes;
const typeOf = (hh: EditorHarness, id: string): string => JSON.parse(hh.getSerialized())[id].type.resolvedName;
describe('blocks registry', () => {
test('43 blocks with the original category split', () => {
expect(BLOCKS).toHaveLength(43);
const count = (c: string) => BLOCKS.filter((b) => b.category === c).length;
expect([count('basic'), count('layout'), count('sections'), count('media'), count('forms'), count('business')]).toEqual([12, 11, 11, 3, 6, 0]);
expect(BLOCK_CATEGORIES.map((c) => c.id)).toContain('business');
expect(new Set(BLOCKS.map((b) => b.id)).size).toBe(43);
for (const b of BLOCKS) expect(b.keywords.length).toBeGreaterThanOrEqual(3);
});
test('create() returns a new element every call', () => {
const b = getBlock('heading')!;
expect(b.create()).not.toBe(b.create());
expect(b.create().props.style).not.toBe(b.create().props.style);
});
test('searchBlocks: empty returns all, pric ranks Pricing first, matches keywords and category', () => {
expect(searchBlocks('')).toHaveLength(43);
expect(searchBlocks('pric')[0].id).toBe('pricing-table');
expect(searchBlocks('FAQ').map((b) => b.id)).toEqual(['accordion']);
expect(searchBlocks('newsletter').map((b) => b.id)).toContain('subscribe-form');
expect(searchBlocks('forms').length).toBe(6 + searchBlocks('forms').filter((b) => b.category !== 'forms').length);
// label-prefix outranks label-contains (Contact Form precedes Form in registry order)
expect(searchBlocks('form')[0].id).toBe('form-container');
expect(searchBlocks('zzzz-nothing')).toEqual([]);
});
test('every block parses to a node tree', () => {
h = renderEditorHarness();
for (const b of BLOCKS) {
expect(() => h!.query.parseReactElement(b.create()).toNodeTree(), b.id).not.toThrow();
}
});
test('insertBlock adds a Heading under the first canvas and selects it', () => {
h = renderEditorHarness();
let id: string | null = null;
h.act(() => { id = insertBlock(h!.query, h!.actions, 'heading'); });
expect(id).toBeTruthy();
expect(rootChildren(h)).toContain(id);
expect(typeOf(h, id!)).toBe('Heading');
expect(h.query.getEvent('selected').all()).toEqual([id]);
});
test('select:false does not select; unknown id returns null', () => {
h = renderEditorHarness();
let id: string | null = null;
h.act(() => { id = insertBlock(h!.query, h!.actions, 'text', { select: false }); });
expect(h.query.getEvent('selected').all()).toEqual([]);
expect(insertBlock(h.query, h.actions, 'nope')).toBeNull();
});
test('afterSelection inserts as the next sibling of the selection', () => {
h = renderEditorHarness();
let a: string | null = null, b: string | null = null, c: string | null = null;
h.act(() => {
a = insertBlock(h!.query, h!.actions, 'heading');
b = insertBlock(h!.query, h!.actions, 'text');
});
h.act(() => { h!.actions.selectNode(a); });
h.act(() => { c = insertBlock(h!.query, h!.actions, 'button', { afterSelection: true }); });
expect(rootChildren(h)).toEqual([a, c, b]);
});
});
describe('BlocksPanel smoke', () => {
test('renders every category with the original counts', () => {
h = renderEditorHarness();
h.mountChild(<BlocksPanel />);
const cats = Array.from(h.container.querySelectorAll('.block-category'));
const summary = cats.map((c) => [c.querySelector('.block-category-header span')!.textContent, c.querySelectorAll('.block-item').length]);
expect(summary).toEqual([['Basic', 12], ['Layout', 11], ['Sections', 11], ['Media', 3], ['Forms', 6]]);
});
test('double-click appends to the canvas without selecting', () => {
h = renderEditorHarness();
h.mountChild(<BlocksPanel />);
const item = Array.from(h.container.querySelectorAll('.block-item')).find((e) => e.textContent === 'Heading')!;
h.act(() => { item.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); });
expect(rootChildren(h)).toHaveLength(1);
expect(h.query.getEvent('selected').all()).toEqual([]);
});
});
+244
View File
@@ -0,0 +1,244 @@
import React from 'react';
import { Container } from '../../components/layout/Container';
import { Section } from '../../components/layout/Section';
import { ColumnLayout } from '../../components/layout/ColumnLayout';
import { BackgroundSection } from '../../components/layout/BackgroundSection';
import { Heading } from '../../components/basic/Heading';
import { TextBlock } from '../../components/basic/TextBlock';
import { ButtonLink } from '../../components/basic/ButtonLink';
import { Logo } from '../../components/basic/Logo';
import { Menu } from '../../components/basic/Menu';
import { Footer } from '../../components/basic/Footer';
import { Divider } from '../../components/basic/Divider';
import { Spacer } from '../../components/basic/Spacer';
import { Icon } from '../../components/basic/Icon';
import { HtmlBlock } from '../../components/basic/HtmlBlock';
import { ImageBlock } from '../../components/media/ImageBlock';
import { VideoBlock } from '../../components/media/VideoBlock';
import { MapEmbed } from '../../components/media/MapEmbed';
import { HeroSimple } from '../../components/sections/HeroSimple';
import { FeaturesGrid } from '../../components/sections/FeaturesGrid';
import { CallToAction } from '../../components/sections/CallToAction';
import { Countdown } from '../../components/sections/Countdown';
import { Testimonials } from '../../components/sections/Testimonials';
import { Accordion } from '../../components/sections/Accordion';
import { Tabs } from '../../components/sections/Tabs';
import { PricingTable } from '../../components/sections/PricingTable';
import { Gallery } from '../../components/sections/Gallery';
import { ContentSlider } from '../../components/sections/ContentSlider';
import { NumberCounter } from '../../components/sections/NumberCounter';
import { FormContainer } from '../../components/forms/FormContainer';
import { InputField } from '../../components/forms/InputField';
import { TextareaField } from '../../components/forms/TextareaField';
import { FormButton } from '../../components/forms/FormButton';
import { ContactForm } from '../../components/forms/ContactForm';
import { SubscribeForm } from '../../components/forms/SubscribeForm';
import { StarRating } from '../../components/basic/StarRating';
import { SocialLinks } from '../../components/basic/SocialLinks';
export type BlockCategoryId = 'basic' | 'layout' | 'sections' | 'media' | 'forms' | 'business';
export interface BlockDef {
id: string;
label: string;
icon: string;
category: BlockCategoryId;
/** Extra search terms (label and category label are searched too). */
keywords: string[];
/** Builds a FRESH element on every call, so each insert gets its own props objects. */
create(): React.ReactElement;
}
export const BLOCK_CATEGORIES: ReadonlyArray<{ id: BlockCategoryId; label: string }> = [
{ id: 'basic', label: 'Basic' },
{ id: 'layout', label: 'Layout' },
{ id: 'sections', label: 'Sections' },
{ id: 'media', label: 'Media' },
{ id: 'forms', label: 'Forms' },
{ id: 'business', label: 'Business' },
];
export const BLOCKS: ReadonlyArray<BlockDef> = [
{ id: 'heading', label: 'Heading', icon: 'fa-header', category: 'basic', keywords: ['title', 'headline', 'h1', 'text'],
create: () => <Heading text="Your Heading" level="h2" style={{ fontSize: '36px', fontWeight: '700', fontFamily: 'Inter, sans-serif', color: '#1f2937', marginBottom: '16px' }} /> },
{ id: 'text', label: 'Text', icon: 'fa-paragraph', category: 'basic', keywords: ['paragraph', 'copy', 'body', 'content'],
create: () => <TextBlock text="Add your text here. Click to edit." style={{ fontSize: '16px', lineHeight: '1.6', color: '#4b5563', fontFamily: 'Inter, sans-serif' }} /> },
{ id: 'button', label: 'Button', icon: 'fa-square', category: 'basic', keywords: ['link', 'cta', 'action', 'click'],
create: () => <ButtonLink text="Click Me" href="#" style={{ display: 'inline-block', padding: '14px 32px', background: '#3b82f6', color: '#ffffff', textDecoration: 'none', borderRadius: '8px', fontWeight: '600', fontSize: '16px', fontFamily: 'Inter, sans-serif' }} /> },
{ id: 'logo', label: 'Logo', icon: 'fa-bookmark', category: 'basic', keywords: ['brand', 'image', 'site'],
create: () => <Logo /> },
{ id: 'menu', label: 'Menu', icon: 'fa-bars', category: 'basic', keywords: ['nav', 'navigation', 'links', 'header'],
create: () => <Menu /> },
{ id: 'footer', label: 'Footer', icon: 'fa-window-minimize', category: 'basic', keywords: ['bottom', 'copyright', 'links'],
create: () => <Footer /> },
{ id: 'divider', label: 'Divider', icon: 'fa-minus', category: 'basic', keywords: ['line', 'separator', 'rule', 'hr'],
create: () => <Divider /> },
{ id: 'spacer', label: 'Spacer', icon: 'fa-arrows-v', category: 'basic', keywords: ['space', 'gap', 'padding', 'blank'],
create: () => <Spacer /> },
{ id: 'icon', label: 'Icon', icon: 'fa-star', category: 'basic', keywords: ['symbol', 'glyph', 'star', 'font awesome'],
create: () => <Icon icon="fa-star" size="48px" color="#3b82f6" /> },
{ id: 'star-rating', label: 'Star Rating', icon: 'fa-star-half-o', category: 'basic', keywords: ['stars', 'review', 'rate', 'score'],
create: () => <StarRating /> },
{ id: 'social-links', label: 'Social Links', icon: 'fa-share-alt', category: 'basic', keywords: ['social', 'facebook', 'twitter', 'follow', 'share'],
create: () => <SocialLinks /> },
{ id: 'html-block', label: 'HTML', icon: 'fa-code', category: 'basic', keywords: ['code', 'custom', 'embed', 'snippet'],
create: () => <HtmlBlock code="<div>Custom HTML</div>" /> },
{ id: 'section', label: 'Section', icon: 'fa-window-maximize', category: 'layout', keywords: ['band', 'wrapper', 'full width', 'row'],
create: () => <Section style={{ padding: '60px 20px', backgroundColor: '#ffffff' }} /> },
{ id: 'container', label: 'Container', icon: 'fa-square-o', category: 'layout', keywords: ['box', 'group', 'wrapper', 'div'],
create: () => <Container style={{ padding: '20px', minHeight: '100px' }} /> },
{ id: 'columns-1', label: '1 Column', icon: 'fa-stop', category: 'layout', keywords: ['column', 'grid', 'layout', 'row'],
create: () => <ColumnLayout columns={1} split="100" /> },
{ id: 'columns-2', label: '2 Columns', icon: 'fa-th-large', category: 'layout', keywords: ['column', 'grid', 'layout', 'row', 'split'],
create: () => <ColumnLayout columns={2} split="50-50" /> },
{ id: 'columns-3', label: '3 Columns', icon: 'fa-th', category: 'layout', keywords: ['column', 'grid', 'layout', 'row'],
create: () => <ColumnLayout columns={3} split="33-33-33" /> },
{ id: 'columns-4', label: '4 Columns', icon: 'fa-th', category: 'layout', keywords: ['column', 'grid', 'layout', 'row'],
create: () => <ColumnLayout columns={4} split="25-25-25-25" /> },
{ id: 'columns-5', label: '5 Columns', icon: 'fa-th', category: 'layout', keywords: ['column', 'grid', 'layout', 'row'],
create: () => <ColumnLayout columns={5} split="20-20-20-20-20" /> },
{ id: 'columns-6', label: '6 Columns', icon: 'fa-th', category: 'layout', keywords: ['column', 'grid', 'layout', 'row'],
create: () => <ColumnLayout columns={6} split="16-16-16-16-16-16" /> },
{ id: 'sidebar-left', label: 'Sidebar Left', icon: 'fa-columns', category: 'layout', keywords: ['column', 'aside', 'layout', 'two column'],
create: () => <ColumnLayout columns={2} split="30-70" /> },
{ id: 'sidebar-right', label: 'Sidebar Right', icon: 'fa-columns', category: 'layout', keywords: ['column', 'aside', 'layout', 'two column'],
create: () => <ColumnLayout columns={2} split="70-30" /> },
{ id: 'bg-section', label: 'BG Section', icon: 'fa-picture-o', category: 'layout', keywords: ['background', 'image', 'cover', 'parallax'],
create: () => <BackgroundSection /> },
{ id: 'hero-simple', label: 'Hero', icon: 'fa-star', category: 'sections', keywords: ['banner', 'header', 'intro', 'headline', 'landing'],
create: () => <HeroSimple /> },
{ id: 'features-grid', label: 'Features', icon: 'fa-th-large', category: 'sections', keywords: ['benefits', 'services', 'grid', 'icons'],
create: () => <FeaturesGrid /> },
{ id: 'cta-section', label: 'CTA', icon: 'fa-bullhorn', category: 'sections', keywords: ['call to action', 'banner', 'signup', 'promo'],
create: () => <CallToAction /> },
{ id: 'accordion', label: 'Accordion', icon: 'fa-list', category: 'sections', keywords: ['faq', 'collapse', 'toggle', 'questions'],
create: () => <Accordion /> },
{ id: 'tabs', label: 'Tabs', icon: 'fa-folder-o', category: 'sections', keywords: ['tabbed', 'panels', 'switch'],
create: () => <Tabs /> },
{ id: 'pricing-table', label: 'Pricing', icon: 'fa-usd', category: 'sections', keywords: ['price', 'plans', 'table', 'tiers'],
create: () => <PricingTable /> },
{ id: 'gallery', label: 'Gallery', icon: 'fa-th', category: 'sections', keywords: ['photos', 'images', 'grid', 'lightbox'],
create: () => <Gallery /> },
{ id: 'countdown', label: 'Countdown', icon: 'fa-clock-o', category: 'sections', keywords: ['timer', 'clock', 'launch', 'deadline'],
create: () => <Countdown targetDate={new Date(Date.now() + 30 * 86400000).toISOString()} /> },
{ id: 'testimonials', label: 'Testimonials', icon: 'fa-quote-left', category: 'sections', keywords: ['reviews', 'quotes', 'customers', 'social proof'],
create: () => <Testimonials testimonials={[{ quote: "Great service!", name: "John", title: "CEO", rating: 5 }]} layout="grid" columns={3} /> },
{ id: 'content-slider', label: 'Slider', icon: 'fa-sliders', category: 'sections', keywords: ['carousel', 'slideshow', 'slides'],
create: () => <ContentSlider /> },
{ id: 'number-counter', label: 'Counters', icon: 'fa-sort-numeric-asc', category: 'sections', keywords: ['stats', 'numbers', 'metrics', 'count'],
create: () => <NumberCounter /> },
{ id: 'image', label: 'Image', icon: 'fa-image', category: 'media', keywords: ['photo', 'picture', 'img', 'media'],
create: () => <ImageBlock alt="Image" style={{ maxWidth: '100%', height: 'auto', display: 'block', borderRadius: '8px' }} /> },
{ id: 'video', label: 'Video', icon: 'fa-play-circle', category: 'media', keywords: ['youtube', 'vimeo', 'movie', 'embed'],
create: () => <VideoBlock videoUrl="" isBackground={false} /> },
{ id: 'map-embed', label: 'Map', icon: 'fa-map-marker', category: 'media', keywords: ['location', 'google maps', 'address', 'directions'],
create: () => <MapEmbed address="New York, NY" zoom={14} height="400px" /> },
{ id: 'contact-form', label: 'Contact Form', icon: 'fa-envelope', category: 'forms', keywords: ['email', 'message', 'enquiry', 'contact us'],
create: () => <ContactForm /> },
{ id: 'subscribe-form', label: 'Subscribe', icon: 'fa-paper-plane', category: 'forms', keywords: ['newsletter', 'email', 'signup', 'mailing list'],
create: () => <SubscribeForm /> },
{ id: 'form-container', label: 'Form', icon: 'fa-wpforms', category: 'forms', keywords: ['form', 'fields', 'submit'],
create: () => <FormContainer /> },
{ id: 'input-field', label: 'Input', icon: 'fa-i-cursor', category: 'forms', keywords: ['text field', 'input', 'field', 'form'],
create: () => <InputField /> },
{ id: 'textarea-field', label: 'Textarea', icon: 'fa-align-left', category: 'forms', keywords: ['message', 'multiline', 'field', 'form'],
create: () => <TextareaField /> },
{ id: 'form-button', label: 'Submit', icon: 'fa-paper-plane', category: 'forms', keywords: ['submit', 'send', 'form', 'button'],
create: () => <FormButton /> },
];
export function getBlock(id: string): BlockDef | undefined {
return BLOCKS.find((b) => b.id === id);
}
/**
* Case-insensitive match over label, keywords and category label. An empty
* query returns everything; otherwise results are ranked label-prefix, then
* label-contains, then keyword/category matches, keeping registry order within
* each rank (stable).
*/
export function searchBlocks(query: string): BlockDef[] {
const q = query.trim().toLowerCase();
if (!q) return [...BLOCKS];
const catLabel = (id: BlockCategoryId) => BLOCK_CATEGORIES.find((c) => c.id === id)?.label.toLowerCase() ?? '';
const ranked: Array<{ block: BlockDef; rank: number; order: number }> = [];
BLOCKS.forEach((block, order) => {
const label = block.label.toLowerCase();
let rank = -1;
if (label.startsWith(q)) rank = 0;
else if (label.includes(q)) rank = 1;
else if (block.keywords.some((k) => k.toLowerCase().includes(q))) rank = 2;
else if (catLabel(block.category).includes(q)) rank = 3;
if (rank >= 0) ranked.push({ block, rank, order });
});
ranked.sort((a, b) => a.rank - b.rank || a.order - b.order);
return ranked.map((r) => r.block);
}
/** Default insertion target when there is no usable selection to anchor to:
* the first real Craft.js canvas in the document (falls back to ROOT). */
export function findDefaultCanvasId(query: any): string {
try {
const serialized = JSON.parse(query.serialize());
for (const id of Object.keys(serialized)) {
if (serialized[id].isCanvas && id !== 'ROOT') return id;
}
} catch {
// Fall through to ROOT below.
}
return 'ROOT';
}
export interface InsertBlockOptions {
/** Insert as the next sibling of the current selection (falls back to the default canvas). */
afterSelection?: boolean;
/** Insert into this parent (appended). Ignored when afterSelection finds a target. */
parentId?: string;
/** Select the new node (default true). */
select?: boolean;
}
/** Builds a fresh node tree for `blockId` and inserts it. Returns the new root node id, or null on failure. */
export function insertBlock(query: any, actions: any, blockId: string, opts: InsertBlockOptions = {}): string | null {
const block = getBlock(blockId);
if (!block) return null;
try {
const tree = query.parseReactElement(block.create()).toNodeTree();
let inserted = false;
if (opts.afterSelection) {
try {
const selectedIds: string[] = query.getEvent('selected').all();
const selectedId = selectedIds.length > 0 ? selectedIds[0] : null;
if (selectedId && selectedId !== 'ROOT') {
const parentId = query.node(selectedId).get()?.data?.parent;
if (parentId) {
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const idx = siblings.indexOf(selectedId);
if (idx !== -1) {
actions.addNodeTree(tree, parentId, idx + 1);
inserted = true;
}
}
}
} catch {
// Selection isn't a valid sibling target (e.g. a linkedNodes slot):
// fall through to the default canvas below.
}
}
if (!inserted) actions.addNodeTree(tree, opts.parentId ?? findDefaultCanvasId(query));
if (opts.select !== false) {
try {
actions.selectNode(tree.rootNodeId);
} catch {
// Nothing to select if the node failed to register.
}
}
return tree.rootNodeId;
} catch (e) {
console.error('Failed to add block:', e);
return null;
}
}
+72
View File
@@ -0,0 +1,72 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
export interface Notice {
id?: string;
kind: 'info' | 'success' | 'error';
message: string;
actionLabel?: string;
onAction?(): void;
/** Auto-dismiss delay. Default 4000; errors default to sticky (0). 0 = sticky. */
timeoutMs?: number;
}
export type ActiveNotice = Notice & { id: string };
interface NoticeContextValue {
notify(n: Notice): string;
dismiss(id: string): void;
notices: ActiveNotice[];
}
// No provider (standalone embeds, isolated unit tests): console-only no-op.
const defaultValue: NoticeContextValue = {
notify: (n) => {
if (n.kind === 'error') console.error('[notice]', n.message);
else console.info('[notice]', n.message);
return n.id ?? '';
},
dismiss: () => {},
notices: [],
};
const NoticeContext = createContext<NoticeContextValue>(defaultValue);
export const useNotices = () => useContext(NoticeContext);
export function noticeTimeout(n: Notice): number {
return n.timeoutMs ?? (n.kind === 'error' ? 0 : 4000);
}
export const NoticeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [notices, setNotices] = useState<ActiveNotice[]>([]);
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
const seq = useRef(0);
const dismiss = useCallback((id: string) => {
const t = timers.current.get(id);
if (t) { clearTimeout(t); timers.current.delete(id); }
setNotices((prev) => prev.filter((n) => n.id !== id));
}, []);
const notify = useCallback((n: Notice): string => {
const id = n.id ?? `notice-${++seq.current}`;
const existing = timers.current.get(id);
if (existing) { clearTimeout(existing); timers.current.delete(id); }
// A repeated id replaces the earlier notice in place.
setNotices((prev) => {
const next: ActiveNotice = { ...n, id };
return prev.some((p) => p.id === id) ? prev.map((p) => (p.id === id ? next : p)) : [...prev, next];
});
const ms = noticeTimeout(n);
if (ms > 0) timers.current.set(id, setTimeout(() => dismiss(id), ms));
return id;
}, [dismiss]);
useEffect(() => {
const map = timers.current;
return () => { map.forEach((t) => clearTimeout(t)); map.clear(); };
}, []);
const value = useMemo(() => ({ notify, dismiss, notices }), [notify, dismiss, notices]);
return <NoticeContext.Provider value={value}>{children}</NoticeContext.Provider>;
};
@@ -0,0 +1,124 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { EditorConfigProvider } from './EditorConfigContext';
import { SaveGuardProvider, useSaveGuard } from './SaveGuardContext';
import { ProjectExtrasProvider, useProjectExtras } from './ProjectExtrasContext';
import { PageProvider } from './PageContext';
import { SiteDesignProvider } from './SiteDesignContext';
import { useWhpApi } from '../hooks/useWhpApi';
import { WhpConfig } from '../types';
vi.mock('@craftjs/core', () => ({
useEditor: () => ({
query: { serialize: () => '{}' },
actions: { deserialize: () => {}, history: { clear: () => {} } },
}),
}));
const whpConfig: WhpConfig = {
user: 'u', apiUrl: '/api/sb', csrfToken: 'tok', siteId: 7, siteDomain: 'e.com',
siteName: 'Site', backUrl: '/', isRoot: false,
};
interface Cap {
extras: ReturnType<typeof useProjectExtras>;
guard: ReturnType<typeof useSaveGuard>;
api: ReturnType<typeof useWhpApi>;
}
let root: Root;
let container: HTMLDivElement;
let cap: Cap;
function mount() {
container = document.createElement('div');
document.body.appendChild(container);
const Consumer: React.FC = () => {
cap = { extras: useProjectExtras(), guard: useSaveGuard(), api: useWhpApi() };
return null;
};
act(() => {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<SaveGuardProvider>
<ProjectExtrasProvider>
<SiteDesignProvider>
<PageProvider><Consumer /></PageProvider>
</SiteDesignProvider>
</ProjectExtrasProvider>
</SaveGuardProvider>
</EditorConfigProvider>,
);
});
}
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
const loadResponse = (project: unknown) => ({
ok: true, status: 200,
json: async () => ({ success: true, rev: 'r1', project }),
});
async function loadWith(project: unknown) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(loadResponse(project)));
mount();
await act(async () => { await cap.api.load(); });
act(() => cap.guard.setLoadState('loaded'));
}
describe('ProjectExtrasProvider', () => {
test('load hydrates extras and leaves the editor clean; a later set dirties it', async () => {
await loadWith({ extras: { stylePresets: [{ id: 'p1' }] }, pages_craft_state: [] });
expect(cap.extras.get('stylePresets', [])).toEqual([{ id: 'p1' }]);
expect(cap.guard.isDirty()).toBe(false);
act(() => cap.extras.set('stylePresets', [{ id: 'p1' }, { id: 'p2' }]));
expect(cap.guard.isDirty()).toBe(true);
});
test('replaceAll after load does not mark dirty; update applies fn and marks dirty', async () => {
await loadWith(null);
act(() => cap.extras.replaceAll({ a: 1 }));
expect(cap.guard.isDirty()).toBe(false);
expect(cap.extras.all()).toEqual({ a: 1 });
act(() => cap.extras.update<number>('a', (n) => n + 1, 0));
expect(cap.extras.get('a', 0)).toBe(2);
expect(cap.guard.isDirty()).toBe(true);
});
test('missing, null or non-object extras in the project hydrate to {}', async () => {
await loadWith({ extras: ['x'], pages_craft_state: [] });
expect(cap.extras.all()).toEqual({});
});
test('the save POST body carries extras set after load', async () => {
await loadWith({ pages_craft_state: [] });
act(() => cap.extras.set('stylePresets', [{ id: 'p1' }]));
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ success: true, rev: 'r2' }) });
vi.stubGlobal('fetch', fetchMock);
await act(async () => { await cap.api.save(); });
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.extras).toEqual({ stylePresets: [{ id: 'p1' }] });
});
test('without a provider the hook is an inert default', () => {
let e!: ReturnType<typeof useProjectExtras>;
const P: React.FC = () => { e = useProjectExtras(); return null; };
const c = document.createElement('div');
let r: Root;
act(() => { r = createRoot(c); r.render(<P />); });
e.set('k', 1);
expect(e.get('k', 'fb')).toBe('fb');
act(() => r.unmount());
// satisfy afterEach teardown
mount();
});
});
+53
View File
@@ -0,0 +1,53 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, ReactNode } from 'react';
import { useSaveGuard } from './SaveGuardContext';
/**
* Feature data that is not Craft state (style presets, custom sections, ...),
* persisted as the top-level `extras` key of project.json. Held in a ref, not
* React state: nothing renders from it directly, and a save queued behind
* another must read the latest value at send time.
*/
interface ProjectExtrasValue {
get<T>(key: string, fallback: T): T;
set(key: string, value: unknown): void;
update<T>(key: string, fn: (prev: T) => T, fallback: T): void;
all(): Record<string, unknown>;
/** Load hydration. Does NOT mark the editor dirty. */
replaceAll(extras: Record<string, unknown>): void;
}
const noop: ProjectExtrasValue = {
get: (_k, fallback) => fallback,
set: () => {},
update: () => {},
all: () => ({}),
replaceAll: () => {},
};
const ProjectExtrasContext = createContext<ProjectExtrasValue>(noop);
export const useProjectExtras = () => useContext(ProjectExtrasContext);
export const ProjectExtrasProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { markDirty } = useSaveGuard();
const store = useRef<Record<string, unknown>>({});
const markRef = useRef(markDirty);
markRef.current = markDirty;
const value = useMemo<ProjectExtrasValue>(() => ({
get: <T,>(key: string, fallback: T): T => (key in store.current ? (store.current[key] as T) : fallback),
set: (key, v) => {
store.current = { ...store.current, [key]: v };
markRef.current();
},
update: <T,>(key: string, fn: (prev: T) => T, fallback: T) => {
const prev = (key in store.current ? store.current[key] : fallback) as T;
store.current = { ...store.current, [key]: fn(prev) };
markRef.current();
},
all: () => store.current,
replaceAll: (extras) => { store.current = { ...extras }; },
}), []);
return <ProjectExtrasContext.Provider value={value}>{children}</ProjectExtrasContext.Provider>;
};
+42
View File
@@ -2054,3 +2054,45 @@ body {
padding: 4px 12px;
}
.conflict-modal-actions button:disabled { opacity: 0.5; cursor: default; }
/* Notice stack (state/NoticeContext + editor/NoticeHost): bottom-left, so it
never collides with TopBar's bottom-right ErrorNotice. */
.notice-host {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 60;
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(420px, calc(100vw - 32px));
}
.notice-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
font-size: 12px;
color: var(--color-text);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.notice-item.success { border-left-color: var(--color-success); }
.notice-item.error { border-left-color: var(--color-danger); }
.notice-text { flex: 1; word-break: break-word; }
.notice-action, .notice-dismiss {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0 4px;
}
.notice-action { font-weight: 600; text-decoration: underline; }
@media (max-width: 768px) {
.notice-host {
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px) + 12px);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, test, expect, afterEach, vi } from 'vitest';
import { getPref, setPref } from './local-prefs';
afterEach(() => { vi.restoreAllMocks(); window.localStorage.clear(); });
describe('local-prefs', () => {
test('round-trips JSON under the wsb: namespace', () => {
setPref('tab', { a: [1, 2] });
expect(getPref('tab', null)).toEqual({ a: [1, 2] });
expect(window.localStorage.getItem('wsb:tab')).toBe('{"a":[1,2]}');
});
test('missing key and corrupt JSON return the fallback', () => {
expect(getPref('nope', 'fb')).toBe('fb');
window.localStorage.setItem('wsb:bad', '{not json');
expect(getPref('bad', 7)).toBe(7);
});
test('a throwing localStorage returns the fallback and setPref does not throw', () => {
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => { throw new Error('blocked'); });
expect(getPref('x', 'fb')).toBe('fb');
expect(() => setPref('x', 1)).not.toThrow();
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Per-browser UI preferences (collapse state, remembered tabs). Namespaced
* `wsb:`, JSON-encoded. Storage can be missing, blocked or throw (private
* mode, sandboxed iframe), so every access is guarded: reads fall back,
* writes are silently dropped. Never use this for state that must persist.
*/
const NS = 'wsb:';
export function getPref<T>(key: string, fallback: T): T {
try {
const raw = window.localStorage.getItem(NS + key);
if (raw === null) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export function setPref(key: string, value: unknown): void {
try {
window.localStorage.setItem(NS + key, JSON.stringify(value));
} catch {
// Storage unavailable or full: the preference simply isn't remembered.
}
}