A Spacing handle on the section bar and in the component tools, opening an editor below the preview — the same place the text editor goes, and for the same reason: a dialog covers the thing being spaced. The device toggle picks the breakpoint. Two axes across three widths is six controls, five of them for a width you are not looking at; this way you switch to Mobile, see the phone, and change the padding you can watch changing. The site's GUTTER is the exception and shows all three at once, because it is one axis and one value and it is a progression judged as a whole. Inherit and 0 stay separate options, and the select names the value it is inheriting rather than leaving it to be worked out. Written IMMEDIATELY rather than drafted. A step is one choice from a short list and the effect is on screen before the menu closes; a Save for it would be a second thing to remember on a panel whose other verbs already act. Spacing sits before Delete in the section bar, ordered by how often each is wanted rather than how they read — the destructive one furthest from where the hand arrives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
454 lines
18 KiB
TypeScript
454 lines
18 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useSearchParams } from 'react-router';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { readableError } from '../errors';
|
|
import { SitePreview, useDevPort } from '../SitePreview';
|
|
import { usePreviewable, useViewMode } from '../ViewMode';
|
|
import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas';
|
|
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
|
|
import { FreeTextEditor } from './FreeTextEditor';
|
|
import { SpacingEditor, type Padding } from './SpacingEditor';
|
|
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
|
|
import { MediaPicker } from '../MediaPicker';
|
|
import { pageFromParam, usePages, type Page } from '../usePages';
|
|
|
|
/**
|
|
* One page, being built.
|
|
*
|
|
* ── Why this is a different panel from Pages ──
|
|
*
|
|
* Pages is the LIST: what exists, what it is called, where it lives, in what order. This is one PAGE:
|
|
* what is on it. They are different jobs and the nav already says so — Pages is a section and the pages
|
|
* are rows underneath it — so the detail panel follows the same shape rather than showing a list with
|
|
* one row highlighted.
|
|
*
|
|
* ── It renders the real page ──
|
|
*
|
|
* The preview is the site's own page at its own address, through the platform's dev proxy. Whatever
|
|
* the blocks end up being, this is what they will be judged against, and it is already true today.
|
|
*/
|
|
export const PageBuilderPanel = () => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [params] = useSearchParams();
|
|
const slug = params.get('page');
|
|
|
|
const devPort = useDevPort();
|
|
const { pages, announce } = usePages();
|
|
|
|
// This section has a preview, so the bar offers Edit/View and the three widths — the same opt-in
|
|
// Layout makes. Pages, the list, does not: there is nothing there to view at tablet width.
|
|
usePreviewable();
|
|
const { mode, device } = useViewMode();
|
|
|
|
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;
|
|
addComponent: (section: CanvasSection, column: number) => void;
|
|
editComponent: (component: CanvasComponent) => void;
|
|
removeComponent: (component: CanvasComponent) => void;
|
|
spaceSection: (section: CanvasSection) => void;
|
|
spaceComponent: (component: CanvasComponent) => void;
|
|
}>({
|
|
add: () => {},
|
|
remove: () => {},
|
|
addComponent: () => {},
|
|
editComponent: () => {},
|
|
removeComponent: () => {},
|
|
spaceSection: () => {},
|
|
spaceComponent: () => {},
|
|
});
|
|
const [adding, setAdding] = useState(false);
|
|
const [creating, setCreating] = useState(false);
|
|
/** Where a component is about to be added, while the kind is being chosen. */
|
|
const [choosing, setChoosing] = useState<{ sectionId: number; column: number } | null>(null);
|
|
/** The component being written, and where a new one would go. Null when the dialog is shut. */
|
|
const [editing, setEditing] = useState<{ id: number | null; sectionId: number; column: number; html: string } | null>(
|
|
null,
|
|
);
|
|
/** Choosing the file for a video — new when `id` is null, otherwise replacing an existing one. */
|
|
const [picking, setPicking] = useState<{ id: number | null; sectionId: number; column: number } | null>(null);
|
|
/** Whose spacing is being changed. A section or one component, never both. */
|
|
const [spacing, setSpacing] = useState<
|
|
{ kind: 'section' | 'component'; id: number; sectionId: number; label: string; padding: Padding } | null
|
|
>(null);
|
|
/** The scale the API publishes, so this file holds no copy of it. */
|
|
const [steps, setSteps] = useState<number[]>([]);
|
|
/*
|
|
Bumped after a write, and passed to SitePreview to remount the frame.
|
|
|
|
The page is rendered by the SITE, from the database, so a new section only appears when the document
|
|
is fetched again. Nothing else can show it: the admin does not know how to draw a section, and if it
|
|
did it would be drawing a second implementation of the page.
|
|
*/
|
|
const [rendered, setRendered] = useState(0);
|
|
|
|
/*
|
|
The builder's chrome, painted into the rendered page.
|
|
|
|
Stable, because SitePreview holds it and re-runs it on every load. A new function each render would
|
|
be a new injection each render.
|
|
*/
|
|
/*
|
|
In VIEW, nothing is painted at all.
|
|
|
|
Not the chrome dimmed or the buttons hidden — no injection, so the frame holds exactly the document
|
|
the site served. That is what View has to mean here: outlines, labels and a drop zone are the
|
|
editor's, and a preview that keeps any of them is not showing you the page.
|
|
|
|
The check is INSIDE the callback and `mode` is a dependency, so switching to View hands SitePreview
|
|
a new function: its effect runs the previous paint's cleanup and then paints nothing. That is what
|
|
takes the chrome away, with no reload and no second code path.
|
|
*/
|
|
/*
|
|
The frame's own document, kept between paints.
|
|
|
|
It is how a draft reaches the page: the editor writes each change into the component's node rather
|
|
than waiting for a save and a reload. Held in a ref because it is not state — nothing re-renders
|
|
when the document changes, and the effect below reads whatever is current.
|
|
*/
|
|
const frameDoc = useRef<Document | null>(null);
|
|
|
|
const onDocument = useCallback(
|
|
(frameDocument: Document) => {
|
|
if (mode === 'view') return;
|
|
frameDoc.current = frameDocument;
|
|
return paintCanvas(frameDocument, {
|
|
sections,
|
|
onAddSection: () => handlersRef.current.add(),
|
|
onDeleteSection: (section) => handlersRef.current.remove(section.id),
|
|
onAddComponent: (section, column) => handlersRef.current.addComponent(section, column),
|
|
onEditComponent: (component) => handlersRef.current.editComponent(component),
|
|
onDeleteComponent: (component) => handlersRef.current.removeComponent(component),
|
|
onSpaceSection: (section) => handlersRef.current.spaceSection(section),
|
|
onSpaceComponent: (component) => handlersRef.current.spaceComponent(component),
|
|
});
|
|
},
|
|
// `mode` is in here so switching to View gives SitePreview a NEW function, which runs the previous
|
|
// paint's cleanup and then paints nothing. That is what removes the chrome without a reload.
|
|
[sections, mode],
|
|
);
|
|
|
|
/*
|
|
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 }) => {
|
|
if (!page) return;
|
|
setCreating(true);
|
|
setError(null);
|
|
try {
|
|
await clientRef.current.post(`/fofos-admin/pages/${page.id}/sections`, values);
|
|
setAdding(false);
|
|
// The site has to re-render for the new band to exist. See `rendered` above.
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
/*
|
|
The list, if nobody has announced it yet.
|
|
|
|
Landing here directly — a bookmark, a reload, a URL typed with `?page=4` — means Pages has never
|
|
rendered and the channel is empty. So this fetches once and announces, which also fills the nav.
|
|
Arriving from the nav, `pages` is already there and nothing is requested.
|
|
*/
|
|
useEffect(() => {
|
|
if (pages) return;
|
|
void clientRef.current
|
|
.get<{ data: Page[] }>('/fofos-admin/pages')
|
|
.then((response) => announce(response.data))
|
|
.catch((failure: unknown) => setError(readableError(failure)));
|
|
}, [pages, announce]);
|
|
|
|
const page = pageFromParam(pages, slug);
|
|
|
|
/*
|
|
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[]; steps?: number[] }>(`/fofos-admin/pages/${page.id}/sections`)
|
|
.then((response) => {
|
|
setSections(response.data);
|
|
if (response.steps) setSteps(response.steps);
|
|
})
|
|
.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.
|
|
/*
|
|
Add component asks WHAT, even though only one kind can be chosen today.
|
|
|
|
It opened the text editor straight away, on the reasoning that one option is not a choice. That
|
|
optimised the state of the app rather than its shape: somebody adding a component could not see what
|
|
a component is or that there will be others, and the day the second kind lands the interaction
|
|
changes underneath them. The picker names the ones that are coming, the way the nav does.
|
|
*/
|
|
const addComponent = (section: CanvasSection, column: number) =>
|
|
setChoosing({ sectionId: section.id, column });
|
|
|
|
const chooseKind = (kind: ComponentKind) => {
|
|
if (!choosing) return;
|
|
setChoosing(null);
|
|
// Each kind opens its own editor. A kind with no case here cannot be created, which is the point.
|
|
if (kind === 'free-text') setEditing({ id: null, ...choosing, html: '' });
|
|
if (kind === 'video') setPicking({ id: null, ...choosing });
|
|
};
|
|
|
|
/*
|
|
A video is a PATH, saved as soon as it is chosen.
|
|
|
|
Free text needs a dialog because writing is a thing people do halfway; picking a file is one act
|
|
with one outcome, so there is nothing to draft and nothing to cancel out of afterwards.
|
|
*/
|
|
const saveVideo = async (url: string) => {
|
|
if (!page || !picking) return;
|
|
const base = `/fofos-admin/pages/${page.id}/sections/${picking.sectionId}/components`;
|
|
setPicking(null);
|
|
setError(null);
|
|
try {
|
|
if (picking.id === null) {
|
|
await clientRef.current.post(base, { kind: 'video', column: picking.column, content: { url } });
|
|
} else {
|
|
await clientRef.current.patch(`${base}/${picking.id}`, { content: { url } });
|
|
}
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
const editComponent = (component: CanvasComponent) => {
|
|
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
|
|
if (!owner) return;
|
|
|
|
// Edit means whatever editing THIS kind means. The canvas draws one button; which door it opens is
|
|
// decided here, where the kinds are known.
|
|
if (component.kind === 'video') {
|
|
setPicking({ id: component.id, sectionId: owner.id, column: component.column });
|
|
return;
|
|
}
|
|
|
|
const stored = owner.components.find((one) => one.id === component.id);
|
|
const html = typeof stored?.content?.html === 'string' ? stored.content.html : '';
|
|
setEditing({ id: component.id, sectionId: owner.id, column: component.column, html });
|
|
};
|
|
|
|
/*
|
|
The draft, written into the component's node as it is typed.
|
|
|
|
The node's own children are replaced and the injected tools put back — those are the admin's and
|
|
live inside the component, so an innerHTML that forgot them would take the Edit button away
|
|
mid-edit.
|
|
|
|
Not sanitised here. The server cleans on write, so what is on screen while typing can be marginally
|
|
more permissive than what is stored — and the difference is visible the moment it is saved, which
|
|
is the right place to find out.
|
|
*/
|
|
useEffect(() => {
|
|
const doc = frameDoc.current;
|
|
if (!doc || !editing?.id) return;
|
|
|
|
const node = doc.querySelector<HTMLElement>(`[data-fofos-component="${editing.id}"]`);
|
|
if (!node) return;
|
|
|
|
const tools = node.querySelector('[data-fofos-admin-canvas="component-tools"]');
|
|
node.innerHTML = editing.html;
|
|
if (tools) node.append(tools);
|
|
}, [editing?.id, editing?.html]);
|
|
|
|
const saveText = async () => {
|
|
if (!page || !editing) return;
|
|
const html = editing.html;
|
|
setCreating(true);
|
|
setError(null);
|
|
const base = `/fofos-admin/pages/${page.id}/sections/${editing.sectionId}/components`;
|
|
try {
|
|
if (editing.id === null) {
|
|
await clientRef.current.post(base, { kind: 'free-text', column: editing.column, content: { html } });
|
|
} else {
|
|
await clientRef.current.patch(`${base}/${editing.id}`, { content: { html } });
|
|
}
|
|
setEditing(null);
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const deleteComponent = async (component: CanvasComponent) => {
|
|
if (!page) return;
|
|
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
|
|
if (!owner) return;
|
|
setError(null);
|
|
try {
|
|
await clientRef.current.delete(`/fofos-admin/pages/${page.id}/sections/${owner.id}/components/${component.id}`);
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
/*
|
|
Spacing is written IMMEDIATELY, like adding and deleting.
|
|
|
|
There is no draft to lose: a step is one choice from a short list, and the effect is on screen before
|
|
the menu closes. A Save for it would mean a second thing to remember on a panel whose other verbs
|
|
already act.
|
|
*/
|
|
const saveSpacing = async (padding: Padding) => {
|
|
if (!page || !spacing) return;
|
|
setSpacing({ ...spacing, padding });
|
|
const base = `/fofos-admin/pages/${page.id}/sections/${spacing.sectionId}`;
|
|
const url = spacing.kind === 'section' ? base : `${base}/components/${spacing.id}`;
|
|
try {
|
|
await clientRef.current.patch(url, { padding });
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
const spaceSection = (section: CanvasSection) =>
|
|
setSpacing({
|
|
kind: 'section',
|
|
id: section.id,
|
|
sectionId: section.id,
|
|
label: section.name,
|
|
padding: (section.padding ?? {}) as Padding,
|
|
});
|
|
|
|
const spaceComponent = (component: CanvasComponent) => {
|
|
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
|
|
if (!owner) return;
|
|
const stored = owner.components.find((one) => one.id === component.id);
|
|
setSpacing({
|
|
kind: 'component',
|
|
id: component.id,
|
|
sectionId: owner.id,
|
|
label: component.kind === 'video' ? 'Video' : 'Text',
|
|
padding: (stored?.padding ?? {}) as Padding,
|
|
});
|
|
};
|
|
|
|
handlersRef.current = {
|
|
add: () => setAdding(true),
|
|
remove: (sectionId: number) => void deleteSection(sectionId),
|
|
addComponent,
|
|
editComponent,
|
|
removeComponent: (component) => void deleteComponent(component),
|
|
spaceSection,
|
|
spaceComponent,
|
|
};
|
|
|
|
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 (!page) {
|
|
// A page that was deleted in another panel, or an id somebody typed. Said plainly rather than
|
|
// redirected: a silent bounce to the list leaves you wondering whether you clicked the wrong thing.
|
|
return <div className="p-4 text-xs text-muted-foreground">That page no longer exists.</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex size-full flex-col gap-3 p-4">
|
|
<div className="flex shrink-0 items-baseline gap-2">
|
|
<h3 className="text-sm font-medium">{page.title}</h3>
|
|
<span className="font-mono text-[10px] text-muted-foreground">{page.path}</span>
|
|
</div>
|
|
|
|
{/* 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. */}
|
|
{/* `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} />
|
|
|
|
<AddSectionDialog
|
|
open={adding}
|
|
busy={creating}
|
|
onCancel={() => setAdding(false)}
|
|
onCreate={(values) => void createSection(values)}
|
|
/>
|
|
|
|
<AddComponentDialog open={choosing !== null} onCancel={() => setChoosing(null)} onChoose={chooseKind} />
|
|
|
|
<MediaPicker
|
|
open={picking !== null}
|
|
onClose={() => setPicking(null)}
|
|
onPick={(path) => void saveVideo(path)}
|
|
title="Choose a video"
|
|
/>
|
|
|
|
{spacing && (
|
|
<SpacingEditor
|
|
label={spacing.label}
|
|
padding={spacing.padding}
|
|
device={device}
|
|
steps={steps}
|
|
busy={false}
|
|
onChange={(padding) => void saveSpacing(padding)}
|
|
onClose={() => setSpacing(null)}
|
|
/>
|
|
)}
|
|
|
|
{editing && (
|
|
<FreeTextEditor
|
|
html={editing.html}
|
|
busy={creating}
|
|
onChange={(html) => setEditing((was) => (was ? { ...was, html } : was))}
|
|
/*
|
|
Cancelling reloads the frame rather than putting the old markup back by hand. The node has
|
|
been written into on every keystroke, and restoring it correctly means remembering what was
|
|
there before the first one — a reload is the same answer with nothing to get wrong.
|
|
*/
|
|
onCancel={() => {
|
|
setEditing(null);
|
|
setRendered((n) => n + 1);
|
|
}}
|
|
onSave={() => void saveText()}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|