2026-04-05 18:31:16 -07:00
|
|
|
import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
|
|
|
|
|
import { useEditor } from '@craftjs/core';
|
|
|
|
|
import { PageData } from '../types';
|
2026-05-23 14:20:51 -07:00
|
|
|
import { SerializedTreeNode } from '../types/sitesmith';
|
2026-04-05 18:31:16 -07:00
|
|
|
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
|
|
|
|
|
|
sitesmith: narrow CANVAS_TYPES to just Container
The canonical Craft.js state from real saves shows that layout shells
(Section, BackgroundSection, HeroSimple, FeaturesGrid, ColumnLayout,
CTASection, FormContainer, Navbar, Footer) all serialize with
isCanvas:false. Only Container instances are canvases. The shells use
internal <Element canvas id="..."> linkedNodes for their drop targets.
Our previous CANVAS_TYPES set claimed all those shells were canvases,
which made Craft.js's toNodeTree walker hit an uncaught Invariant —
the shell asserted "I'm a canvas" but its render ignores data.nodes,
so the walker would chase phantom children.
2026-05-24 16:27:38 -07:00
|
|
|
/** Only `Container` instances are "real" canvases in serialized state — they
|
|
|
|
|
* directly render whatever is in node.data.nodes. Layout-shell components
|
|
|
|
|
* (Section, HeroSimple, FeaturesGrid, ColumnLayout, CTASection, etc) use
|
|
|
|
|
* Craft.js <Element canvas id="…"> linkedNodes internally; their own
|
|
|
|
|
* isCanvas must be FALSE or Craft.js's toNodeTree walker trips an Invariant
|
|
|
|
|
* because the shell claims to be a canvas but its render ignores `nodes`. */
|
|
|
|
|
const CANVAS_TYPES = new Set<string>(['Container']);
|
sitesmith: fix blank canvas on Replace site
treeToState() was setting isCanvas:true on every node, including leaf
components (Heading, TextBlock, ButtonLink, Spacer, ImageBlock). Craft.js
then renders those as empty drop-canvas wrappers instead of their actual
content, so the canvas appears blank after applying an AI-generated
'replace' response.
Now uses a CANVAS_TYPES set matching the apply-ai-response utility:
only the layout wrappers (Container, Section, ColumnLayout, Hero/Features/
CTA sections, FormContainer, Navbar, Footer, etc.) are canvases. ROOT is
forced to be a canvas regardless of source type so children render.
Also defensively normalizes props.style: AI sometimes emits an empty
array instead of an object, which can confuse downstream consumers.
2026-05-24 15:35:05 -07:00
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
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;
|
2026-05-23 14:20:51 -07:00
|
|
|
/** AI helpers — replace entire site or page with a new tree */
|
|
|
|
|
replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => void;
|
|
|
|
|
replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => void;
|
|
|
|
|
setHeader: (tree: SerializedTreeNode) => void;
|
|
|
|
|
setFooter: (tree: SerializedTreeNode) => void;
|
2026-04-05 18:31:16 -07:00
|
|
|
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: () => {},
|
2026-05-23 14:20:51 -07:00
|
|
|
replaceAllPages: () => {},
|
|
|
|
|
replaceCurrentPage: () => {},
|
|
|
|
|
setHeader: () => {},
|
|
|
|
|
setFooter: () => {},
|
2026-04-05 18:31:16 -07:00
|
|
|
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: '',
|
|
|
|
|
})));
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-23 14:20:51 -07:00
|
|
|
/** Flatten a SerializedTreeNode into a Craft.js SerializedNodes JSON string */
|
|
|
|
|
const treeToState = useCallback((tree: SerializedTreeNode): string => {
|
|
|
|
|
let counter = 0;
|
|
|
|
|
const nodes: Record<string, unknown> = {};
|
|
|
|
|
const walk = (node: SerializedTreeNode, parent: string | null): string => {
|
|
|
|
|
const id = (node.props.node_id as string | undefined) || `ai-auto-${counter++}`;
|
|
|
|
|
const childIds: string[] = [];
|
sitesmith: fix blank canvas on Replace site
treeToState() was setting isCanvas:true on every node, including leaf
components (Heading, TextBlock, ButtonLink, Spacer, ImageBlock). Craft.js
then renders those as empty drop-canvas wrappers instead of their actual
content, so the canvas appears blank after applying an AI-generated
'replace' response.
Now uses a CANVAS_TYPES set matching the apply-ai-response utility:
only the layout wrappers (Container, Section, ColumnLayout, Hero/Features/
CTA sections, FormContainer, Navbar, Footer, etc.) are canvases. ROOT is
forced to be a canvas regardless of source type so children render.
Also defensively normalizes props.style: AI sometimes emits an empty
array instead of an object, which can confuse downstream consumers.
2026-05-24 15:35:05 -07:00
|
|
|
const typeName = node.type?.resolvedName;
|
|
|
|
|
// Normalize props: the AI sometimes emits `style: []` instead of `{}`.
|
|
|
|
|
// React/Craft.js choke when a CSSProperties slot is an array — normalize it.
|
|
|
|
|
const rawProps = node.props ?? {};
|
|
|
|
|
const props: Record<string, unknown> = { ...rawProps };
|
|
|
|
|
if (Array.isArray(props.style)) props.style = {};
|
2026-05-23 14:20:51 -07:00
|
|
|
nodes[id] = {
|
|
|
|
|
type: node.type,
|
sitesmith: fix blank canvas on Replace site
treeToState() was setting isCanvas:true on every node, including leaf
components (Heading, TextBlock, ButtonLink, Spacer, ImageBlock). Craft.js
then renders those as empty drop-canvas wrappers instead of their actual
content, so the canvas appears blank after applying an AI-generated
'replace' response.
Now uses a CANVAS_TYPES set matching the apply-ai-response utility:
only the layout wrappers (Container, Section, ColumnLayout, Hero/Features/
CTA sections, FormContainer, Navbar, Footer, etc.) are canvases. ROOT is
forced to be a canvas regardless of source type so children render.
Also defensively normalizes props.style: AI sometimes emits an empty
array instead of an object, which can confuse downstream consumers.
2026-05-24 15:35:05 -07:00
|
|
|
// isCanvas must match the component's craft.rules — only layout
|
|
|
|
|
// wrappers accept children. Setting it true on leaf components
|
|
|
|
|
// (Heading, TextBlock, ButtonLink, etc) makes Craft.js render them
|
|
|
|
|
// as empty drop-canvas wrappers and the actual content disappears.
|
|
|
|
|
isCanvas: typeName ? CANVAS_TYPES.has(typeName) : false,
|
|
|
|
|
props,
|
|
|
|
|
displayName: typeName,
|
2026-05-23 14:20:51 -07:00
|
|
|
custom: {},
|
|
|
|
|
hidden: false,
|
|
|
|
|
parent,
|
|
|
|
|
nodes: childIds,
|
|
|
|
|
linkedNodes: {},
|
|
|
|
|
};
|
|
|
|
|
for (const child of node.nodes ?? []) {
|
|
|
|
|
childIds.push(walk(child, id));
|
|
|
|
|
}
|
2026-05-24 16:17:25 -07:00
|
|
|
// ColumnLayout uses Craft.js linkedNodes with fixed ids (col-0, col-1, ...).
|
|
|
|
|
// The AI emits children as direct `nodes`, but ColumnLayout's render ignores
|
|
|
|
|
// them and creates fresh column Elements — the AI's children become orphans
|
|
|
|
|
// and any subsequent toNodeTree walk hits an Invariant. Move direct children
|
|
|
|
|
// into linkedNodes so they render in the columns the user actually sees.
|
|
|
|
|
if (typeName === 'ColumnLayout' && childIds.length > 0) {
|
|
|
|
|
const linked: Record<string, string> = {};
|
|
|
|
|
childIds.forEach((cid, i) => {
|
|
|
|
|
linked[`col-${i}`] = cid;
|
|
|
|
|
if (nodes[cid]) (nodes[cid] as any).isCanvas = true; // columns are canvases
|
|
|
|
|
});
|
|
|
|
|
(nodes[id] as any).nodes = [];
|
|
|
|
|
(nodes[id] as any).linkedNodes = linked;
|
|
|
|
|
// Reflect the actual column count on the component so its render matches.
|
|
|
|
|
const cur = (nodes[id] as any).props || {};
|
|
|
|
|
if (!cur.columns || cur.columns !== childIds.length) cur.columns = childIds.length;
|
|
|
|
|
(nodes[id] as any).props = cur;
|
|
|
|
|
}
|
2026-05-23 14:20:51 -07:00
|
|
|
return id;
|
|
|
|
|
};
|
|
|
|
|
const rootId = walk(tree, null);
|
|
|
|
|
// Craft.js deserialize requires the root node keyed as 'ROOT'
|
|
|
|
|
if (rootId !== 'ROOT') {
|
|
|
|
|
nodes['ROOT'] = nodes[rootId];
|
|
|
|
|
(nodes['ROOT'] as any).parent = null;
|
sitesmith: fix blank canvas on Replace site
treeToState() was setting isCanvas:true on every node, including leaf
components (Heading, TextBlock, ButtonLink, Spacer, ImageBlock). Craft.js
then renders those as empty drop-canvas wrappers instead of their actual
content, so the canvas appears blank after applying an AI-generated
'replace' response.
Now uses a CANVAS_TYPES set matching the apply-ai-response utility:
only the layout wrappers (Container, Section, ColumnLayout, Hero/Features/
CTA sections, FormContainer, Navbar, Footer, etc.) are canvases. ROOT is
forced to be a canvas regardless of source type so children render.
Also defensively normalizes props.style: AI sometimes emits an empty
array instead of an object, which can confuse downstream consumers.
2026-05-24 15:35:05 -07:00
|
|
|
// ROOT must be a canvas regardless of component type so children render.
|
|
|
|
|
(nodes['ROOT'] as any).isCanvas = true;
|
2026-05-23 14:20:51 -07:00
|
|
|
delete nodes[rootId];
|
|
|
|
|
// Fix up parent references from ROOT's children
|
|
|
|
|
for (const childId of (nodes['ROOT'] as any).nodes) {
|
|
|
|
|
if (nodes[childId]) (nodes[childId] as any).parent = 'ROOT';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return JSON.stringify(nodes);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* AI helper: replace all pages with newly generated trees.
|
|
|
|
|
* Stores each page's serialized state without touching the live canvas
|
|
|
|
|
* (the canvas still shows the currently active page — call switchPage() if needed).
|
|
|
|
|
*/
|
|
|
|
|
const replaceAllPages = useCallback((newPages: { name: string; tree: SerializedTreeNode }[]) => {
|
|
|
|
|
if (newPages.length === 0) return;
|
|
|
|
|
const built = newPages.map((p, i) => ({
|
|
|
|
|
id: i === 0 ? 'home' : `page_${Date.now()}_${i}`,
|
|
|
|
|
name: p.name,
|
|
|
|
|
slug: slugify(p.name),
|
|
|
|
|
craftState: treeToState(p.tree),
|
|
|
|
|
headCode: '',
|
|
|
|
|
}));
|
|
|
|
|
setPages(built);
|
|
|
|
|
// Load the first page into the live canvas
|
|
|
|
|
const firstState = built[0].craftState;
|
|
|
|
|
setActivePageId(built[0].id);
|
|
|
|
|
activePageIdRef.current = built[0].id;
|
|
|
|
|
loadState(firstState, EMPTY_CANVAS);
|
|
|
|
|
}, [treeToState, loadState]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* AI helper: replace the current page's tree.
|
|
|
|
|
* Deserializes the new tree into the live Craft.js canvas and persists it.
|
|
|
|
|
*/
|
|
|
|
|
const replaceCurrentPage = useCallback((page: { name: string; tree: SerializedTreeNode }) => {
|
|
|
|
|
const craftState = treeToState(page.tree);
|
|
|
|
|
const currentId = activePageIdRef.current;
|
|
|
|
|
if (currentId === HEADER_ID) {
|
|
|
|
|
setHeaderPage((prev) => ({ ...prev, name: page.name, craftState }));
|
|
|
|
|
} else if (currentId === FOOTER_ID) {
|
|
|
|
|
setFooterPage((prev) => ({ ...prev, name: page.name, craftState }));
|
|
|
|
|
} else {
|
|
|
|
|
setPages((prev) =>
|
|
|
|
|
prev.map((p) => (p.id === currentId ? { ...p, name: page.name, craftState } : p)),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
loadState(craftState, EMPTY_CANVAS);
|
|
|
|
|
}, [treeToState, loadState]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* AI helper: replace the shared header tree.
|
|
|
|
|
* Updates stored state; does NOT switch the canvas to header view.
|
|
|
|
|
*/
|
|
|
|
|
const setHeader = useCallback((tree: SerializedTreeNode) => {
|
|
|
|
|
const craftState = treeToState(tree);
|
|
|
|
|
setHeaderPage((prev) => ({ ...prev, craftState }));
|
|
|
|
|
// If the canvas is currently showing the header, refresh it live
|
|
|
|
|
if (activePageIdRef.current === HEADER_ID) {
|
|
|
|
|
loadState(craftState, EMPTY_HEADER);
|
|
|
|
|
}
|
|
|
|
|
}, [treeToState, loadState]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* AI helper: replace the shared footer tree.
|
|
|
|
|
* Updates stored state; does NOT switch the canvas to footer view.
|
|
|
|
|
*/
|
|
|
|
|
const setFooter = useCallback((tree: SerializedTreeNode) => {
|
|
|
|
|
const craftState = treeToState(tree);
|
|
|
|
|
setFooterPage((prev) => ({ ...prev, craftState }));
|
|
|
|
|
// If the canvas is currently showing the footer, refresh it live
|
|
|
|
|
if (activePageIdRef.current === FOOTER_ID) {
|
|
|
|
|
loadState(craftState, EMPTY_FOOTER);
|
|
|
|
|
}
|
|
|
|
|
}, [treeToState, loadState]);
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
return (
|
|
|
|
|
<PageContext.Provider
|
|
|
|
|
value={{
|
|
|
|
|
pages,
|
|
|
|
|
headerPage,
|
|
|
|
|
footerPage,
|
|
|
|
|
activePageId,
|
|
|
|
|
isEditingHeader,
|
|
|
|
|
isEditingFooter,
|
|
|
|
|
switchPage,
|
|
|
|
|
editHeader,
|
|
|
|
|
editFooter,
|
|
|
|
|
addPage,
|
|
|
|
|
deletePage,
|
|
|
|
|
renamePage,
|
|
|
|
|
setHeaderCraftState,
|
|
|
|
|
setFooterCraftState,
|
|
|
|
|
setPagesCraftState,
|
2026-05-23 14:20:51 -07:00
|
|
|
replaceAllPages,
|
|
|
|
|
replaceCurrentPage,
|
|
|
|
|
setHeader,
|
|
|
|
|
setFooter,
|
2026-04-05 18:31:16 -07:00
|
|
|
siteDesign: design,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</PageContext.Provider>
|
|
|
|
|
);
|
|
|
|
|
};
|