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:
300
craft/src/state/PageContext.tsx
Normal file
300
craft/src/state/PageContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user