Both hold nothing, so adding one opens no editor: a dialog with no field on it is a step that exists only to be got past. The picker itself answers the opposite way for the kinds that DO have a choice to make, which is the distinction worth keeping. The canvas draws no Edit button for them either — an Edit that opens nothing is worse than no Edit. It asks the panel rather than holding a list of kinds, because which kinds are editable is the same knowledge that decides which editor opens, and that already lives there. Absent means editable, so a caller that has never thought about it gets what it had. The Spacing dialog's label became a map. It was two kinds and readable as a ternary, then three and not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
563 lines
22 KiB
TypeScript
563 lines
22 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';
|
|
|
|
/*
|
|
What the Spacing dialog calls a block, by kind.
|
|
|
|
A map rather than a chain of ternaries: it was two kinds and readable, then three and not. Unknown
|
|
falls back to 'Text' — a label is a heading on a dialog, and the worst a wrong one does is read oddly
|
|
for a kind nobody has finished adding.
|
|
*/
|
|
const LABELS: Record<string, string> = {
|
|
'free-text': 'Text',
|
|
video: 'Video',
|
|
gallery: 'Gallery',
|
|
'contact-form': 'Contact form',
|
|
partners: 'Partners',
|
|
};
|
|
|
|
/*
|
|
The kinds that hold nothing, named once.
|
|
|
|
Both the picker and the canvas ask the same question — one to decide whether to open an editor, the
|
|
other whether to draw an Edit button — and two lists would drift into a button that opens nothing.
|
|
*/
|
|
const PLACES_ONLY: string[] = ['contact-form', 'partners'];
|
|
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 { GalleryPicker } from '../GalleryPicker';
|
|
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. */
|
|
/*
|
|
Which gallery a block shows, while it is being chosen.
|
|
|
|
The same shape as `picking` and for the same reason: choosing a set is one act with one outcome, so
|
|
there is nothing to draft and nothing to cancel out of afterwards. `galleryId` rides along so
|
|
reopening the picker can show where the block already stands.
|
|
*/
|
|
const [choosingGallery, setChoosingGallery] = useState<{
|
|
id: number | null;
|
|
sectionId: number;
|
|
column: number;
|
|
galleryId: number | null;
|
|
} | null>(null);
|
|
|
|
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),
|
|
// Nothing to edit, so no button. See PLACES_ONLY.
|
|
canEditComponent: (component) => !PLACES_ONLY.includes(component.kind),
|
|
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 });
|
|
|
|
/*
|
|
A block that holds nothing is CREATED on the spot.
|
|
|
|
No dialog, because there is nothing to fill in: the contact form is the site's form and the partner
|
|
row is the partner table. Asking for a confirmation with no field on it would be a step that exists
|
|
only to be got past — the same argument the picker itself answers the other way, where there IS a
|
|
choice to make.
|
|
*/
|
|
const createEmpty = async (kind: ComponentKind, at: { sectionId: number; column: number }) => {
|
|
if (!page) return;
|
|
setError(null);
|
|
try {
|
|
await clientRef.current.post(`/fofos-admin/pages/${page.id}/sections/${at.sectionId}/components`, {
|
|
kind,
|
|
column: at.column,
|
|
content: {},
|
|
});
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
const chooseKind = (kind: ComponentKind) => {
|
|
if (!choosing) return;
|
|
const at = choosing;
|
|
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, ...at, html: '' });
|
|
if (kind === 'video') setPicking({ id: null, ...at });
|
|
if (kind === 'gallery') setChoosingGallery({ id: null, ...at, galleryId: null });
|
|
if (PLACES_ONLY.includes(kind)) void createEmpty(kind, at);
|
|
};
|
|
|
|
/*
|
|
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));
|
|
}
|
|
};
|
|
|
|
/*
|
|
A gallery block is a REFERENCE, saved as soon as the set is chosen — see GalleryPicker.
|
|
|
|
Identical in shape to saveVideo because it is the same interaction: pick one thing, store one
|
|
value. The two are left as two functions rather than one generic saver, because what they store
|
|
happens to be one field each today and a shared helper would be an abstraction over a coincidence.
|
|
*/
|
|
const saveGallery = async (galleryId: number) => {
|
|
if (!page || !choosingGallery) return;
|
|
const base = `/fofos-admin/pages/${page.id}/sections/${choosingGallery.sectionId}/components`;
|
|
const target = choosingGallery;
|
|
setChoosingGallery(null);
|
|
setError(null);
|
|
try {
|
|
if (target.id === null) {
|
|
await clientRef.current.post(base, { kind: 'gallery', column: target.column, content: { galleryId } });
|
|
} else {
|
|
await clientRef.current.patch(`${base}/${target.id}`, { content: { galleryId } });
|
|
}
|
|
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;
|
|
}
|
|
|
|
if (component.kind === 'gallery') {
|
|
const held = owner.components.find((one) => one.id === component.id)?.content?.galleryId;
|
|
setChoosingGallery({
|
|
id: component.id,
|
|
sectionId: owner.id,
|
|
column: component.column,
|
|
galleryId: typeof held === 'number' ? held : null,
|
|
});
|
|
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: LABELS[component.kind] ?? '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"
|
|
/>
|
|
|
|
<GalleryPicker
|
|
open={choosingGallery !== null}
|
|
onClose={() => setChoosingGallery(null)}
|
|
onPick={(galleryId) => void saveGallery(galleryId)}
|
|
selected={choosingGallery?.galleryId ?? null}
|
|
/>
|
|
|
|
{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>
|
|
);
|
|
};
|