`?page=about` instead of `?page=2`. A URL that names the page can be read, sent to somebody, and still means the right thing after a database is restored from a dump with different ids — which happened once in this project already, earlier today. Home needs a word, because its slug is empty by design: that is the check constraint, and it is what makes `/` fall out of joining a slug to the root. An empty string is no use in a query parameter that has to name a page unambiguously, so the admin says `home` — and the site's API now REFUSES that slug, which is what makes the mapping total in both directions rather than merely usual. One reserved address costs nothing. Anybody who wanted /home wanted the front page, which they have. One place maps a page to its URL value and back — pageParam and pageFromParam — so the nav, the detail panel and the builder cannot disagree about what `?page=` means. `record` is deleted on every navigation. It belonged to the per-page record panels, which are unreachable now; nothing reads it, so it was not a bug, but every URL anybody copied carried a parameter naming a row in a section that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
276 lines
12 KiB
TypeScript
276 lines
12 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 { FreeTextDialog } from './FreeTextDialog';
|
|
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 } = 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;
|
|
}>({ add: () => {}, remove: () => {}, addComponent: () => {}, editComponent: () => {}, removeComponent: () => {} });
|
|
const [adding, setAdding] = useState(false);
|
|
const [creating, setCreating] = useState(false);
|
|
/** 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,
|
|
);
|
|
/*
|
|
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.
|
|
*/
|
|
const onDocument = useCallback(
|
|
(frameDocument: Document) => {
|
|
if (mode === 'view') return;
|
|
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),
|
|
});
|
|
},
|
|
// `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[] }>(`/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.
|
|
/*
|
|
Free Text is the only kind, so Add component opens the editor straight away.
|
|
|
|
No picker in between: one option is not a choice, and a dialog that offers it is a click asking to
|
|
be got past. The picker arrives with the second kind, which is the moment it starts meaning
|
|
something.
|
|
*/
|
|
const addComponent = (section: CanvasSection, column: number) =>
|
|
setEditing({ id: null, sectionId: section.id, column, html: '' });
|
|
|
|
const editComponent = (component: CanvasComponent) => {
|
|
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
|
|
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 ?? 0, column: component.column, html });
|
|
};
|
|
|
|
const saveText = async (html: string) => {
|
|
if (!page || !editing) return;
|
|
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));
|
|
}
|
|
};
|
|
|
|
handlersRef.current = {
|
|
add: () => setAdding(true),
|
|
remove: (sectionId: number) => void deleteSection(sectionId),
|
|
addComponent,
|
|
editComponent,
|
|
removeComponent: (component) => void deleteComponent(component),
|
|
};
|
|
|
|
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)}
|
|
/>
|
|
|
|
<FreeTextDialog
|
|
open={editing !== null}
|
|
busy={creating}
|
|
initial={editing?.html ?? ''}
|
|
onCancel={() => setEditing(null)}
|
|
onSave={(html) => void saveText(html)}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|