Add Craft.js site builder (v2) - complete rebuild from GrapesJS

Rebuilt the visual site builder from scratch using Craft.js, React 18,
and TypeScript. The new editor renders directly in the DOM (no iframe),
supports 40+ components, multi-page with shared header/footer, 16
templates, full-spectrum color/gradient controls, custom head code
injection, save/publish workflow, and auto-save.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 18:31:16 -07:00
parent b511a6684d
commit 91a6b6f34b
103 changed files with 26296 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { WhpConfig } from '../types';
interface EditorConfigContextValue {
whpConfig: WhpConfig | null;
isWHP: boolean;
}
const EditorConfigContext = createContext<EditorConfigContextValue>({
whpConfig: null,
isWHP: false,
});
export const useEditorConfig = () => useContext(EditorConfigContext);
export const EditorConfigProvider: React.FC<{ config: WhpConfig | null; children: ReactNode }> = ({ config, children }) => {
return (
<EditorConfigContext.Provider value={{ whpConfig: config, isWHP: !!config }}>
{children}
</EditorConfigContext.Provider>
);
};

View File

@@ -0,0 +1,300 @@
import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
import { useEditor } from '@craftjs/core';
import { PageData } from '../types';
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
interface PageContextValue {
pages: PageData[];
headerPage: PageData;
footerPage: PageData;
activePageId: string;
isEditingHeader: boolean;
isEditingFooter: boolean;
switchPage: (pageId: string) => void;
editHeader: () => void;
editFooter: () => void;
addPage: (name: string, slug: string) => void;
deletePage: (pageId: string) => void;
renamePage: (pageId: string, name: string, slug: string) => void;
setHeaderCraftState: (craftState: string) => void;
setFooterCraftState: (craftState: string) => void;
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void;
siteDesign: SiteDesign;
}
const HEADER_ID = '__header__';
const FOOTER_ID = '__footer__';
const EMPTY_CANVAS =
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
const EMPTY_HEADER =
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"60px","backgroundColor":"#ffffff","padding":"12px 24px","display":"flex","alignItems":"center"},"tag":"header"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
const EMPTY_FOOTER =
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"60px","backgroundColor":"#0f172a","color":"#94a3b8","padding":"40px 24px","textAlign":"center"},"tag":"footer"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
const PageContext = createContext<PageContextValue>({
pages: [],
headerPage: { id: HEADER_ID, name: 'Header', slug: '__header__', craftState: null, headCode: '' },
footerPage: { id: FOOTER_ID, name: 'Footer', slug: '__footer__', craftState: null, headCode: '' },
activePageId: 'home',
isEditingHeader: false,
isEditingFooter: false,
switchPage: () => {},
editHeader: () => {},
editFooter: () => {},
addPage: () => {},
deletePage: () => {},
renamePage: () => {},
setHeaderCraftState: () => {},
setFooterCraftState: () => {},
setPagesCraftState: () => {},
siteDesign: {} as SiteDesign,
});
export const usePages = () => useContext(PageContext);
function slugify(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
const DEFAULT_PAGE: PageData = {
id: 'home',
name: 'Home',
slug: 'index',
craftState: null,
headCode: '',
};
const DEFAULT_HEADER: PageData = {
id: HEADER_ID,
name: 'Header',
slug: '__header__',
craftState: null,
headCode: '',
};
const DEFAULT_FOOTER: PageData = {
id: FOOTER_ID,
name: 'Footer',
slug: '__footer__',
craftState: null,
headCode: '',
};
export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { query, actions } = useEditor();
const { design } = useSiteDesign();
const [pages, setPages] = useState<PageData[]>([DEFAULT_PAGE]);
const [headerPage, setHeaderPage] = useState<PageData>(DEFAULT_HEADER);
const [footerPage, setFooterPage] = useState<PageData>(DEFAULT_FOOTER);
const [activePageId, setActivePageId] = useState('home');
const activePageIdRef = useRef(activePageId);
activePageIdRef.current = activePageId;
const isEditingHeader = activePageId === HEADER_ID;
const isEditingFooter = activePageId === FOOTER_ID;
/** Save whatever is on the current Frame back to the right state slot */
const saveCurrentState = useCallback(() => {
const currentState = query.serialize();
const currentId = activePageIdRef.current;
if (currentId === HEADER_ID) {
setHeaderPage((prev) => ({ ...prev, craftState: currentState }));
} else if (currentId === FOOTER_ID) {
setFooterPage((prev) => ({ ...prev, craftState: currentState }));
} else {
setPages((prev) =>
prev.map((p) => (p.id === currentId ? { ...p, craftState: currentState } : p)),
);
}
}, [query]);
/** Load a craft state into the Frame */
const loadState = useCallback(
(craftState: string | null, fallback: string) => {
setTimeout(() => {
try {
actions.deserialize(craftState || fallback);
} catch (e) {
console.error('Failed to deserialize state:', e);
try {
actions.deserialize(fallback);
} catch (_e2) {
// give up
}
}
}, 0);
},
[actions],
);
const switchPage = useCallback(
(pageId: string) => {
if (pageId === activePageIdRef.current) return;
// Serialize the current Craft.js state synchronously BEFORE switching
const currentState = query.serialize();
const currentId = activePageIdRef.current;
// Persist the serialized state to the correct page slot
if (currentId === HEADER_ID) {
setHeaderPage((prev) => ({ ...prev, craftState: currentState }));
} else if (currentId === FOOTER_ID) {
setFooterPage((prev) => ({ ...prev, craftState: currentState }));
} else {
setPages((prev) =>
prev.map((p) => (p.id === currentId ? { ...p, craftState: currentState } : p)),
);
}
// Load target page state.
// For header/footer we need the latest saved state. Since setState above is async,
// read from the ref-like state getter. For header/footer, we read the current
// state value and fall back to what we just saved if the target is the same slot.
if (pageId === HEADER_ID) {
// Use functional state read to get the latest value
setHeaderPage((prev) => {
loadState(prev.craftState, EMPTY_HEADER);
return prev;
});
} else if (pageId === FOOTER_ID) {
setFooterPage((prev) => {
loadState(prev.craftState, EMPTY_FOOTER);
return prev;
});
} else {
setPages((prev) => {
const target = prev.find((p) => p.id === pageId);
loadState(target?.craftState || null, EMPTY_CANVAS);
return prev;
});
}
setActivePageId(pageId);
activePageIdRef.current = pageId;
},
[query, loadState],
);
const editHeader = useCallback(() => {
switchPage(HEADER_ID);
}, [switchPage]);
const editFooter = useCallback(() => {
switchPage(FOOTER_ID);
}, [switchPage]);
const addPage = useCallback(
(name: string, slug: string) => {
const finalSlug = slug || slugify(name);
const id = `page_${Date.now()}`;
// Save current page first
saveCurrentState();
setPages((prev) => [
...prev,
{
id,
name,
slug: finalSlug,
craftState: null,
headCode: '',
},
]);
// Switch to the new page with empty canvas
loadState(null, EMPTY_CANVAS);
setActivePageId(id);
activePageIdRef.current = id;
},
[saveCurrentState, loadState],
);
const deletePage = useCallback(
(pageId: string) => {
// Can't delete header/footer
if (pageId === HEADER_ID || pageId === FOOTER_ID) return;
setPages((prev) => {
if (prev.length <= 1) return prev;
const filtered = prev.filter((p) => p.id !== pageId);
// If deleting the active page, switch to the first remaining
if (pageId === activePageIdRef.current) {
const nextPage = filtered[0];
setActivePageId(nextPage.id);
activePageIdRef.current = nextPage.id;
loadState(nextPage.craftState, EMPTY_CANVAS);
}
return filtered;
});
},
[loadState],
);
const renamePage = useCallback((pageId: string, name: string, slug: string) => {
setPages((prev) =>
prev.map((p) =>
p.id === pageId ? { ...p, name, slug: slug || slugify(name) } : p,
),
);
}, []);
/** Allow external code (e.g., load from API) to set the header craft state */
const setHeaderCraftState = useCallback((craftState: string) => {
setHeaderPage((prev) => ({ ...prev, craftState }));
}, []);
/** Allow external code (e.g., load from API) to set the footer craft state */
const setFooterCraftState = useCallback((craftState: string) => {
setFooterPage((prev) => ({ ...prev, craftState }));
}, []);
/** Allow external code (e.g., load from API) to restore pages with craft states */
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => {
setPages(pagesData.map((p) => ({
id: p.id,
name: p.name,
slug: p.slug,
craftState: p.craftState,
headCode: '',
})));
}, []);
return (
<PageContext.Provider
value={{
pages,
headerPage,
footerPage,
activePageId,
isEditingHeader,
isEditingFooter,
switchPage,
editHeader,
editFooter,
addPage,
deletePage,
renamePage,
setHeaderCraftState,
setFooterCraftState,
setPagesCraftState,
siteDesign: design,
}}
>
{children}
</PageContext.Provider>
);
};

