the builder draws the sections it finds
An empty section renders as nothing. That is correct on the public site and useless in an editor: somebody who has just chosen "two columns" sees a blank page and has to take it on trust. So the canvas outlines every band it finds, names it, says its shape, and offers Delete — and outlines the columns inside it, because a one-column band and a two-column band are identical until something is in them, which is exactly when it is too late to notice the wrong choice. The site provides three attributes and no behaviour: data-page-empty, data-section-id, data-column. Markers rather than classes, so they cannot be mistaken for styling, cannot be purged by a build that has never heard of this file, and read as what they are. That is the whole contract — one attribute on the public site buys the entire editor. Section names and layouts are FETCHED, not read out of the DOM. The markup says which row a band came from and nothing else; inferring the layout from its classes would be the admin reading the site's stylesheet, which is the coupling all of this exists to avoid. ── The race, and why the fix is not another ref ── Creating a section starts two things that do not finish in a fixed order: the frame fetches a new document, and the panel fetches the sections to draw on it. Painting only on load meant painting with whatever had arrived by then, which after a create was usually the data from before it existed. So SitePreview re-runs the injection when EITHER moves — `loaded` is state, `onDocument` is a dependency, and whichever arrives second triggers the paint that counts. The caller then controls repainting through its own useCallback dependencies, which is what those are for. The handlers still go through a ref, and the data does not. Callbacks are rebuilt every render and would repaint on every one; the sections are the thing being drawn and should be exactly what decides when to redraw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+30
-17
@@ -66,6 +66,12 @@ export const SitePreview = ({
|
|||||||
*
|
*
|
||||||
* Return a cleanup and it runs before the next load and on unmount, so nothing injected outlives the
|
* Return a cleanup and it runs before the next load and on unmount, so nothing injected outlives the
|
||||||
* document it was injected into.
|
* document it was injected into.
|
||||||
|
*
|
||||||
|
* ── Give it a stable identity, and change that identity to REPAINT ──
|
||||||
|
*
|
||||||
|
* It is re-run whenever it changes, so a `useCallback` whose dependencies are the data being drawn is
|
||||||
|
* exactly right: new data, new function, redrawn. An inline arrow is a new function every render and
|
||||||
|
* would reinject on every one.
|
||||||
*/
|
*/
|
||||||
onDocument?: (document: Document) => void | (() => void);
|
onDocument?: (document: Document) => void | (() => void);
|
||||||
/**
|
/**
|
||||||
@@ -167,26 +173,28 @@ export const SitePreview = ({
|
|||||||
}, [applyTheme, ready]);
|
}, [applyTheme, ready]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Hand the loaded document to whoever asked for it.
|
Hand the loaded document to whoever asked for it — on load, and again whenever they change.
|
||||||
|
|
||||||
Through a ref so a caller's inline arrow — which is a new function every render — does not re-run
|
Two things have to happen and they race: the frame fetches a new document, and the caller fetches
|
||||||
the injection on every render. Same trap as `useClient`, one level along.
|
whatever it means to draw on it. Neither reliably finishes first. Running the injection ONLY on load
|
||||||
|
meant painting with whatever data had arrived by then, which after creating a section was usually
|
||||||
|
the data from before it existed.
|
||||||
|
|
||||||
|
So `loaded` is state rather than a callback: the effect below depends on both it and `onDocument`,
|
||||||
|
and runs again when either moves. Whichever arrives second triggers the paint that counts.
|
||||||
*/
|
*/
|
||||||
const onDocumentRef = useRef(onDocument);
|
const [loaded, setLoaded] = useState(0);
|
||||||
onDocumentRef.current = onDocument;
|
|
||||||
const cleanupRef = useRef<(() => void) | void>(undefined);
|
|
||||||
|
|
||||||
const handOverDocument = useCallback(() => {
|
|
||||||
// Whatever was injected into the PREVIOUS document goes first. A reload replaces the document, so
|
|
||||||
// a cleanup that runs after the fact is cleaning something that no longer exists.
|
|
||||||
cleanupRef.current?.();
|
|
||||||
cleanupRef.current = undefined;
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded) return;
|
||||||
const frameDocument = frame.current?.contentDocument;
|
const frameDocument = frame.current?.contentDocument;
|
||||||
if (frameDocument) cleanupRef.current = onDocumentRef.current?.(frameDocument);
|
if (!frameDocument) return;
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => () => cleanupRef.current?.(), []);
|
const cleanup = onDocument?.(frameDocument);
|
||||||
|
// Runs before the next paint and on unmount, so nothing injected outlives its document — and two
|
||||||
|
// paints never leave two sets of controls.
|
||||||
|
return () => cleanup?.();
|
||||||
|
}, [loaded, onDocument]);
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
!port ? 'No development server is configured — set its port under Administration.'
|
!port ? 'No development server is configured — set its port under Administration.'
|
||||||
@@ -236,12 +244,17 @@ export const SitePreview = ({
|
|||||||
// The key is what makes `reloadToken` reload: a new key is a new element, so the browser
|
// The key is what makes `reloadToken` reload: a new key is a new element, so the browser
|
||||||
// fetches the document again and `onLoad` runs, which re-injects the builder's chrome.
|
// fetches the document again and `onLoad` runs, which re-injects the builder's chrome.
|
||||||
key={reloadToken ?? 0}
|
key={reloadToken ?? 0}
|
||||||
ref={frame}
|
// A remount is a NEW document, and the effect above must not paint into the old one on
|
||||||
|
// the strength of a `loaded` that referred to it.
|
||||||
|
ref={(node) => {
|
||||||
|
frame.current = node;
|
||||||
|
if (!node) setLoaded(0);
|
||||||
|
}}
|
||||||
// Applied on load as well as on change: the document is replaced on a reload, and the
|
// Applied on load as well as on change: the document is replaced on a reload, and the
|
||||||
// properties go with it.
|
// properties go with it.
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
applyTheme();
|
applyTheme();
|
||||||
handOverDocument();
|
setLoaded((n) => n + 1);
|
||||||
}}
|
}}
|
||||||
src={`/api/dev-proxy/${port}/${path}`}
|
src={`/api/dev-proxy/${port}/${path}`}
|
||||||
title={`The site at /${path}`}
|
title={`The site at /${path}`}
|
||||||
|
|||||||
+151
-30
@@ -9,40 +9,58 @@
|
|||||||
*
|
*
|
||||||
* It is possible because the preview frame is SAME ORIGIN — it comes through the platform's dev proxy
|
* It is possible because the preview frame is SAME ORIGIN — it comes through the platform's dev proxy
|
||||||
* on Officer's own host — so `iframe.contentDocument` is readable and writable. That single fact is
|
* on Officer's own host — so `iframe.contentDocument` is readable and writable. That single fact is
|
||||||
* what the whole page builder rests on, and it is why swapping this plugin's hand-rolled cross-origin
|
* what the whole page builder rests on.
|
||||||
* proxy for the platform's mattered beyond the bugs it fixed.
|
*
|
||||||
|
* ── What the SITE has to provide, and it is only this ──
|
||||||
|
*
|
||||||
|
* data-page-empty the placeholder shown when a page has no sections
|
||||||
|
* data-section-id which row a band came from
|
||||||
|
* data-column a column within a band
|
||||||
|
*
|
||||||
|
* Three attributes and no behaviour. Markers rather than classes, so they cannot be mistaken for
|
||||||
|
* styling, cannot be purged by a build that has never heard of this file, and read as what they are:
|
||||||
|
* handles for something else.
|
||||||
*
|
*
|
||||||
* ── Plain DOM, not React ──
|
* ── Plain DOM, not React ──
|
||||||
*
|
*
|
||||||
* React could portal into another document, and it would mean running a reconciler against a document
|
* React could portal into another document, and it would mean running a reconciler against a document
|
||||||
* that reloads under it whenever the dev server rebuilds. The controls here are a handful of elements
|
* that reloads under it whenever the dev server rebuilds. These are a handful of elements with click
|
||||||
* with click handlers; the cost of building them by hand is a few lines, and the benefit is that a
|
* handlers; building them by hand costs a few lines, and a reload becomes a document that no longer
|
||||||
* reload is just a document that no longer exists rather than a tree React still thinks it owns.
|
* exists rather than a tree React still thinks it owns.
|
||||||
*
|
*
|
||||||
* ── Styles are inline, on purpose ──
|
* ── Styles are inline, on purpose ──
|
||||||
*
|
*
|
||||||
* The frame has the SITE's stylesheet, which is Tailwind built by scanning the site's own templates.
|
* The frame has the SITE's stylesheet, which is Tailwind built by scanning the site's own templates.
|
||||||
* A class this file invents was never scanned and does not exist there, so it would silently do
|
* A class this file invents was never scanned and does not exist there, so it would silently do
|
||||||
* nothing. Inline styles cannot be purged by a build that has never heard of this file.
|
* nothing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Everything injected carries this, so a re-injection can find and remove what the last one left. */
|
/** Everything injected carries this, so a re-injection can find and remove what the last one left. */
|
||||||
const MARK = 'data-fofos-admin-canvas';
|
const MARK = 'data-fofos-admin-canvas';
|
||||||
|
|
||||||
|
/** The editor's own colour. Deliberately not the site's — this is chrome, and it should not blend in. */
|
||||||
|
const ACCENT = '#3b82f6';
|
||||||
|
|
||||||
|
export type CanvasSection = { id: number; name: string; layout: 'one' | 'two' };
|
||||||
|
|
||||||
export type Canvas = {
|
export type Canvas = {
|
||||||
/** Somebody pressed Add Section. */
|
sections: CanvasSection[];
|
||||||
onAddSection: () => void;
|
onAddSection: () => void;
|
||||||
|
onDeleteSection: (section: CanvasSection) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the page's content goes.
|
* Where the page's content goes.
|
||||||
*
|
*
|
||||||
* `<main>` because it is what the site's Layout renders and what the word means — the page minus the
|
* `<main>` because it is what the site's Layout renders and what the word means — the page minus the
|
||||||
* furniture around it. Not a class or a data attribute, which would be a contract the SITE has to keep,
|
* furniture around it.
|
||||||
* and the point of this file is that the site does not have to know it exists.
|
|
||||||
*/
|
*/
|
||||||
const contentRoot = (document: Document): HTMLElement | null => document.querySelector('main');
|
const contentRoot = (document: Document): HTMLElement | null => document.querySelector('main');
|
||||||
|
|
||||||
|
const style = (element: HTMLElement, rules: string[]) => {
|
||||||
|
element.style.cssText = rules.join(';');
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draw the builder's controls into a rendered page.
|
* Draw the builder's controls into a rendered page.
|
||||||
*
|
*
|
||||||
@@ -54,58 +72,161 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
|
|||||||
if (!root) return () => {};
|
if (!root) return () => {};
|
||||||
|
|
||||||
// Anything a previous injection left. Cheap insurance: the cleanup should have run, and if a reload
|
// Anything a previous injection left. Cheap insurance: the cleanup should have run, and if a reload
|
||||||
// raced it, a second Add Section button is exactly the sort of thing nobody reports as a bug.
|
// raced it, a second Add section button is exactly the sort of thing nobody reports as a bug.
|
||||||
root.querySelectorAll(`[${MARK}]`).forEach((node) => node.remove());
|
root.querySelectorAll(`[${MARK}]`).forEach((node) => node.remove());
|
||||||
|
|
||||||
|
/** Everything to undo, in the order it was done. */
|
||||||
|
const undo: (() => void)[] = [];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The site's EMPTY-PAGE placeholder, hidden while the builder draws its own controls in that space.
|
The site's empty-page placeholder, hidden while the builder draws its own controls in that space.
|
||||||
|
|
||||||
Found by its marker rather than by position. It was `firstElementChild`, which was right for exactly
|
Found by its marker rather than by position. Hidden rather than removed, so the cleanup puts it
|
||||||
as long as an empty page was the only page — the moment a page has a section, the first child IS
|
back — a page whose last section is deleted shows it again without a reload.
|
||||||
that section and hiding it would hide the thing being edited.
|
|
||||||
|
|
||||||
Hidden rather than removed, so the cleanup can simply put it back. A page whose last section is
|
|
||||||
deleted then shows it again without a reload.
|
|
||||||
*/
|
*/
|
||||||
const placeholder = root.querySelector<HTMLElement>('[data-page-empty]');
|
const placeholder = root.querySelector<HTMLElement>('[data-page-empty]');
|
||||||
if (placeholder) placeholder.style.display = 'none';
|
if (placeholder) {
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
undo.push(() => {
|
||||||
|
placeholder.style.display = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Every section, outlined and named.
|
||||||
|
|
||||||
|
This is the whole reason the decoration exists: an EMPTY section renders as nothing, which is
|
||||||
|
correct on the public site and useless in an editor. Somebody who has just chosen "two columns"
|
||||||
|
needs to see two columns, not a blank page they have to take on trust.
|
||||||
|
*/
|
||||||
|
for (const section of canvas.sections) {
|
||||||
|
const element = root.querySelector<HTMLElement>(`[data-section-id="${section.id}"]`);
|
||||||
|
if (!element) continue;
|
||||||
|
|
||||||
|
const previousPosition = element.style.position;
|
||||||
|
const previousOutline = element.style.outline;
|
||||||
|
const previousOffset = element.style.outlineOffset;
|
||||||
|
const previousMargin = element.style.margin;
|
||||||
|
|
||||||
|
// `relative` so the label and the controls can be placed against this band rather than the page.
|
||||||
|
element.style.position = 'relative';
|
||||||
|
element.style.outline = `1px solid ${ACCENT}`;
|
||||||
|
element.style.outlineOffset = '4px';
|
||||||
|
// Room for the outline and the label above it, so two bands in a row do not touch.
|
||||||
|
element.style.margin = '2.5rem 0';
|
||||||
|
|
||||||
|
undo.push(() => {
|
||||||
|
element.style.position = previousPosition;
|
||||||
|
element.style.outline = previousOutline;
|
||||||
|
element.style.outlineOffset = previousOffset;
|
||||||
|
element.style.margin = previousMargin;
|
||||||
|
});
|
||||||
|
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.setAttribute(MARK, 'section-bar');
|
||||||
|
style(bar, [
|
||||||
|
'position:absolute',
|
||||||
|
'top:-1.75rem',
|
||||||
|
'left:0',
|
||||||
|
'right:0',
|
||||||
|
'display:flex',
|
||||||
|
'align-items:center',
|
||||||
|
'gap:0.5rem',
|
||||||
|
'font-size:0.6875rem',
|
||||||
|
`color:${ACCENT}`,
|
||||||
|
// The page's own font would make this look like content. It is not content.
|
||||||
|
'font-family:ui-sans-serif, system-ui, sans-serif',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = section.name;
|
||||||
|
style(label, ['font-weight:600']);
|
||||||
|
|
||||||
|
const shape = document.createElement('span');
|
||||||
|
shape.textContent = section.layout === 'two' ? 'two columns' : 'one column';
|
||||||
|
style(shape, ['opacity:0.7']);
|
||||||
|
|
||||||
|
const remove = document.createElement('button');
|
||||||
|
remove.type = 'button';
|
||||||
|
remove.textContent = 'Delete';
|
||||||
|
style(remove, [
|
||||||
|
'margin-left:auto',
|
||||||
|
'cursor:pointer',
|
||||||
|
'background:none',
|
||||||
|
'border:0',
|
||||||
|
'padding:0',
|
||||||
|
'font:inherit',
|
||||||
|
'color:inherit',
|
||||||
|
'opacity:0.7',
|
||||||
|
'text-decoration:underline',
|
||||||
|
]);
|
||||||
|
const onRemove = () => canvas.onDeleteSection(section);
|
||||||
|
remove.addEventListener('click', onRemove);
|
||||||
|
undo.push(() => remove.removeEventListener('click', onRemove));
|
||||||
|
|
||||||
|
bar.append(label, shape, remove);
|
||||||
|
element.append(bar);
|
||||||
|
undo.push(() => bar.remove());
|
||||||
|
|
||||||
|
/*
|
||||||
|
The columns, outlined too.
|
||||||
|
|
||||||
|
An empty column has height and nothing else, so a two-column band and a one-column band look
|
||||||
|
identical until something is in them — which is exactly when it is too late to notice you chose
|
||||||
|
the wrong one.
|
||||||
|
*/
|
||||||
|
element.querySelectorAll<HTMLElement>('[data-column]').forEach((column) => {
|
||||||
|
const was = column.style.outline;
|
||||||
|
const wasOffset = column.style.outlineOffset;
|
||||||
|
column.style.outline = `1px dashed color-mix(in srgb, ${ACCENT} 45%, transparent)`;
|
||||||
|
column.style.outlineOffset = '-4px';
|
||||||
|
undo.push(() => {
|
||||||
|
column.style.outline = was;
|
||||||
|
column.style.outlineOffset = wasOffset;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add section, always last ─────────────────────────────────────────────────────────────────
|
||||||
const zone = document.createElement('div');
|
const zone = document.createElement('div');
|
||||||
zone.setAttribute(MARK, 'add-section');
|
zone.setAttribute(MARK, 'add-section');
|
||||||
zone.style.cssText = [
|
style(zone, [
|
||||||
'display:flex',
|
'display:flex',
|
||||||
'align-items:center',
|
'align-items:center',
|
||||||
'justify-content:center',
|
'justify-content:center',
|
||||||
'min-height:40vh',
|
// Tall when the page is empty, modest once there is something to add to — the zone should not
|
||||||
'margin:2rem 0',
|
// dwarf the content it sits under.
|
||||||
// A dashed outline reads as "a space waiting for something" in every editor anybody has used.
|
canvas.sections.length ? 'min-height:8rem' : 'min-height:40vh',
|
||||||
|
'margin:2.5rem 0',
|
||||||
'border:2px dashed color-mix(in srgb, currentColor 25%, transparent)',
|
'border:2px dashed color-mix(in srgb, currentColor 25%, transparent)',
|
||||||
'border-radius:0.75rem',
|
'border-radius:0.75rem',
|
||||||
].join(';');
|
]);
|
||||||
|
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.textContent = '+ Add section';
|
button.textContent = '+ Add section';
|
||||||
button.style.cssText = [
|
style(button, [
|
||||||
'cursor:pointer',
|
'cursor:pointer',
|
||||||
'padding:0.75rem 1.5rem',
|
'padding:0.75rem 1.5rem',
|
||||||
'border-radius:0.5rem',
|
'border-radius:0.5rem',
|
||||||
// The page's own colours, inverted — so the control belongs to whatever theme the site is wearing
|
// The page's own colours, inverted, so the control belongs to whatever theme the site is wearing.
|
||||||
// rather than being a grey box sitting on it.
|
|
||||||
'background:var(--site-fg, #000)',
|
'background:var(--site-fg, #000)',
|
||||||
'color:var(--site-bg, #fff)',
|
'color:var(--site-bg, #fff)',
|
||||||
'border:0',
|
'border:0',
|
||||||
'font:inherit',
|
'font:inherit',
|
||||||
'font-size:0.875rem',
|
'font-size:0.875rem',
|
||||||
].join(';');
|
]);
|
||||||
button.addEventListener('click', canvas.onAddSection);
|
button.addEventListener('click', canvas.onAddSection);
|
||||||
|
|
||||||
zone.append(button);
|
zone.append(button);
|
||||||
root.append(zone);
|
root.append(zone);
|
||||||
|
undo.push(() => {
|
||||||
return () => {
|
|
||||||
button.removeEventListener('click', canvas.onAddSection);
|
button.removeEventListener('click', canvas.onAddSection);
|
||||||
zone.remove();
|
zone.remove();
|
||||||
if (placeholder) placeholder.style.display = '';
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Reversed, so a style put back is put back over the value that replaced it rather than under.
|
||||||
|
for (const step of undo.reverse()) step();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { readableError } from '../errors';
|
import { readableError } from '../errors';
|
||||||
import { SitePreview, useDevPort } from '../SitePreview';
|
import { SitePreview, useDevPort } from '../SitePreview';
|
||||||
import { paintCanvas } from '../canvas';
|
import { paintCanvas, type CanvasSection } from '../canvas';
|
||||||
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
|
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
|
||||||
import { usePages, type Page } from '../usePages';
|
import { usePages, type Page } from '../usePages';
|
||||||
|
|
||||||
@@ -34,6 +34,17 @@ export const PageBuilderPanel = () => {
|
|||||||
const { pages, announce } = usePages();
|
const { pages, announce } = usePages();
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [sections, setSections] = useState<CanvasSection[]>([]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
The handlers, through a ref; the DATA, as a dependency.
|
||||||
|
|
||||||
|
`onDocument` is re-run whenever its identity changes, so what it should depend on is the thing being
|
||||||
|
drawn — the sections — and nothing else. The callbacks are ordinary functions rebuilt every render,
|
||||||
|
so putting them in the dependency list would repaint on every render; through a ref they are always
|
||||||
|
the current ones without being part of the identity.
|
||||||
|
*/
|
||||||
|
const handlersRef = useRef<{ add: () => void; remove: (id: number) => void }>({ add: () => {}, remove: () => {} });
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
/*
|
/*
|
||||||
@@ -52,10 +63,34 @@ export const PageBuilderPanel = () => {
|
|||||||
be a new injection each render.
|
be a new injection each render.
|
||||||
*/
|
*/
|
||||||
const onDocument = useCallback(
|
const onDocument = useCallback(
|
||||||
(frameDocument: Document) => paintCanvas(frameDocument, { onAddSection: () => setAdding(true) }),
|
(frameDocument: Document) =>
|
||||||
[],
|
paintCanvas(frameDocument, {
|
||||||
|
sections,
|
||||||
|
onAddSection: () => handlersRef.current.add(),
|
||||||
|
onDeleteSection: (section) => handlersRef.current.remove(section.id),
|
||||||
|
}),
|
||||||
|
[sections],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
The handlers, through a ref; the DATA, as a dependency.
|
||||||
|
|
||||||
|
`onDocument` is re-run whenever its identity changes, so what it should depend on is the thing being
|
||||||
|
drawn — the sections — and nothing else. The callbacks are ordinary functions rebuilt every render,
|
||||||
|
so putting them in the dependency list would repaint on every render; through a ref they are always
|
||||||
|
the current ones without being part of the identity.
|
||||||
|
*/
|
||||||
|
const deleteSection = async (sectionId: number) => {
|
||||||
|
if (!page) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await clientRef.current.delete(`/fofos-admin/pages/${page.id}/sections/${sectionId}`);
|
||||||
|
setRendered((n) => n + 1);
|
||||||
|
} catch (failure) {
|
||||||
|
setError(readableError(failure));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createSection = async (values: { name: string; layout: SectionLayout }) => {
|
const createSection = async (values: { name: string; layout: SectionLayout }) => {
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
@@ -89,6 +124,25 @@ export const PageBuilderPanel = () => {
|
|||||||
|
|
||||||
const page = pages?.find((row) => row.id === id) ?? null;
|
const page = pages?.find((row) => row.id === id) ?? null;
|
||||||
|
|
||||||
|
/*
|
||||||
|
The page's sections, which the canvas needs to name and outline what it finds in the document.
|
||||||
|
|
||||||
|
Fetched here rather than read out of the DOM. The markup says which id a band came from and nothing
|
||||||
|
else — no name, no layout — and inferring the layout from the classes would be the admin reading the
|
||||||
|
site's stylesheet, which is exactly the coupling all of this avoids.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!page) return;
|
||||||
|
void clientRef.current
|
||||||
|
.get<{ data: CanvasSection[] }>(`/fofos-admin/pages/${page.id}/sections`)
|
||||||
|
.then((response) => setSections(response.data))
|
||||||
|
.catch((failure: unknown) => setError(readableError(failure)));
|
||||||
|
}, [page, rendered]);
|
||||||
|
|
||||||
|
// Assigned on every render, after both functions above exist, so the ref always holds the current
|
||||||
|
// pair without either being part of `onDocument`'s identity.
|
||||||
|
handlersRef.current = { add: () => setAdding(true), remove: (sectionId: number) => void deleteSection(sectionId) };
|
||||||
|
|
||||||
if (error) return <div className="p-4 text-xs text-destructive">{error}</div>;
|
if (error) return <div className="p-4 text-xs text-destructive">{error}</div>;
|
||||||
if (!pages) return <div className="p-4 text-xs text-muted-foreground">Loading…</div>;
|
if (!pages) return <div className="p-4 text-xs text-muted-foreground">Loading…</div>;
|
||||||
|
|
||||||
@@ -107,6 +161,8 @@ export const PageBuilderPanel = () => {
|
|||||||
|
|
||||||
{/* The real page, at its own address, with the builder's own controls drawn inside it. `path`
|
{/* The real page, at its own address, with the builder's own controls drawn inside it. `path`
|
||||||
has no leading slash — Home's is the empty string, which the proxy resolves to the root. */}
|
has no leading slash — Home's is the empty string, which the proxy resolves to the root. */}
|
||||||
|
{/* `reloadToken` moves on every write, which refetches the document AND re-runs `onDocument` —
|
||||||
|
so the decoration is redrawn against the sections that now exist. */}
|
||||||
<SitePreview port={devPort} path={page.slug} onDocument={onDocument} reloadToken={rendered} />
|
<SitePreview port={devPort} path={page.slug} onDocument={onDocument} reloadToken={rendered} />
|
||||||
|
|
||||||
<AddSectionDialog
|
<AddSectionDialog
|
||||||
|
|||||||
Reference in New Issue
Block a user