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:
@@ -0,0 +1,97 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useEditorConfig } from '../state/EditorConfigContext';
|
||||
import { AssetData } from '../types';
|
||||
|
||||
export function useAssets() {
|
||||
const { whpConfig, isWHP } = useEditorConfig();
|
||||
const [assets, setAssets] = useState<AssetData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAssets = useCallback(async () => {
|
||||
if (!isWHP || !whpConfig) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${whpConfig.apiUrl}?action=list_assets&site_id=${whpConfig.siteId}`,
|
||||
{ headers: { 'X-CSRF-Token': whpConfig.csrfToken } }
|
||||
);
|
||||
if (!resp.ok) throw new Error(`Failed to load assets: ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
if (data.success && Array.isArray(data.assets)) {
|
||||
setAssets(data.assets.map((item: any) => ({
|
||||
name: item.name || '',
|
||||
url: item.url || '',
|
||||
type: item.type || 'file',
|
||||
size: item.size,
|
||||
modified: item.modified,
|
||||
})));
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to load assets';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isWHP, whpConfig]);
|
||||
|
||||
const uploadAsset = useCallback(async (file: File): Promise<string | null> => {
|
||||
if (!isWHP || !whpConfig) return null;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
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,
|
||||
}
|
||||
);
|
||||
if (!resp.ok) throw new Error(`Upload failed: ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
if (!data.success) throw new Error(data.error || 'Upload failed');
|
||||
|
||||
const newAsset: AssetData = {
|
||||
name: data.name || file.name,
|
||||
url: data.url || '',
|
||||
type: data.type || file.type || 'file',
|
||||
size: file.size,
|
||||
modified: Date.now(),
|
||||
};
|
||||
setAssets((prev) => [newAsset, ...prev]);
|
||||
return newAsset.url;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Upload failed';
|
||||
setError(msg);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isWHP, whpConfig]);
|
||||
|
||||
const deleteAsset = useCallback(async (filename: string) => {
|
||||
if (!isWHP || !whpConfig) return;
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${whpConfig.apiUrl}?action=delete_asset&site_id=${whpConfig.siteId}&filename=${encodeURIComponent(filename)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': whpConfig.csrfToken },
|
||||
}
|
||||
);
|
||||
if (!resp.ok) throw new Error(`Delete failed: ${resp.status}`);
|
||||
setAssets((prev) => prev.filter((a) => a.name !== filename));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Delete failed';
|
||||
setError(msg);
|
||||
}
|
||||
}, [isWHP, whpConfig]);
|
||||
|
||||
return { assets, loading, error, loadAssets, uploadAsset, deleteAsset };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string | null;
|
||||
}
|
||||
|
||||
export function useContextMenu() {
|
||||
const [menuState, setMenuState] = useState<ContextMenuState>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
nodeId: null,
|
||||
});
|
||||
|
||||
const show = useCallback((x: number, y: number, nodeId: string | null) => {
|
||||
setMenuState({ visible: true, x, y, nodeId });
|
||||
}, []);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
setMenuState((prev) => ({ ...prev, visible: false }));
|
||||
}, []);
|
||||
|
||||
return { menuState, show, hide };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
|
||||
function isInputFocused(): boolean {
|
||||
const el = document.activeElement;
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
|
||||
if ((el as HTMLElement).isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const { actions, query } = useEditor((state) => {
|
||||
const selectedIds = state.events.selected;
|
||||
const selectedId = selectedIds ? Array.from(selectedIds)[0] : null;
|
||||
return { selectedId };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Skip when typing in inputs
|
||||
if (isInputFocused()) return;
|
||||
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
|
||||
// Ctrl+Z: Undo
|
||||
if (ctrl && !e.shiftKey && e.key === 'z') {
|
||||
e.preventDefault();
|
||||
try {
|
||||
actions.history.undo();
|
||||
} catch (err) {
|
||||
// No more undo steps
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Y or Ctrl+Shift+Z: Redo
|
||||
if ((ctrl && e.key === 'y') || (ctrl && e.shiftKey && e.key === 'z') || (ctrl && e.shiftKey && e.key === 'Z')) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
actions.history.redo();
|
||||
} catch (err) {
|
||||
// No more redo steps
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete/Backspace: delete selected node
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const selected = query.getEvent('selected').all();
|
||||
if (selected.length > 0) {
|
||||
const nodeId = selected[0];
|
||||
if (nodeId !== 'ROOT') {
|
||||
actions.delete(nodeId);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+D: duplicate selected
|
||||
if (ctrl && (e.key === 'd' || e.key === 'D')) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const selected = query.getEvent('selected').all();
|
||||
if (selected.length > 0) {
|
||||
const nodeId = selected[0];
|
||||
if (nodeId !== 'ROOT') {
|
||||
const node = query.node(nodeId).get();
|
||||
const parentId = node?.data?.parent;
|
||||
if (parentId) {
|
||||
const tree = query.node(nodeId).toNodeTree();
|
||||
actions.addNodeTree(tree, parentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Duplicate failed:', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: deselect all
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
try {
|
||||
actions.clearEvents();
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [actions, query]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user