View File

@@ -0,0 +1,81 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
export interface SiteDesign {
// Basic
primaryColor: string;
secondaryColor: string;
accentColor: string;
headingFont: string;
bodyFont: string;
linkColor: string;
// Advanced
successColor: string;
warningColor: string;
errorColor: string;
backgroundColor: string;
textColor: string;
mutedTextColor: string;
borderColor: string;
borderRadius: string;
buttonFont: string;
buttonRadius: string;
navStyle: 'light' | 'dark';
// Site-wide custom code
headCode: string;
}
export interface SiteDesignContextValue {
design: SiteDesign;
updateDesign: (updates: Partial<SiteDesign>) => void;
resetToDefaults: () => void;
}
export const DEFAULT_SITE_DESIGN: SiteDesign = {
primaryColor: '#3b82f6',
secondaryColor: '#8b5cf6',
accentColor: '#10b981',
headingFont: 'Inter, sans-serif',
bodyFont: 'Inter, sans-serif',
linkColor: '#3b82f6',
successColor: '#10b981',
warningColor: '#f59e0b',
errorColor: '#ef4444',
backgroundColor: '#ffffff',
textColor: '#1f2937',
mutedTextColor: '#6b7280',
borderColor: '#e5e7eb',
borderRadius: '8px',
buttonFont: 'Inter, sans-serif',
buttonRadius: '8px',
navStyle: 'light',
headCode: '',
};
const SiteDesignContext = createContext<SiteDesignContextValue>({
design: DEFAULT_SITE_DESIGN,
updateDesign: () => {},
resetToDefaults: () => {},
});
export const useSiteDesign = () => useContext(SiteDesignContext);
export const SiteDesignProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [design, setDesign] = useState<SiteDesign>(DEFAULT_SITE_DESIGN);
const updateDesign = useCallback((updates: Partial<SiteDesign>) => {
setDesign((prev) => ({ ...prev, ...updates }));
}, []);
const resetToDefaults = useCallback(() => {
setDesign(DEFAULT_SITE_DESIGN);
}, []);
return (
<SiteDesignContext.Provider value={{ design, updateDesign, resetToDefaults }}>
{children}
</SiteDesignContext.Provider>
);
};