2026-04-05 18:31:16 -07:00
|
|
|
import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
|
|
|
|
|
import { useEditor } from '@craftjs/core';
|
2026-07-14 07:14:56 -07:00
|
|
|
import { PageData, PageSeo } 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';
|
2026-07-12 14:04:57 -07:00
|
|
|
import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree';
|
2026-05-24 17:22:40 -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;
|
2026-07-14 07:36:00 -07:00
|
|
|
/**
|
|
|
|
|
* Duplicates `pageId`, inserting the copy immediately after the source in
|
|
|
|
|
* `pages` and switching the canvas to the new copy. If `pageId` is the
|
|
|
|
|
* active page, its current on-canvas state is saved first so the copy
|
|
|
|
|
* (and the original) both reflect what's actually on screen. The copy
|
|
|
|
|
* gets its own unique slug (never `'index'` -- it's never at index 0) and
|
|
|
|
|
* a name of `"<source name> copy"`; its `seo` is copied from the source.
|
|
|
|
|
*/
|
|
|
|
|
duplicatePage: (pageId: string) => void;
|
|
|
|
|
/**
|
|
|
|
|
* Reorders `pageId` one slot `'up'` or `'down'` within `pages` (swap with
|
|
|
|
|
* the adjacent page; no-op at either end). Does NOT touch the live
|
|
|
|
|
* canvas -- only list order changes. Re-applies the landing-page
|
|
|
|
|
* invariant afterward (see `applyLandingInvariant`) since a reorder can
|
|
|
|
|
* move a different page into/out of index 0.
|
|
|
|
|
*/
|
|
|
|
|
movePage: (pageId: string, direction: 'up' | 'down') => void;
|
|
|
|
|
/**
|
|
|
|
|
* Moves `pageId` to index 0 (making it the new landing page) and
|
|
|
|
|
* re-applies the landing-page invariant. Does NOT touch the live canvas.
|
|
|
|
|
*/
|
|
|
|
|
setLandingPage: (pageId: string) => void;
|
2026-07-14 07:14:56 -07:00
|
|
|
/** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
|
|
|
|
|
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
|
2026-04-05 18:31:16 -07:00
|
|
|
setHeaderCraftState: (craftState: string) => void;
|
|
|
|
|
setFooterCraftState: (craftState: string) => void;
|
2026-07-14 07:14:56 -07:00
|
|
|
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo }[]) => void;
|
2026-07-12 17:29:32 -07:00
|
|
|
/**
|
|
|
|
|
* Bookkeeping-only: point `activePageId` at an already-loaded page without
|
|
|
|
|
* re-serializing/deserializing the canvas (the caller -- e.g. useWhpApi's
|
|
|
|
|
* `load()` -- has already put the right state on the canvas itself). Used
|
|
|
|
|
* to fix I-1: `activePageId` defaults to the hardcoded `'home'` and
|
|
|
|
|
* `load()` never updated it, so after the original Home page was deleted
|
|
|
|
|
* (its replacement gets a fresh `page_<ts>` id) and the app reloaded,
|
|
|
|
|
* `activePageId` pointed at nothing in `pages`, and a subsequent edit+save
|
|
|
|
|
* only reached the legacy top-level fields.
|
|
|
|
|
*/
|
|
|
|
|
setActivePageIdDirect: (pageId: string) => 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__';
|
|
|
|
|
|
2026-07-12 18:31:12 -07:00
|
|
|
// M-3: `page_${Date.now()}` alone collides when two pages are minted inside
|
|
|
|
|
// the same millisecond (addPage called twice in quick succession, or two AI
|
|
|
|
|
// replaceAllPages entries) -- then rename/delete/save operate on both pages
|
|
|
|
|
// at once since they share an id. A module-scoped monotonic counter,
|
|
|
|
|
// combined with the timestamp, guarantees uniqueness regardless of how many
|
|
|
|
|
// ids are minted within the same millisecond. This is state/id-minting code
|
|
|
|
|
// (not toHtml/export), so Date.now() here is fine -- see task-minors-brief.md.
|
|
|
|
|
let pageIdCounter = 0;
|
|
|
|
|
|
|
|
|
|
/** Mints a unique page id: timestamp (base36) + a monotonic per-process counter (base36). */
|
|
|
|
|
export function nextPageId(): string {
|
|
|
|
|
return 'page_' + Date.now().toString(36) + '_' + (++pageIdCounter).toString(36);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
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":{}}}';
|
|
|
|
|
|
2026-07-12 14:04:57 -07:00
|
|
|
/**
|
|
|
|
|
* Flatten a `SerializedTreeNode` (as produced by the AI, a template, etc.)
|
|
|
|
|
* into a Craft.js `SerializedNodes` JSON string ready for
|
|
|
|
|
* `actions.deserialize()`.
|
|
|
|
|
*
|
|
|
|
|
* Untrusted input is validated the same way `apply-ai-response.ts`'s
|
|
|
|
|
* `buildNodeTree` validates AI `patch`/section-replace trees: any node whose
|
|
|
|
|
* `type.resolvedName` isn't a registered component is dropped (subtree and
|
|
|
|
|
* all) via `sanitizeAiTree`, and a bad/colliding/`'ROOT'` id is regenerated
|
|
|
|
|
* — never left as-is. This matters here specifically because `replace`
|
|
|
|
|
* scope `site`/`page` responses reach this function via `actions.deserialize`
|
|
|
|
|
* with no further validation downstream, unlike the `buildNodeTree` path
|
|
|
|
|
* which also gets Craft.js's own `parseFreshNode` as a second line of
|
|
|
|
|
* defense. If the ROOT node itself is invalid, sanitizeAiTree returns null
|
|
|
|
|
* and we fall back to an empty canvas rather than handing deserialize()
|
|
|
|
|
* something that could throw.
|
|
|
|
|
*
|
|
|
|
|
* Exported standalone (not a hook) so it's directly unit-testable without
|
|
|
|
|
* mounting a Craft.js `<Editor>`.
|
|
|
|
|
*/
|
|
|
|
|
export function treeToCraftState(tree: SerializedTreeNode): string {
|
|
|
|
|
const sanitized = sanitizeAiTree(tree, new Set());
|
|
|
|
|
if (!sanitized) return EMPTY_CANVAS;
|
|
|
|
|
|
|
|
|
|
const { rootNodeId, nodes: flatNodes } = flattenTreeForCraft(sanitized);
|
|
|
|
|
const nodes: Record<string, FlatCraftNode> = flatNodes;
|
|
|
|
|
|
|
|
|
|
// Craft.js deserialize requires the root node keyed as 'ROOT'.
|
|
|
|
|
if (rootNodeId !== 'ROOT') {
|
|
|
|
|
nodes['ROOT'] = { ...nodes[rootNodeId], parent: null, isCanvas: true };
|
|
|
|
|
delete nodes[rootNodeId];
|
|
|
|
|
for (const childId of nodes['ROOT'].nodes) {
|
|
|
|
|
if (nodes[childId]) nodes[childId].parent = 'ROOT';
|
|
|
|
|
}
|
2026-07-12 17:31:01 -07:00
|
|
|
// I-2: linkedNodes children (e.g. ColumnLayout's col-0/col-1, or a
|
|
|
|
|
// Section/BackgroundSection/FormContainer's SHELL_INNER wrapper) need
|
|
|
|
|
// the same reparenting as nodes[] children above. Without this, a
|
|
|
|
|
// ColumnLayout/SHELL_INNER-rooted AI `replace` leaves those children's
|
|
|
|
|
// `parent` pointing at the OLD root id, which is then `delete`d --
|
|
|
|
|
// producing a dangling parent reference that breaks select/move/delete
|
|
|
|
|
// of those nodes in the Craft.js editor.
|
|
|
|
|
for (const linkedId of Object.values(nodes['ROOT'].linkedNodes)) {
|
|
|
|
|
if (nodes[linkedId]) nodes[linkedId].parent = 'ROOT';
|
|
|
|
|
}
|
2026-07-12 14:04:57 -07:00
|
|
|
}
|
|
|
|
|
return JSON.stringify(nodes);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:53:22 -07:00
|
|
|
// Default header seed: a ROOT header Container holding a Navbar with default
|
|
|
|
|
// links. New sites previously opened with an EMPTY header (just a bare
|
|
|
|
|
// Container), so there was no menu to edit and the empty zone rendered as a
|
|
|
|
|
// stray band above the page. Seeding a real Navbar gives every new site an
|
|
|
|
|
// editable menu-with-links out of the box (and removes the empty-header gap).
|
2026-07-12 20:56:11 -07:00
|
|
|
//
|
|
|
|
|
// Item 15: the nav used to link Home/About/Services/Contact, but a brand
|
|
|
|
|
// new site only has a "Home" page -- About/Services/Contact were dead links
|
|
|
|
|
// on first click. Rather than seeding three empty placeholder pages nobody
|
|
|
|
|
// asked for, the simpler default is a nav with just the one real page (Home)
|
|
|
|
|
// plus a CTA button, which is inert (`href: '#'`) rather than pointing at a
|
|
|
|
|
// page that doesn't exist. Users add pages/links as their site grows.
|
2026-07-12 14:04:57 -07:00
|
|
|
// Node shape matches treeToCraftState() / Craft's actions.deserialize().
|
2026-07-06 17:53:22 -07:00
|
|
|
export const DEFAULT_HEADER_STATE = JSON.stringify({
|
|
|
|
|
ROOT: {
|
|
|
|
|
type: { resolvedName: 'Container' },
|
|
|
|
|
isCanvas: true,
|
|
|
|
|
props: { style: { width: '100%' }, tag: 'header' },
|
|
|
|
|
displayName: 'Container',
|
|
|
|
|
custom: {},
|
|
|
|
|
hidden: false,
|
|
|
|
|
nodes: ['header-navbar'],
|
|
|
|
|
linkedNodes: {},
|
|
|
|
|
},
|
|
|
|
|
'header-navbar': {
|
|
|
|
|
type: { resolvedName: 'Navbar' },
|
|
|
|
|
isCanvas: false,
|
|
|
|
|
props: {
|
|
|
|
|
logoType: 'text',
|
|
|
|
|
logoText: 'MySite',
|
|
|
|
|
logoImage: '',
|
|
|
|
|
logoWidth: '120px',
|
|
|
|
|
logoUrl: '/',
|
|
|
|
|
logoFontFamily: 'Inter, sans-serif',
|
|
|
|
|
logoFontSize: '20px',
|
|
|
|
|
links: [
|
|
|
|
|
{ text: 'Home', href: '/' },
|
2026-07-12 20:56:11 -07:00
|
|
|
{ text: 'Get Started', href: '#', isCta: true },
|
2026-07-06 17:53:22 -07:00
|
|
|
],
|
|
|
|
|
backgroundColor: '#ffffff',
|
|
|
|
|
textColor: '#3f3f46',
|
|
|
|
|
hoverColor: '#3b82f6',
|
|
|
|
|
ctaColor: '#3b82f6',
|
|
|
|
|
ctaTextColor: '#ffffff',
|
|
|
|
|
padding: '16px 24px',
|
|
|
|
|
navAlignment: 'space-between',
|
|
|
|
|
isSticky: false,
|
|
|
|
|
showMobileMenu: false,
|
|
|
|
|
style: { borderBottom: '1px solid #e4e4e7' },
|
|
|
|
|
},
|
|
|
|
|
displayName: 'Navbar',
|
|
|
|
|
parent: 'ROOT',
|
|
|
|
|
custom: {},
|
|
|
|
|
hidden: false,
|
|
|
|
|
nodes: [],
|
|
|
|
|
linkedNodes: {},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
const PageContext = createContext<PageContextValue>({
|
|
|
|
|
pages: [],
|
2026-07-12 13:30:17 -07:00
|
|
|
headerPage: { id: HEADER_ID, name: 'Header', slug: '__header__', craftState: null },
|
|
|
|
|
footerPage: { id: FOOTER_ID, name: 'Footer', slug: '__footer__', craftState: null },
|
2026-04-05 18:31:16 -07:00
|
|
|
activePageId: 'home',
|
|
|
|
|
isEditingHeader: false,
|
|
|
|
|
isEditingFooter: false,
|
|
|
|
|
switchPage: () => {},
|
|
|
|
|
editHeader: () => {},
|
|
|
|
|
editFooter: () => {},
|
|
|
|
|
addPage: () => {},
|
|
|
|
|
deletePage: () => {},
|
|
|
|
|
renamePage: () => {},
|
2026-07-14 07:36:00 -07:00
|
|
|
duplicatePage: () => {},
|
|
|
|
|
movePage: () => {},
|
|
|
|
|
setLandingPage: () => {},
|
2026-07-14 07:14:56 -07:00
|
|
|
updatePageSeo: () => {},
|
2026-04-05 18:31:16 -07:00
|
|
|
setHeaderCraftState: () => {},
|
|
|
|
|
setFooterCraftState: () => {},
|
|
|
|
|
setPagesCraftState: () => {},
|
2026-07-12 17:29:32 -07:00
|
|
|
setActivePageIdDirect: () => {},
|
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 {
|
2026-07-12 17:33:33 -07:00
|
|
|
const slug = name
|
2026-04-05 18:31:16 -07:00
|
|
|
.toLowerCase()
|
|
|
|
|
.trim()
|
|
|
|
|
.replace(/[^a-z0-9\s-]/g, '')
|
|
|
|
|
.replace(/\s+/g, '-')
|
|
|
|
|
.replace(/-+/g, '-');
|
2026-07-12 17:33:33 -07:00
|
|
|
// M-4: a punctuation-only name (e.g. "!!!") strips down to '' -- without a
|
|
|
|
|
// fallback, buildSavePayload would write filename = '' + '.html' for that
|
|
|
|
|
// page. uniqueSlug's existing dedupe logic then applies on top of this
|
|
|
|
|
// fallback the same way it does for any other base ('page', 'page-2', ...).
|
|
|
|
|
return slug || 'page';
|
2026-04-05 18:31:16 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 13:25:41 -07:00
|
|
|
/**
|
|
|
|
|
* Append `-2`, `-3`, … to `base` until it no longer collides with
|
|
|
|
|
* `existingSlugs`. Two pages that slugify to the same string (e.g. both
|
|
|
|
|
* named "About") must not both publish to `about.html` — the second write
|
|
|
|
|
* would silently overwrite the first on publish.
|
|
|
|
|
*/
|
|
|
|
|
export function uniqueSlug(base: string, existingSlugs: string[]): string {
|
|
|
|
|
if (!existingSlugs.includes(base)) return base;
|
|
|
|
|
let i = 2;
|
|
|
|
|
while (existingSlugs.includes(`${base}-${i}`)) i++;
|
|
|
|
|
return `${base}-${i}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 07:36:00 -07:00
|
|
|
/**
|
|
|
|
|
* Re-establishes the landing-page invariant on a REORDERED pages array: the
|
|
|
|
|
* page now at index 0 is the landing page and its slug is locked to
|
|
|
|
|
* `'index'` (regardless of whatever slug it held before it was moved there);
|
|
|
|
|
* every other page keeps its slug UNLESS it's the page that previously held
|
|
|
|
|
* `'index'` and has now been demoted to index > 0 -- that page needs a real,
|
|
|
|
|
* unique slug of its own (derived from its name) since a page can no longer
|
|
|
|
|
* publish to `index.html` from anywhere but index 0.
|
|
|
|
|
*
|
|
|
|
|
* Pure function of the array -- used by both `movePage` (swap two adjacent
|
|
|
|
|
* pages) and `setLandingPage` (move an arbitrary page to index 0) as the
|
|
|
|
|
* shared "fix the invariant up after reordering" step, and directly
|
|
|
|
|
* unit-testable without mounting `PageProvider`.
|
|
|
|
|
*
|
2026-07-14 07:47:44 -07:00
|
|
|
* Normally at most one page enters with slug `'index'` (true for any array
|
2026-07-14 07:36:00 -07:00
|
|
|
* that already satisfied the invariant before the reorder that produced this
|
2026-07-14 07:47:44 -07:00
|
|
|
* input) -- exactly the case both callers hand it. Defensively, though, a
|
|
|
|
|
* STRAY second page with slug `'index'` at index > 0 (e.g. from legacy
|
|
|
|
|
* loaded data that predates this invariant) is also demoted rather than left
|
|
|
|
|
* as a duplicate -- see the running `usedSlugs` accumulation below.
|
2026-07-14 07:36:00 -07:00
|
|
|
*/
|
|
|
|
|
export function applyLandingInvariant(pages: PageData[]): PageData[] {
|
|
|
|
|
if (pages.length === 0) return pages;
|
|
|
|
|
|
2026-07-14 07:47:44 -07:00
|
|
|
// Slugs that must not be collided into: 'index' (reserved for whoever
|
|
|
|
|
// ends up at index 0) plus every non-landing page's existing slug except
|
|
|
|
|
// any demoted page's (it currently holds 'index' and is about to be given
|
|
|
|
|
// a new one). Computed upfront, over the WHOLE array, so a demoted page's
|
|
|
|
|
// new slug is checked against every other page regardless of array order
|
|
|
|
|
// -- checking only "slugs seen so far" while walking the array would miss
|
|
|
|
|
// a collision against a page that appears LATER in the list than the
|
|
|
|
|
// demoted one. Mutated (pushed to) as pages are demoted below so that two
|
|
|
|
|
// demoted pages in the same pass can't collide with EACH OTHER either.
|
|
|
|
|
const usedSlugs: string[] = ['index'];
|
2026-07-14 07:36:00 -07:00
|
|
|
for (let i = 1; i < pages.length; i++) {
|
2026-07-14 07:47:44 -07:00
|
|
|
if (pages[i].slug !== 'index') usedSlugs.push(pages[i].slug);
|
2026-07-14 07:36:00 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return pages.map((page, i) => {
|
|
|
|
|
if (i === 0) {
|
|
|
|
|
return page.slug === 'index' ? page : { ...page, slug: 'index' };
|
|
|
|
|
}
|
|
|
|
|
if (page.slug === 'index') {
|
2026-07-14 07:47:44 -07:00
|
|
|
// Demoted landing page (or a stray extra 'index' page -- see doc
|
|
|
|
|
// comment above) -- give it a real, unique slug of its own.
|
|
|
|
|
const newSlug = uniqueSlug(slugify(page.name), usedSlugs);
|
|
|
|
|
usedSlugs.push(newSlug);
|
|
|
|
|
return { ...page, slug: newSlug };
|
2026-07-14 07:36:00 -07:00
|
|
|
}
|
|
|
|
|
return page;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
const DEFAULT_PAGE: PageData = {
|
|
|
|
|
id: 'home',
|
|
|
|
|
name: 'Home',
|
|
|
|
|
slug: 'index',
|
|
|
|
|
craftState: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const DEFAULT_HEADER: PageData = {
|
|
|
|
|
id: HEADER_ID,
|
|
|
|
|
name: 'Header',
|
|
|
|
|
slug: '__header__',
|
2026-07-06 17:53:22 -07:00
|
|
|
craftState: DEFAULT_HEADER_STATE,
|
2026-04-05 18:31:16 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const DEFAULT_FOOTER: PageData = {
|
|
|
|
|
id: FOOTER_ID,
|
|
|
|
|
name: 'Footer',
|
|
|
|
|
slug: '__footer__',
|
|
|
|
|
craftState: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-07-12 13:28:27 -07:00
|
|
|
// Mirror the latest state in refs so event handlers (switchPage,
|
|
|
|
|
// deletePage) can synchronously read "what's current" without smuggling a
|
|
|
|
|
// side effect into a setState updater function to peek at `prev` — updater
|
|
|
|
|
// functions must stay pure since React (StrictMode) may invoke them twice.
|
|
|
|
|
const pagesRef = useRef(pages);
|
|
|
|
|
pagesRef.current = pages;
|
|
|
|
|
const headerPageRef = useRef(headerPage);
|
|
|
|
|
headerPageRef.current = headerPage;
|
|
|
|
|
const footerPageRef = useRef(footerPage);
|
|
|
|
|
footerPageRef.current = footerPage;
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
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)),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 13:28:27 -07:00
|
|
|
// Load target page state. `pageId` can never equal `currentId` here
|
|
|
|
|
// (guarded by the early return above), so the target's stored state
|
|
|
|
|
// was untouched by the setState calls just above — the refs (kept in
|
|
|
|
|
// sync with state on every render) are safe to read synchronously
|
|
|
|
|
// without waiting for a re-render, and without running the load as a
|
|
|
|
|
// side effect inside a setState updater.
|
2026-04-05 18:31:16 -07:00
|
|
|
if (pageId === HEADER_ID) {
|
2026-07-12 13:28:27 -07:00
|
|
|
loadState(headerPageRef.current.craftState, EMPTY_HEADER);
|
2026-04-05 18:31:16 -07:00
|
|
|
} else if (pageId === FOOTER_ID) {
|
2026-07-12 13:28:27 -07:00
|
|
|
loadState(footerPageRef.current.craftState, EMPTY_FOOTER);
|
2026-04-05 18:31:16 -07:00
|
|
|
} else {
|
2026-07-12 13:28:27 -07:00
|
|
|
const target = pagesRef.current.find((p) => p.id === pageId);
|
|
|
|
|
loadState(target?.craftState || null, EMPTY_CANVAS);
|
2026-04-05 18:31:16 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) => {
|
2026-07-12 13:25:41 -07:00
|
|
|
const requestedSlug = slug || slugify(name);
|
2026-07-12 18:31:12 -07:00
|
|
|
const id = nextPageId();
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
// Save current page first
|
|
|
|
|
saveCurrentState();
|
|
|
|
|
|
|
|
|
|
setPages((prev) => [
|
|
|
|
|
...prev,
|
|
|
|
|
{
|
|
|
|
|
id,
|
|
|
|
|
name,
|
2026-07-12 13:25:41 -07:00
|
|
|
slug: uniqueSlug(requestedSlug, prev.map((p) => p.slug)),
|
2026-04-05 18:31:16 -07:00
|
|
|
craftState: null,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
2026-07-12 13:28:27 -07:00
|
|
|
const prev = pagesRef.current;
|
|
|
|
|
if (prev.length <= 1) return;
|
|
|
|
|
|
|
|
|
|
const filtered = prev.filter((p) => p.id !== pageId);
|
|
|
|
|
setPages(filtered);
|
|
|
|
|
|
|
|
|
|
// If deleting the active page, switch to the first remaining. This
|
|
|
|
|
// runs after the state update is computed (not inside the setPages
|
|
|
|
|
// updater) so the side effects (deserialize, ref/state mutation) fire
|
|
|
|
|
// exactly once regardless of how many times React invokes updaters.
|
|
|
|
|
if (pageId === activePageIdRef.current) {
|
|
|
|
|
const nextPage = filtered[0];
|
|
|
|
|
setActivePageId(nextPage.id);
|
|
|
|
|
activePageIdRef.current = nextPage.id;
|
|
|
|
|
loadState(nextPage.craftState, EMPTY_CANVAS);
|
|
|
|
|
}
|
2026-04-05 18:31:16 -07:00
|
|
|
},
|
|
|
|
|
[loadState],
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-14 07:36:00 -07:00
|
|
|
/**
|
|
|
|
|
* Duplicates `pageId`: inserts a copy immediately after the source in
|
|
|
|
|
* `pages` and switches the live canvas to it. See the doc comment on
|
|
|
|
|
* `PageContextValue.duplicatePage`.
|
|
|
|
|
*/
|
|
|
|
|
const duplicatePage = useCallback(
|
|
|
|
|
(pageId: string) => {
|
2026-07-14 07:47:44 -07:00
|
|
|
// Always persist whatever is on the live canvas back into its page
|
|
|
|
|
// slot BEFORE any teardown below (same as addPage/switchPage/deletePage
|
|
|
|
|
// do unconditionally). Without this, duplicating a page OTHER than the
|
|
|
|
|
// active one would tear down and switch the canvas via loadState()
|
|
|
|
|
// further down without ever serializing the outgoing active page's
|
|
|
|
|
// live edits into its slot -- silently discarding them.
|
|
|
|
|
saveCurrentState();
|
2026-07-14 07:36:00 -07:00
|
|
|
|
2026-07-14 07:47:44 -07:00
|
|
|
const isActive = pageId === activePageIdRef.current;
|
2026-07-14 07:36:00 -07:00
|
|
|
const source = pagesRef.current.find((p) => p.id === pageId);
|
|
|
|
|
if (!source) return;
|
|
|
|
|
|
2026-07-14 07:47:44 -07:00
|
|
|
// If the source IS the active page, saveCurrentState() above just
|
|
|
|
|
// wrote the live canvas into `source.craftState`'s slot -- but
|
|
|
|
|
// `pagesRef.current` (captured above) may still be the pre-update
|
|
|
|
|
// snapshot depending on render timing, so ask Craft.js directly for
|
|
|
|
|
// the same value rather than re-reading the ref. If the source is a
|
|
|
|
|
// NON-active page, its stored craftState is untouched by saving the
|
|
|
|
|
// (different) active page above, so use it as-is.
|
2026-07-14 07:36:00 -07:00
|
|
|
const sourceCraftState = isActive ? query.serialize() : source.craftState;
|
|
|
|
|
const otherSlugs = pagesRef.current.map((p) => p.slug);
|
|
|
|
|
const copyId = nextPageId();
|
|
|
|
|
const copyName = `${source.name} copy`;
|
|
|
|
|
// The copy is always inserted AFTER the source (index >= 1), so it
|
|
|
|
|
// never needs the reserved 'index' slug -- a normal unique slug always
|
|
|
|
|
// applies here regardless of whether the source itself is the landing
|
|
|
|
|
// page.
|
|
|
|
|
const copySlug = uniqueSlug(slugify(copyName), otherSlugs);
|
|
|
|
|
const copy: PageData = {
|
|
|
|
|
id: copyId,
|
|
|
|
|
name: copyName,
|
|
|
|
|
slug: copySlug,
|
|
|
|
|
craftState: sourceCraftState,
|
|
|
|
|
seo: source.seo ? { ...source.seo } : undefined,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setPages((prev) => {
|
|
|
|
|
const idx = prev.findIndex((p) => p.id === pageId);
|
|
|
|
|
if (idx === -1) return prev;
|
|
|
|
|
const next = [...prev];
|
|
|
|
|
next.splice(idx + 1, 0, copy);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Switch the canvas to the new copy so the user lands on it, same as
|
|
|
|
|
// addPage switching to the freshly created page.
|
|
|
|
|
loadState(copy.craftState, EMPTY_CANVAS);
|
|
|
|
|
setActivePageId(copyId);
|
|
|
|
|
activePageIdRef.current = copyId;
|
|
|
|
|
},
|
|
|
|
|
[query, saveCurrentState, loadState],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reorders `pageId` one slot up or down (swap with the adjacent page).
|
|
|
|
|
* Pure list-order change -- does not touch the live canvas. See the doc
|
|
|
|
|
* comment on `PageContextValue.movePage`.
|
|
|
|
|
*/
|
|
|
|
|
const movePage = useCallback((pageId: string, direction: 'up' | 'down') => {
|
|
|
|
|
setPages((prev) => {
|
|
|
|
|
const idx = prev.findIndex((p) => p.id === pageId);
|
|
|
|
|
if (idx === -1) return prev;
|
|
|
|
|
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
|
|
|
|
if (swapIdx < 0 || swapIdx >= prev.length) return prev; // no-op at the ends
|
|
|
|
|
|
|
|
|
|
const next = [...prev];
|
|
|
|
|
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
|
|
|
|
return applyLandingInvariant(next);
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Moves `pageId` to index 0, making it the new landing page. Pure list-
|
|
|
|
|
* order change -- does not touch the live canvas. See the doc comment on
|
|
|
|
|
* `PageContextValue.setLandingPage`.
|
|
|
|
|
*/
|
|
|
|
|
const setLandingPage = useCallback((pageId: string) => {
|
|
|
|
|
setPages((prev) => {
|
|
|
|
|
const idx = prev.findIndex((p) => p.id === pageId);
|
|
|
|
|
if (idx <= 0) return prev; // already the landing page, or not found
|
|
|
|
|
|
|
|
|
|
const next = [...prev];
|
|
|
|
|
const [moved] = next.splice(idx, 1);
|
|
|
|
|
next.unshift(moved);
|
|
|
|
|
return applyLandingInvariant(next);
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
const renamePage = useCallback((pageId: string, name: string, slug: string) => {
|
|
|
|
|
setPages((prev) =>
|
2026-05-25 12:14:26 -07:00
|
|
|
prev.map((p, i) => {
|
|
|
|
|
if (p.id !== pageId) return p;
|
|
|
|
|
// First page is the landing page — its slug is locked to 'index' so
|
|
|
|
|
// the file always publishes to index.html regardless of the user-set
|
|
|
|
|
// name. The display name can change freely.
|
2026-07-12 13:25:41 -07:00
|
|
|
if (i === 0) return { ...p, name, slug: 'index' };
|
|
|
|
|
const requestedSlug = slug || slugify(name);
|
|
|
|
|
const otherSlugs = prev.filter((pp) => pp.id !== pageId).map((pp) => pp.slug);
|
|
|
|
|
return { ...p, name, slug: uniqueSlug(requestedSlug, otherSlugs) };
|
2026-05-25 12:14:26 -07:00
|
|
|
}),
|
2026-04-05 18:31:16 -07:00
|
|
|
);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-14 07:14:56 -07:00
|
|
|
/** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
|
|
|
|
|
const updatePageSeo = useCallback((pageId: string, seo: Partial<PageSeo>) => {
|
|
|
|
|
setPages((prev) =>
|
|
|
|
|
prev.map((p) => (p.id === pageId ? { ...p, seo: { ...p.seo, ...seo } } : p)),
|
|
|
|
|
);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
/** 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 }));
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-12 17:29:32 -07:00
|
|
|
/**
|
|
|
|
|
* Bookkeeping-only setter for `activePageId` -- see the doc comment on
|
|
|
|
|
* `PageContextValue.setActivePageIdDirect`. Does NOT serialize/deserialize
|
|
|
|
|
* the canvas; callers that need that should use `switchPage` instead.
|
|
|
|
|
*/
|
|
|
|
|
const setActivePageIdDirect = useCallback((pageId: string) => {
|
|
|
|
|
setActivePageId(pageId);
|
|
|
|
|
activePageIdRef.current = pageId;
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
/** Allow external code (e.g., load from API) to restore pages with craft states */
|
2026-07-14 07:14:56 -07:00
|
|
|
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo }[]) => {
|
2026-05-25 12:14:26 -07:00
|
|
|
setPages(pagesData.map((p, i) => ({
|
2026-04-05 18:31:16 -07:00
|
|
|
id: p.id,
|
|
|
|
|
name: p.name,
|
2026-05-25 12:14:26 -07:00
|
|
|
// Heal legacy projects whose first page was saved with slug='home' (or
|
|
|
|
|
// any other) before the landing-page rule existed. The first page is
|
|
|
|
|
// ALWAYS the landing page → slug 'index' → file index.html.
|
|
|
|
|
slug: i === 0 ? 'index' : p.slug,
|
2026-04-05 18:31:16 -07:00
|
|
|
craftState: p.craftState,
|
2026-07-14 07:14:56 -07:00
|
|
|
seo: p.seo,
|
2026-04-05 18:31:16 -07:00
|
|
|
})));
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-23 14:20:51 -07:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-07-12 13:25:41 -07:00
|
|
|
// Track slugs as they're assigned so later pages dedupe against earlier
|
|
|
|
|
// ones in the same batch (e.g. the AI generating two "About" pages).
|
|
|
|
|
const seenSlugs: string[] = [];
|
|
|
|
|
const built = newPages.map((p, i) => {
|
2026-05-24 17:50:08 -07:00
|
|
|
// First page must publish to index.html so it serves at the site root.
|
|
|
|
|
// Apache resolves '/' to index.html, not home.html — without this, the
|
|
|
|
|
// AI's "Home" page lands at /home.html and visitors hit a blank root.
|
2026-07-12 13:25:41 -07:00
|
|
|
const slug = i === 0 ? 'index' : uniqueSlug(slugify(p.name), seenSlugs);
|
|
|
|
|
seenSlugs.push(slug);
|
|
|
|
|
return {
|
2026-07-12 18:31:12 -07:00
|
|
|
id: i === 0 ? 'home' : nextPageId(),
|
2026-07-12 13:25:41 -07:00
|
|
|
name: p.name,
|
|
|
|
|
slug,
|
2026-07-12 14:04:57 -07:00
|
|
|
craftState: treeToCraftState(p.tree),
|
2026-07-12 13:25:41 -07:00
|
|
|
};
|
|
|
|
|
});
|
2026-05-23 14:20:51 -07:00
|
|
|
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);
|
2026-07-12 14:04:57 -07:00
|
|
|
}, [loadState]);
|
2026-05-23 14:20:51 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 }) => {
|
2026-07-12 14:04:57 -07:00
|
|
|
const craftState = treeToCraftState(page.tree);
|
2026-05-23 14:20:51 -07:00
|
|
|
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);
|
2026-07-12 14:04:57 -07:00
|
|
|
}, [loadState]);
|
2026-05-23 14:20:51 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* AI helper: replace the shared header tree.
|
|
|
|
|
* Updates stored state; does NOT switch the canvas to header view.
|
|
|
|
|
*/
|
|
|
|
|
const setHeader = useCallback((tree: SerializedTreeNode) => {
|
2026-07-12 14:04:57 -07:00
|
|
|
const craftState = treeToCraftState(tree);
|
2026-05-23 14:20:51 -07:00
|
|
|
setHeaderPage((prev) => ({ ...prev, craftState }));
|
|
|
|
|
// If the canvas is currently showing the header, refresh it live
|
|
|
|
|
if (activePageIdRef.current === HEADER_ID) {
|
|
|
|
|
loadState(craftState, EMPTY_HEADER);
|
|
|
|
|
}
|
2026-07-12 14:04:57 -07:00
|
|
|
}, [loadState]);
|
2026-05-23 14:20:51 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* AI helper: replace the shared footer tree.
|
|
|
|
|
* Updates stored state; does NOT switch the canvas to footer view.
|
|
|
|
|
*/
|
|
|
|
|
const setFooter = useCallback((tree: SerializedTreeNode) => {
|
2026-07-12 14:04:57 -07:00
|
|
|
const craftState = treeToCraftState(tree);
|
2026-05-23 14:20:51 -07:00
|
|
|
setFooterPage((prev) => ({ ...prev, craftState }));
|
|
|
|
|
// If the canvas is currently showing the footer, refresh it live
|
|
|
|
|
if (activePageIdRef.current === FOOTER_ID) {
|
|
|
|
|
loadState(craftState, EMPTY_FOOTER);
|
|
|
|
|
}
|
2026-07-12 14:04:57 -07:00
|
|
|
}, [loadState]);
|
2026-05-23 14:20:51 -07:00
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
return (
|
|
|
|
|
<PageContext.Provider
|
|
|
|
|
value={{
|
|
|
|
|
pages,
|
|
|
|
|
headerPage,
|
|
|
|
|
footerPage,
|
|
|
|
|
activePageId,
|
|
|
|
|
isEditingHeader,
|
|
|
|
|
isEditingFooter,
|
|
|
|
|
switchPage,
|
|
|
|
|
editHeader,
|
|
|
|
|
editFooter,
|
|
|
|
|
addPage,
|
|
|
|
|
deletePage,
|
|
|
|
|
renamePage,
|
2026-07-14 07:36:00 -07:00
|
|
|
duplicatePage,
|
|
|
|
|
movePage,
|
|
|
|
|
setLandingPage,
|
2026-07-14 07:14:56 -07:00
|
|
|
updatePageSeo,
|
2026-04-05 18:31:16 -07:00
|
|
|
setHeaderCraftState,
|
|
|
|
|
setFooterCraftState,
|
|
|
|
|
setPagesCraftState,
|
2026-07-12 17:29:32 -07:00
|
|
|
setActivePageIdDirect,
|
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>
|
|
|
|
|
);
|
|
|
|
|
};
|