feat(site-builder): page duplicate/reorder/set-landing + fix cross-page node copy/paste

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 07:36:00 -07:00
co-authored by Claude Opus 4.8
parent 204ea5e078
commit a698f014b0
9 changed files with 1040 additions and 108 deletions
+165
View File
@@ -18,6 +18,28 @@ interface PageContextValue {
addPage: (name: string, slug: string) => void;
deletePage: (pageId: string) => void;
renamePage: (pageId: string, name: string, slug: string) => void;
/**
* 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;
/** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
setHeaderCraftState: (craftState: string) => void;
@@ -188,6 +210,9 @@ const PageContext = createContext<PageContextValue>({
addPage: () => {},
deletePage: () => {},
renamePage: () => {},
duplicatePage: () => {},
movePage: () => {},
setLandingPage: () => {},
updatePageSeo: () => {},
setHeaderCraftState: () => {},
setFooterCraftState: () => {},
@@ -229,6 +254,52 @@ export function uniqueSlug(base: string, existingSlugs: string[]): string {
return `${base}-${i}`;
}
/**
* 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`.
*
* Assumes at most one page enters with slug `'index'` (true for any array
* that already satisfied the invariant before the reorder that produced this
* input) -- exactly the case both callers hand it.
*/
export function applyLandingInvariant(pages: PageData[]): PageData[] {
if (pages.length === 0) return pages;
// Slugs that are FIXED and must not be collided into: 'index' (reserved
// for whoever ends up at index 0) plus every non-landing page's existing
// slug except the demoted page's (it currently holds 'index' and is about
// to be given a new one). Computed upfront, over the WHOLE array, so the
// 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.
const fixedSlugs: string[] = ['index'];
for (let i = 1; i < pages.length; i++) {
if (pages[i].slug !== 'index') fixedSlugs.push(pages[i].slug);
}
return pages.map((page, i) => {
if (i === 0) {
return page.slug === 'index' ? page : { ...page, slug: 'index' };
}
if (page.slug === 'index') {
// Demoted landing page -- give it a real, unique slug of its own.
return { ...page, slug: uniqueSlug(slugify(page.name), fixedSlugs) };
}
return page;
});
}
const DEFAULT_PAGE: PageData = {
id: 'home',
name: 'Home',
@@ -409,6 +480,97 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
[loadState],
);
/**
* 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) => {
const isActive = pageId === activePageIdRef.current;
// If the source is the active page, persist its current on-canvas
// state back into `pages` first (same as switchPage/addPage do)
// so the ORIGINAL page isn't left with a stale craftState after
// this. `query.serialize()` below reads the live canvas directly
// rather than waiting on this (React state update timing aside,
// it's simplest to just ask Craft.js for the truth).
if (isActive) {
saveCurrentState();
}
const source = pagesRef.current.find((p) => p.id === pageId);
if (!source) return;
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);
});
}, []);
const renamePage = useCallback((pageId: string, name: string, slug: string) => {
setPages((prev) =>
prev.map((p, i) => {
@@ -556,6 +718,9 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
addPage,
deletePage,
renamePage,
duplicatePage,
movePage,
setLandingPage,
updatePageSeo,
setHeaderCraftState,
setFooterCraftState,