Add section asks what shape, and creates one
Pressing the button drawn inside the page opens a dialog: a name, and a choice between one column and two, then Create section. The row lands in the database and the frame re-renders to show it. The layouts are DRAWN rather than named. "One column" is words for a shape, and a shape is faster to recognise than to read; the words stay underneath for anybody the picture does not reach. The name is asked for and not generated. It never appears on the site — it exists so a page of eight bands is navigable, and "Section 4" tells nobody anything, which is exactly the state a generated name would leave every page in. Asked at the only moment somebody knows what they are about to build. The frame is remounted after a write rather than the admin drawing the new band itself. The site renders from the database; the admin does not know how to render a page, and if it learnt it would be a second implementation of the very thing this preview exists to avoid. The canvas now finds the empty-page placeholder by its `data-page-empty` marker instead of taking `firstElementChild`. That was right for exactly as long as an empty page was the only kind — the moment a page has a section, the first child IS that section, and it would have hidden the thing being edited. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,7 @@ export const SitePreview = ({
|
|||||||
path,
|
path,
|
||||||
theme,
|
theme,
|
||||||
onDocument,
|
onDocument,
|
||||||
|
reloadToken,
|
||||||
}: {
|
}: {
|
||||||
port: number | null;
|
port: number | null;
|
||||||
/** The site path to show — `layout`, `about`, or `` for the front page. No leading slash. */
|
/** The site path to show — `layout`, `about`, or `` for the front page. No leading slash. */
|
||||||
@@ -67,6 +68,17 @@ export const SitePreview = ({
|
|||||||
* document it was injected into.
|
* document it was injected into.
|
||||||
*/
|
*/
|
||||||
onDocument?: (document: Document) => void | (() => void);
|
onDocument?: (document: Document) => void | (() => void);
|
||||||
|
/**
|
||||||
|
* Change this to fetch the page again.
|
||||||
|
*
|
||||||
|
* The site renders from the database, so anything written there only appears when the document is
|
||||||
|
* requested anew. The admin cannot draw the change itself — it does not know how to render a page,
|
||||||
|
* and if it did it would be a second implementation of the one thing this preview exists to avoid.
|
||||||
|
*
|
||||||
|
* Remounts the frame rather than calling `contentWindow.location.reload()`. Both work while the frame
|
||||||
|
* is same-origin, and the remount is one mechanism that would still work if it ever were not.
|
||||||
|
*/
|
||||||
|
reloadToken?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
/*
|
/*
|
||||||
@@ -221,6 +233,9 @@ export const SitePreview = ({
|
|||||||
remove the attribute. This is the owner's own development server on this machine.
|
remove the attribute. This is the owner's own development server on this machine.
|
||||||
*/
|
*/
|
||||||
<iframe
|
<iframe
|
||||||
|
// 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.
|
||||||
|
key={reloadToken ?? 0}
|
||||||
ref={frame}
|
ref={frame}
|
||||||
// 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.
|
||||||
|
|||||||
+10
-8
@@ -58,15 +58,17 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
|
|||||||
root.querySelectorAll(`[${MARK}]`).forEach((node) => node.remove());
|
root.querySelectorAll(`[${MARK}]`).forEach((node) => node.remove());
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The page's own content is hidden rather than removed.
|
The site's EMPTY-PAGE placeholder, hidden while the builder draws its own controls in that space.
|
||||||
|
|
||||||
Removed, it would be gone until the frame reloaded — and the builder wants to put controls AROUND
|
Found by its marker rather than by position. It was `firstElementChild`, which was right for exactly
|
||||||
what is there, not instead of it. Today the page is the empty placeholder and hiding it is the whole
|
as long as an empty page was the only page — the moment a page has a section, the first child IS
|
||||||
effect; tomorrow, when a page has blocks, the same call leaves them visible and this hides nothing.
|
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.firstElementChild as HTMLElement | null;
|
const placeholder = root.querySelector<HTMLElement>('[data-page-empty]');
|
||||||
const hadPlaceholder = Boolean(placeholder && !placeholder.hasAttribute(MARK));
|
if (placeholder) placeholder.style.display = 'none';
|
||||||
if (hadPlaceholder && placeholder) placeholder.style.display = 'none';
|
|
||||||
|
|
||||||
const zone = document.createElement('div');
|
const zone = document.createElement('div');
|
||||||
zone.setAttribute(MARK, 'add-section');
|
zone.setAttribute(MARK, 'add-section');
|
||||||
@@ -104,6 +106,6 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
|
|||||||
return () => {
|
return () => {
|
||||||
button.removeEventListener('click', canvas.onAddSection);
|
button.removeEventListener('click', canvas.onAddSection);
|
||||||
zone.remove();
|
zone.remove();
|
||||||
if (hadPlaceholder && placeholder) placeholder.style.display = '';
|
if (placeholder) placeholder.style.display = '';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Columns2, Loader2, Square } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
export type SectionLayout = 'one' | 'two';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layouts, drawn rather than named.
|
||||||
|
*
|
||||||
|
* "One column" and "Two columns" are words for a shape, and a shape is faster to recognise than to
|
||||||
|
* read. The words stay underneath for anybody the picture does not reach.
|
||||||
|
*
|
||||||
|
* This list and the site's own are two copies of the same fact — the database has it as a check
|
||||||
|
* constraint and PageSection.tsx has it as a branch. A layout offered here that the site cannot draw
|
||||||
|
* would render as nothing, so adding one means all three.
|
||||||
|
*/
|
||||||
|
const LAYOUTS: { value: SectionLayout; label: string; Icon: typeof Square; columns: number }[] = [
|
||||||
|
{ value: 'one', label: 'One column', Icon: Square, columns: 1 },
|
||||||
|
{ value: 'two', label: 'Two columns', Icon: Columns2, columns: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Naming a section and choosing its shape, before it exists.
|
||||||
|
*
|
||||||
|
* ── Why a name is asked for and not generated ──
|
||||||
|
*
|
||||||
|
* The name is never rendered on the site. It exists so a page of eight bands is navigable, and
|
||||||
|
* "Section 4" tells nobody anything — which is precisely the state a generated name would leave every
|
||||||
|
* page in. Asked once, at the only moment somebody knows what they are about to build.
|
||||||
|
*/
|
||||||
|
export const AddSectionDialog = ({
|
||||||
|
open,
|
||||||
|
busy,
|
||||||
|
onCancel,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onCreate: (values: { name: string; layout: SectionLayout }) => void;
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [layout, setLayout] = useState<SectionLayout>('one');
|
||||||
|
|
||||||
|
// Fresh every time it opens. A dialog that remembers the last thing somebody typed and abandoned is
|
||||||
|
// a dialog that quietly suggests they meant it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName('');
|
||||||
|
setLayout('one');
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed || busy) return;
|
||||||
|
onCreate({ name: trimmed, layout });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add a section</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs text-muted-foreground">Section name</label>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
placeholder="Intro"
|
||||||
|
onChange={(event) => setName(event.currentTarget.value)}
|
||||||
|
// Enter creates, because a two-field form is one somebody expects to finish from the
|
||||||
|
// keyboard. Guarded by the same check the button uses.
|
||||||
|
onKeyDown={(event) => event.key === 'Enter' && create()}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
For you, not for the site. It never appears on the page.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs text-muted-foreground">Layout</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{LAYOUTS.map(({ value, label, Icon, columns }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLayout(value)}
|
||||||
|
className={`flex flex-col items-center gap-2 rounded-lg border p-4 transition-colors ${
|
||||||
|
layout === value ? 'border-primary bg-muted' : 'border-border hover:bg-muted/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="size-6 text-muted-foreground" />
|
||||||
|
<span className="text-xs">{label}</span>
|
||||||
|
<span className="sr-only">{columns} column preview</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onCancel} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{/* Dead without a name. The server refuses an empty one, and a button that fires a request
|
||||||
|
only to show an error is a slower way of saying what the button could have said. */}
|
||||||
|
<Button onClick={create} disabled={!name.trim() || busy}>
|
||||||
|
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
Create section
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ 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 } from '../canvas';
|
||||||
|
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
|
||||||
import { usePages, type Page } from '../usePages';
|
import { usePages, type Page } from '../usePages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,8 +34,16 @@ 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);
|
||||||
/** Proof the frame is talking back. Replaced by the section picker once there are sections to pick. */
|
const [adding, setAdding] = useState(false);
|
||||||
const [asked, setAsked] = useState(0);
|
const [creating, setCreating] = useState(false);
|
||||||
|
/*
|
||||||
|
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.
|
The builder's chrome, painted into the rendered page.
|
||||||
@@ -43,10 +52,26 @@ 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: () => setAsked((n) => n + 1) }),
|
(frameDocument: Document) => paintCanvas(frameDocument, { onAddSection: () => setAdding(true) }),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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.
|
The list, if nobody has announced it yet.
|
||||||
|
|
||||||
@@ -82,22 +107,14 @@ 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. */}
|
||||||
<SitePreview port={devPort} path={page.slug} onDocument={onDocument} />
|
<SitePreview port={devPort} path={page.slug} onDocument={onDocument} reloadToken={rendered} />
|
||||||
|
|
||||||
{/*
|
<AddSectionDialog
|
||||||
The other end of the wire.
|
open={adding}
|
||||||
|
busy={creating}
|
||||||
Temporary, and it earns its place until the section picker exists: it is the only visible proof
|
onCancel={() => setAdding(false)}
|
||||||
that a button drawn inside the rendered page reaches React out here. When there are sections to
|
onCreate={(values) => void createSection(values)}
|
||||||
choose, this becomes the chooser.
|
/>
|
||||||
*/}
|
|
||||||
<div className="shrink-0 rounded-lg border border-dashed border-border p-3 text-center">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{asked === 0
|
|
||||||
? 'Press Add section on the page.'
|
|
||||||
: `Add section pressed ${asked} time${asked === 1 ? '' : 's'} — the page is talking to the panel.`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user