every control writes itself

The Save button is gone, and so is the SaveTarget context that announced it.
Every screen here is a preview of the thing being edited, so a change is already
visible before it is written — a button then asks somebody to confirm what they
can see.

useAutosave debounces a burst into one request. It takes the unsaved state as a
string rather than a flag, so a failed save is not retried on the next render,
and it flushes on unmount so a section change loses nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 07:34:34 +01:00
co-authored by Claude Opus 5
parent 6717402331
commit b5ca67e68e
8 changed files with 98 additions and 137 deletions
+22 -56
View File
@@ -1,7 +1,7 @@
import { Navigate } from 'react-router';
import { href, useNav } from './routes';
import { DEFAULT_SECTION } from './sections';
import { Check, Eye, Loader2, Monitor, Pencil, Smartphone, Tablet } from 'lucide-react';
import { Eye, Monitor, Pencil, Smartphone, Tablet } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { sectionFor, sectionOpen, type SectionKey } from './sections';
import { AdministrationPanel } from './AdministrationPanel';
@@ -12,7 +12,6 @@ import { PagesPanel } from './sections/PagesPanel';
import { PageBuilderPanel } from './sections/PageBuilderPanel';
import { PublishButton } from './PublishButton';
import { SyncButton } from './SyncButton';
import { SaveTargetProvider, useSaveButton } from './SaveTarget';
import { DEVICES, ViewModeProvider, useViewMode } from './ViewMode';
import { DeviceToggle } from './DeviceToggle';
import { MessagesPanel } from './MessagesPanel';
@@ -59,35 +58,6 @@ const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
media: MediaPanel,
};
/**
* The one Save, driven by whichever section is open.
*
* Renders nothing at all when the section offers none — Media, Administration and Messages have
* nothing to save, and a permanently dead button is worse than no button: it invites a press and
* teaches that pressing does nothing.
*/
const SaveButton = () => {
const target = useSaveButton();
if (!target) return null;
return (
<Button
size="sm"
variant="outline"
className="h-6 gap-1.5 px-2 text-xs"
onClick={() => void target.save()}
// Dead until there is something to write. The whole value of one Save is that its state answers
// "have I saved?" — a button that looks the same either way answers nothing.
disabled={!target.dirty || target.busy}
title={target.dirty ? 'Save your changes' : 'Nothing to save'}
>
{target.busy ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
{target.busy ? 'Saving…' : 'Save'}
</Button>
);
};
/** The width the preview is rendered at. See DEVICES for why these three and not others. */
const BarDeviceToggle = () => {
const { previewable } = useViewMode();
@@ -98,7 +68,6 @@ const BarDeviceToggle = () => {
return <DeviceToggle size="bar" />;
};
export const DetailPanel = () => {
/*
A page selected inside Pages opens the BUILDER, not the list with a row lit up. By SLUG, so the URL
@@ -155,42 +124,39 @@ export const DetailPanel = () => {
shared furniture drawn the same way by every panel in the app; these two buttons belong to this
plugin and to the site it administers.
*/}
<SaveTargetProvider>
<ViewModeProvider>
{/*
<ViewModeProvider>
{/*
Three groups: how you are looking at it, at what width, and what to do with it.
The middle one is centred against the BAR rather than against what is beside it — the two
outer groups change width as sections come and go, and a middle that drifted with them
would never be in the same place twice.
*/}
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2">
{/* Empty, and deliberately still here: the middle group is centred against the BAR, so
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2">
{/* Empty, and deliberately still here: the middle group is centred against the BAR, so
removing the left group would let Desktop/Tablet/Mobile drift left as the right group
changes width. It held Edit/View — see the note in ViewMode about why there is no
longer a mode to toggle. */}
<div className="flex flex-1 justify-start" />
<div className="flex flex-1 justify-start" />
<div className="flex justify-center">
<BarDeviceToggle />
</div>
<div className="flex flex-1 items-center justify-end gap-2">
{/* The order is the sequence: save what you wrote, make the media web-ready, then make
it public. A row that put them any other way round would read as the steps backwards.
Sync sits left of Build for the same reason Build sits left of Publish — it is what
has to have happened first, and a stale `derived/` is a picture missing from the page
you just built. */}
<SaveButton />
<SyncButton />
<PublishButton />
</div>
<div className="flex justify-center">
<BarDeviceToggle />
</div>
<div className="min-h-0 flex-1">{Panel ? <Panel /> : <PlannedSection section={current} />}</div>
</ViewModeProvider>
</SaveTargetProvider>
<div className="flex flex-1 items-center justify-end gap-2">
{/* The order is the sequence: make the media web-ready, then make it public. Sync sits
left of Build for the same reason Build sits left of Publish — it is what has to have
happened first, and a stale `derived/` is a picture missing from the page you just
built.
No Save. Every control writes itself — see useAutosave. */}
<SyncButton />
<PublishButton />
</div>
</div>
<div className="min-h-0 flex-1">{Panel ? <Panel /> : <PlannedSection section={current} />}</div>
</ViewModeProvider>
</div>
);
};