A component that lives at the PLUGIN level and appears on the site's page: an empty drop zone with an Add section button, injected into the frame at runtime. Nothing of it exists in the website's source, so the site never carries code only an editor would see, and the builder can change without the site being rebuilt. It works because the preview frame is SAME ORIGIN — through the platform's dev proxy, on Officer's own host — so contentDocument is readable and writable. That single fact is what the page builder rests on, and it is why swapping this plugin's hand-rolled cross-origin proxy for the platform's mattered beyond the bugs it fixed. SitePreview grew one prop, `onDocument`, called with the frame's document after every load and returning a cleanup that runs before the next one. So a reload never leaves two sets of controls behind, and nothing injected outlives the document it went into. Plain DOM rather than React. Portalling would mean running a reconciler against a document that reloads underneath it whenever the dev server rebuilds; these are a handful of elements with a click handler, and this way a reload is a document that no longer exists rather than a tree React still thinks it owns. Inline styles, not classes. The frame carries the SITE's stylesheet, built by scanning the site's own templates — a class invented in this file was never scanned and would silently do nothing. The button takes its colours from --site-fg and --site-bg, so it belongs to whatever theme the site is wearing. The page's existing content is HIDDEN rather than removed, so the same call will leave real blocks visible once there are any. The panel counts the presses for now. That is the only visible proof the wire runs both ways, and it becomes the section picker when there are sections to pick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
104 lines
4.2 KiB
TypeScript
104 lines
4.2 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 { paintCanvas } from '../canvas';
|
|
import { 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 id = Number(params.get('page')) || null;
|
|
|
|
const devPort = useDevPort();
|
|
const { pages, announce } = usePages();
|
|
|
|
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 [asked, setAsked] = 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.
|
|
*/
|
|
const onDocument = useCallback(
|
|
(frameDocument: Document) => paintCanvas(frameDocument, { onAddSection: () => setAsked((n) => n + 1) }),
|
|
[],
|
|
);
|
|
|
|
/*
|
|
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 = pages?.find((row) => row.id === id) ?? null;
|
|
|
|
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. */}
|
|
<SitePreview port={devPort} path={page.slug} onDocument={onDocument} />
|
|
|
|
{/*
|
|
The other end of the wire.
|
|
|
|
Temporary, and it earns its place until the section picker exists: it is the only visible proof
|
|
that a button drawn inside the rendered page reaches React out here. When there are sections to
|
|
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>
|
|
);
|
|
};
|