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
co-authored by Claude Opus 4.6
parent b511a6684d
commit 91a6b6f34b
103 changed files with 26296 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
import { useCallback } from 'react';
import { useEditor } from '@craftjs/core';
import { useEditorConfig } from '../state/EditorConfigContext';
import { usePages } from '../state/PageContext';
import { exportBodyHtml } from '../utils/html-export';
export function useWhpApi() {
const { query, actions } = useEditor();
const { whpConfig, isWHP } = useEditorConfig();
const { pages, headerPage, footerPage, activePageId, setHeaderCraftState, setFooterCraftState, setPagesCraftState } = usePages();
const save = useCallback(async () => {
if (!isWHP || !whpConfig) return null;
// Serialize the current canvas state (whatever page is active)
const currentCraftState = query.serialize();
// Export body HTML for the current page
let currentHtml = '';
let css = '';
try {
const result = exportBodyHtml(currentCraftState);
currentHtml = result.html;
css = result.css;
} catch (e) {
console.error('HTML export failed, saving state only:', e);
}
// Export header HTML from its craft state
let headerHtml = '';
try {
if (headerPage.craftState) {
const hResult = exportBodyHtml(headerPage.craftState);
headerHtml = hResult.html;
}
} catch (e) {
console.error('Header HTML export failed:', e);
}
// Export footer HTML from its craft state
let footerHtml = '';
try {
if (footerPage.craftState) {
const fResult = exportBodyHtml(footerPage.craftState);
footerHtml = fResult.html;
}
} catch (e) {
console.error('Footer HTML export failed:', e);
}
// Build the pages array with HTML for each page
// For the active page, use the freshly exported HTML from the canvas;
// for others, export from their stored craft state
const pagesPayload = pages.map((page) => {
const filename = (page.slug === 'index' ? 'index' : page.slug) + '.html';
let pageHtml = '';
if (page.id === activePageId) {
// Active page: use the current canvas HTML (already exported above)
pageHtml = currentHtml;
} else if (page.craftState) {
try {
const pResult = exportBodyHtml(page.craftState);
pageHtml = pResult.html;
} catch (e) {
console.error(`HTML export failed for page ${page.name}:`, e);
}
}
return {
filename,
title: page.name,
html: pageHtml,
};
});
// Build pages_craft_state array: for each page, store its craft state
// For the currently active page, always use the fresh canvas state (currentCraftState)
// since page.craftState may be stale (not updated until page switch)
const pagesGrapesjs = pages.map((page) => ({
id: page.id,
name: page.name,
slug: page.slug,
craftState: page.id === activePageId ? currentCraftState : (page.craftState || null),
}));
const payload = {
site_id: whpConfig.siteId,
name: whpConfig.siteName,
html: currentHtml,
css,
pages: pagesPayload,
header_html: headerHtml,
footer_html: footerHtml,
craft_state: currentCraftState,
header_craft_state: headerPage.craftState || null,
footer_craft_state: footerPage.craftState || null,
pages_craft_state: pagesGrapesjs,
};
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify(payload),
});
return resp.json();
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage]);
const publish = useCallback(async () => {
if (!isWHP || !whpConfig) return null;
// First save to ensure staging is up to date
await save();
// Then publish from staging to live
const resp = await fetch(
`${whpConfig.apiUrl}?action=publish&site_id=${whpConfig.siteId}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify({ site_id: whpConfig.siteId }),
},
);
return resp.json();
}, [isWHP, whpConfig, save]);
const load = useCallback(async () => {
if (!isWHP || !whpConfig) return null;
const resp = await fetch(
`${whpConfig.apiUrl}?action=load&site_id=${whpConfig.siteId}`,
);
const data = await resp.json();
if (data.success && data.project) {
const proj = data.project;
// Restore header craft state
if (proj.header_craft_state) {
setHeaderCraftState(typeof proj.header_craft_state === 'string'
? proj.header_craft_state : JSON.stringify(proj.header_craft_state));
}
// Restore footer craft state
if (proj.footer_craft_state) {
setFooterCraftState(typeof proj.footer_craft_state === 'string'
? proj.footer_craft_state : JSON.stringify(proj.footer_craft_state));
}
// Restore pages and load the first page into the canvas
if (proj.pages_craft_state && Array.isArray(proj.pages_craft_state) && proj.pages_craft_state.length > 0) {
setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null }) => ({
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null,
})));
// Load the first page (home) into the canvas
const firstPage = proj.pages_craft_state[0];
if (firstPage.craftState) {
try {
const state = typeof firstPage.craftState === 'string'
? firstPage.craftState : JSON.stringify(firstPage.craftState);
actions.deserialize(state);
} catch (e) {
console.warn('Failed to load page state:', e);
}
}
}
}
return data;
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState]);
const uploadAsset = useCallback(
async (file: File) => {
if (!isWHP || !whpConfig) return null;
const formData = new FormData();
formData.append('file', file);
const resp = await fetch(
`${whpConfig.apiUrl}?action=upload_asset&site_id=${whpConfig.siteId}`,
{
method: 'POST',
headers: { 'X-CSRF-Token': whpConfig.csrfToken },
body: formData,
},
);
return resp.json();
},
[isWHP, whpConfig],
);
return { save, publish, load, uploadAsset, isWHP };
}