Files
site-builder/craft/src/hooks/useWhpApi.ts
T

327 lines
12 KiB
TypeScript
Raw Normal View History

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';
import { PageData } from '../types';
export interface BuildSavePayloadInput {
siteId: number;
siteName: string;
/** query.serialize() of whatever is currently on the live canvas. */
liveCraftState: string;
pages: PageData[];
headerPage: PageData;
footerPage: PageData;
activePageId: string;
/** True when the live canvas is showing the header zone (activePageId === '__header__'). */
isEditingHeader: boolean;
/** True when the live canvas is showing the footer zone (activePageId === '__footer__'). */
isEditingFooter: boolean;
}
/**
* Pure payload builder for the save() API call. Extracted so the
* header/footer-vs-page routing logic can be unit-tested without mounting
* React/Craft.js.
*
* Bug this fixes: when the user is editing the Header or Footer,
* `activePageId` is `'__header__'`/`'__footer__'` — which matches no entry
* in `pages`. The live canvas serialization must be routed into
* `header_craft_state`/`footer_craft_state` in that case, NOT into a page
* slot or the top-level `craft_state`/`html` (which must always represent
* an actual page). Conversely, header/footer must be sourced from the FRESH
* live state when that zone is being edited, not from the stale stored
* `headerPage.craftState`/`footerPage.craftState`.
*/
export function buildSavePayload(input: BuildSavePayloadInput) {
const {
siteId,
siteName,
liveCraftState,
pages,
headerPage,
footerPage,
activePageId,
isEditingHeader,
isEditingFooter,
} = input;
const isPageActive = !isEditingHeader && !isEditingFooter;
// I-1 (data-loss): `activePageId` can go dangling -- e.g. the original
// Home page (id 'home') is deleted, its replacement gets a fresh id
// (`page_<ts>`), and a reload re-initializes `activePageId` back to the
// hardcoded default `'home'` (see PageContext's `useState('home')`) before
// `load()` has a chance to point it at the actually-restored page. If a
// real page IS active but matches no entry in `pages`, treat `pages[0]`
// (the landing page) as the active one so the live canvas serialization
// still reaches the index.html page slot / `pages_craft_state[0]` instead
// of only the legacy top-level `craft_state`/`html` fields.
const activePageIndex = isPageActive ? pages.findIndex((p) => p.id === activePageId) : -1;
const effectiveActivePageId = activePageIndex !== -1 ? activePageId : pages[0]?.id;
// Fresh header/footer state: the live canvas wins when that zone is the
// one currently being edited; otherwise fall back to the last-committed
// stored state (updated on zone switch by PageContext's saveCurrentState).
const headerCraftState = isEditingHeader ? liveCraftState : (headerPage.craftState || null);
const footerCraftState = isEditingFooter ? liveCraftState : (footerPage.craftState || null);
let headerHtml = '';
try {
if (headerCraftState) {
headerHtml = exportBodyHtml(headerCraftState).html;
}
} catch (e) {
console.error('Header HTML export failed:', e);
}
let footerHtml = '';
try {
if (footerCraftState) {
footerHtml = exportBodyHtml(footerCraftState).html;
}
} catch (e) {
console.error('Footer HTML export failed:', e);
}
// The top-level `craft_state`/`html` fields (and the matching per-page
// slot below) must always represent an actual PAGE. When editing the
// header/footer, activePageId matches no page — use the landing page's
// own stored state instead of leaking the live header/footer canvas into
// a page slot or mislabeling it as page content.
let currentCraftState: string | null;
let currentHtml = '';
let css = '';
if (isPageActive) {
currentCraftState = liveCraftState;
try {
const result = exportBodyHtml(currentCraftState);
currentHtml = result.html;
css = result.css;
} catch (e) {
console.error('HTML export failed, saving state only:', e);
}
} else {
const landingPage = pages[0] ?? null;
currentCraftState = landingPage?.craftState ?? null;
try {
if (currentCraftState) {
const result = exportBodyHtml(currentCraftState);
currentHtml = result.html;
css = result.css;
}
} catch (e) {
console.error('HTML export failed, saving state only:', e);
}
}
// Build the pages array with HTML for each page. For the active page (only
// possible when a real page is active), use the freshly exported HTML from
// the canvas; for others, export from their stored craft state.
const pagesPayload = pages.map((page, i) => {
// The first page is ALWAYS the landing page → publishes to index.html
// regardless of the page name/slug. Apache serves '/' from index.html,
// and renaming the first page should not break the root URL.
const filename = i === 0 ? 'index.html' : page.slug + '.html';
let pageHtml = '';
if (isPageActive && page.id === effectiveActivePageId) {
// Active page: use the current canvas HTML (already exported above)
pageHtml = currentHtml;
} else if (page.craftState) {
try {
pageHtml = exportBodyHtml(page.craftState).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 (only when a real page is active), always
// use the fresh canvas state since page.craftState may be stale (not
// updated until page switch). When editing header/footer, activePageId
// matches no page, so every page correctly falls back to its own stored
// state below.
const pagesGrapesjs = pages.map((page, i) => ({
id: page.id,
name: page.name,
// Pin the landing page's slug to 'index' on the wire too, so that on
// reload the editor's clean-URL routing (.htaccess rewrite of /name →
// name.html) lines up with the file we just wrote (index.html).
slug: i === 0 ? 'index' : page.slug,
craftState: (isPageActive && page.id === effectiveActivePageId) ? liveCraftState : (page.craftState || null),
}));
return {
site_id: siteId,
name: siteName,
html: currentHtml,
css,
pages: pagesPayload,
header_html: headerHtml,
footer_html: footerHtml,
craft_state: currentCraftState,
header_craft_state: headerCraftState,
footer_craft_state: footerCraftState,
pages_craft_state: pagesGrapesjs,
};
}
export function useWhpApi() {
const { query, actions } = useEditor();
const { whpConfig, isWHP } = useEditorConfig();
const {
pages,
headerPage,
footerPage,
activePageId,
isEditingHeader,
isEditingFooter,
setHeaderCraftState,
setFooterCraftState,
setPagesCraftState,
setActivePageIdDirect,
} = usePages();
const save = useCallback(async () => {
if (!isWHP || !whpConfig) return null;
// Serialize whatever is currently on the live canvas (a page, the
// header, or the footer — depending on activePageId/isEditingHeader/
// isEditingFooter).
const liveCraftState = query.serialize();
const payload = buildSavePayload({
siteId: whpConfig.siteId,
siteName: whpConfig.siteName,
liveCraftState,
pages,
headerPage,
footerPage,
activePageId,
isEditingHeader,
isEditingFooter,
});
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, isEditingHeader, isEditingFooter]);
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);
}
}
// I-1 (data-loss): point activePageId at the page we just loaded
// into the canvas. Without this, activePageId stays at whatever it
// was initialized to (the hardcoded default 'home'), which goes
// dangling the moment the original Home page has been deleted and
// replaced (its replacement gets a fresh `page_<ts>` id) -- the next
// edit+save would then only reach the legacy top-level fields
// instead of the actual page slot. Only do this when a real page is
// being loaded, i.e. we're not currently mid-edit of the header/
// footer zone (switching zones is handled separately by switchPage).
if (!isEditingHeader && !isEditingFooter) {
setActivePageIdDirect(firstPage.id);
}
}
}
return data;
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter]);
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 };
}