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:
+22
-56
@@ -1,7 +1,7 @@
|
|||||||
import { Navigate } from 'react-router';
|
import { Navigate } from 'react-router';
|
||||||
import { href, useNav } from './routes';
|
import { href, useNav } from './routes';
|
||||||
import { DEFAULT_SECTION } from './sections';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { sectionFor, sectionOpen, type SectionKey } from './sections';
|
import { sectionFor, sectionOpen, type SectionKey } from './sections';
|
||||||
import { AdministrationPanel } from './AdministrationPanel';
|
import { AdministrationPanel } from './AdministrationPanel';
|
||||||
@@ -12,7 +12,6 @@ import { PagesPanel } from './sections/PagesPanel';
|
|||||||
import { PageBuilderPanel } from './sections/PageBuilderPanel';
|
import { PageBuilderPanel } from './sections/PageBuilderPanel';
|
||||||
import { PublishButton } from './PublishButton';
|
import { PublishButton } from './PublishButton';
|
||||||
import { SyncButton } from './SyncButton';
|
import { SyncButton } from './SyncButton';
|
||||||
import { SaveTargetProvider, useSaveButton } from './SaveTarget';
|
|
||||||
import { DEVICES, ViewModeProvider, useViewMode } from './ViewMode';
|
import { DEVICES, ViewModeProvider, useViewMode } from './ViewMode';
|
||||||
import { DeviceToggle } from './DeviceToggle';
|
import { DeviceToggle } from './DeviceToggle';
|
||||||
import { MessagesPanel } from './MessagesPanel';
|
import { MessagesPanel } from './MessagesPanel';
|
||||||
@@ -59,35 +58,6 @@ const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
|
|||||||
media: MediaPanel,
|
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. */
|
/** The width the preview is rendered at. See DEVICES for why these three and not others. */
|
||||||
const BarDeviceToggle = () => {
|
const BarDeviceToggle = () => {
|
||||||
const { previewable } = useViewMode();
|
const { previewable } = useViewMode();
|
||||||
@@ -98,7 +68,6 @@ const BarDeviceToggle = () => {
|
|||||||
return <DeviceToggle size="bar" />;
|
return <DeviceToggle size="bar" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const DetailPanel = () => {
|
export const DetailPanel = () => {
|
||||||
/*
|
/*
|
||||||
A page selected inside Pages opens the BUILDER, not the list with a row lit up. By SLUG, so the URL
|
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
|
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.
|
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.
|
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
|
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
|
outer groups change width as sections come and go, and a middle that drifted with them
|
||||||
would never be in the same place twice.
|
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">
|
<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
|
{/* 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
|
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
|
changes width. It held Edit/View — see the note in ViewMode about why there is no
|
||||||
longer a mode to toggle. */}
|
longer a mode to toggle. */}
|
||||||
<div className="flex flex-1 justify-start" />
|
<div className="flex flex-1 justify-start" />
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<BarDeviceToggle />
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1">{Panel ? <Panel /> : <PlannedSection section={current} />}</div>
|
<div className="flex flex-1 items-center justify-end gap-2">
|
||||||
</ViewModeProvider>
|
{/* The order is the sequence: make the media web-ready, then make it public. Sync sits
|
||||||
</SaveTargetProvider>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One Save, in the panel's own bar, driven by whichever section is open.
|
|
||||||
*
|
|
||||||
* ── Why a context and not a prop ──
|
|
||||||
*
|
|
||||||
* The button lives in DetailPanel and the work lives in the section — and DetailPanel deliberately
|
|
||||||
* knows nothing about the sections beyond a map of keys to components. Threading a save handler down
|
|
||||||
* would mean every panel taking a prop it did not ask for, and DetailPanel holding a union of what
|
|
||||||
* each of them can do.
|
|
||||||
*
|
|
||||||
* So the section announces itself instead. It says whether it has unsaved changes, whether it is busy,
|
|
||||||
* and how to save; the bar renders that. Neither has to know what the other is.
|
|
||||||
*
|
|
||||||
* ── One Save replaces the per-card ones ──
|
|
||||||
*
|
|
||||||
* The panels used to save a card at a time, because the API treats an absent key as "leave it alone" —
|
|
||||||
* so a Save carrying everything meant a half-written manifesto shipping with a photograph somebody
|
|
||||||
* meant to change. With one Save that is no longer an accident, it is the model: one Save saves the
|
|
||||||
* page you are looking at, which is what a person expects from a button that says Save.
|
|
||||||
*
|
|
||||||
* What it costs is the thing worth stating: there is no longer a way to commit one part of a page and
|
|
||||||
* leave the rest. Nothing in this admin needed that, and if something ever does it wants its own verb
|
|
||||||
* rather than four buttons called the same word.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type SaveTarget = {
|
|
||||||
/** Something is edited and not yet written. The button is dead without it. */
|
|
||||||
dirty: boolean;
|
|
||||||
/** A save is in flight. Keeps a second press from starting a second one. */
|
|
||||||
busy: boolean;
|
|
||||||
save: () => void | Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SaveTargetContext = createContext<{
|
|
||||||
target: SaveTarget | null;
|
|
||||||
announce: (target: SaveTarget | null) => void;
|
|
||||||
}>({ target: null, announce: () => {} });
|
|
||||||
|
|
||||||
export const SaveTargetProvider = ({ children }: { children: ReactNode }) => {
|
|
||||||
const [target, setTarget] = useState<SaveTarget | null>(null);
|
|
||||||
// Stable, so the effect in `useSaveTarget` is not re-run by this provider re-rendering.
|
|
||||||
const announce = useCallback((next: SaveTarget | null) => setTarget(next), []);
|
|
||||||
return <SaveTargetContext.Provider value={{ target, announce }}>{children}</SaveTargetContext.Provider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Read by the bar. Null when the open section has nothing to save. */
|
|
||||||
export const useSaveButton = (): SaveTarget | null => useContext(SaveTargetContext).target;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by a section to offer its Save.
|
|
||||||
*
|
|
||||||
* `save` is held in a ref and the effect depends only on the two booleans, so a handler rebuilt on
|
|
||||||
* every render — which it is, being an ordinary function in a component — does not re-announce on
|
|
||||||
* every render. Announcing in a loop would be a state update per render, which React stops by
|
|
||||||
* throwing rather than by being slow.
|
|
||||||
*
|
|
||||||
* The cleanup clears it, so a section that unmounts leaves no button behind pointing at a component
|
|
||||||
* that is gone.
|
|
||||||
*/
|
|
||||||
export const useSaveTarget = (dirty: boolean, busy: boolean, save: () => void | Promise<void>): void => {
|
|
||||||
const { announce } = useContext(SaveTargetContext);
|
|
||||||
const saveRef = useRef(save);
|
|
||||||
saveRef.current = save;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
announce({ dirty, busy, save: () => saveRef.current() });
|
|
||||||
return () => announce(null);
|
|
||||||
}, [dirty, busy, announce]);
|
|
||||||
};
|
|
||||||
@@ -8,7 +8,7 @@ import { useFlash } from '../useFlash';
|
|||||||
import { RichText } from '../RichText';
|
import { RichText } from '../RichText';
|
||||||
import { MediaField } from '../MediaField';
|
import { MediaField } from '../MediaField';
|
||||||
import { OverlayEditor, type Overlay } from '../OverlayEditor';
|
import { OverlayEditor, type Overlay } from '../OverlayEditor';
|
||||||
import { useSaveTarget } from '../SaveTarget';
|
import { useAutosave } from '../useAutosave';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* About Us — four bands, in the order the page shows them.
|
* About Us — four bands, in the order the page shows them.
|
||||||
@@ -139,7 +139,7 @@ export const AboutPanel = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useSaveTarget(dirty, busy !== null, save);
|
useAutosave(dirty ? 'dirty' : null, busy !== null, save);
|
||||||
|
|
||||||
if (!about) {
|
if (!about) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { readableError } from '../errors';
|
|||||||
import { useFlash } from '../useFlash';
|
import { useFlash } from '../useFlash';
|
||||||
import { RichText } from '../RichText';
|
import { RichText } from '../RichText';
|
||||||
import { MediaPicker } from '../MediaPicker';
|
import { MediaPicker } from '../MediaPicker';
|
||||||
import { useSaveTarget } from '../SaveTarget';
|
import { useAutosave } from '../useAutosave';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Home page: a hero, an intro, and an ordered gallery.
|
* The Home page: a hero, an intro, and an ordered gallery.
|
||||||
@@ -129,7 +129,7 @@ export const HomePanel = () => {
|
|||||||
|
|
||||||
const save = () => send('save', () => clientRef.current.patch<{ data: Home }>('/fofos-admin/home', edited));
|
const save = () => send('save', () => clientRef.current.patch<{ data: Home }>('/fofos-admin/home', edited));
|
||||||
|
|
||||||
useSaveTarget(dirty, busy !== null, save);
|
useAutosave(dirty ? 'dirty' : null, busy !== null, save);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useSearchParams } from 'react-router';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { readableError } from '../errors';
|
import { readableError } from '../errors';
|
||||||
import { useFlash } from '../useFlash';
|
import { useFlash } from '../useFlash';
|
||||||
import { useSaveTarget } from '../SaveTarget';
|
import { useAutosave } from '../useAutosave';
|
||||||
import { usePreviewable, useViewMode } from '../ViewMode';
|
import { usePreviewable, useViewMode } from '../ViewMode';
|
||||||
import { applySpacing, paintMeasure, type SpacingDraft } from '../canvas';
|
import { applySpacing, paintMeasure, type SpacingDraft } from '../canvas';
|
||||||
import { SitePreview, useDevPort } from '../SitePreview';
|
import { SitePreview, useDevPort } from '../SitePreview';
|
||||||
@@ -203,7 +203,8 @@ export const LayoutPanel = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useSaveTarget(dirty, busy, save);
|
// The serialised draft, or null when it matches what the server has. See useAutosave.
|
||||||
|
useAutosave(dirty ? only(draft) : null, busy, save);
|
||||||
// This section has a preview, so the bar offers Edit/View and the three widths.
|
// This section has a preview, so the bar offers Edit/View and the three widths.
|
||||||
usePreviewable();
|
usePreviewable();
|
||||||
const { device } = useViewMode();
|
const { device } = useViewMode();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { readableError } from '../errors';
|
import { readableError } from '../errors';
|
||||||
import { useFlash } from '../useFlash';
|
import { useFlash } from '../useFlash';
|
||||||
import { useSaveTarget } from '../SaveTarget';
|
import { useAutosave } from '../useAutosave';
|
||||||
import { usePages, type Page } from '../usePages';
|
import { usePages, type Page } from '../usePages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,7 +143,8 @@ export const PagesPanel = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useSaveTarget(dirty, busy, save);
|
// The rows that differ, serialised — so one set of edits is attempted once. See useAutosave.
|
||||||
|
useAutosave(dirty ? JSON.stringify(changed()) : null, busy, save);
|
||||||
|
|
||||||
const act = async (run: () => Promise<unknown>) => {
|
const act = async (run: () => Promise<unknown>) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { readableError } from '../errors';
|
import { readableError } from '../errors';
|
||||||
import { useFlash } from '../useFlash';
|
import { useFlash } from '../useFlash';
|
||||||
import { MediaPicker } from '../MediaPicker';
|
import { MediaPicker } from '../MediaPicker';
|
||||||
import { useSaveTarget } from '../SaveTarget';
|
import { useAutosave } from '../useAutosave';
|
||||||
import type { RecordsConfig } from './records';
|
import type { RecordsConfig } from './records';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,7 +222,7 @@ export const RecordsPanel = ({ config }: { config: RecordsConfig }) => {
|
|||||||
config.fields.some((f) => asText(draft[f.key]) !== asText(open[f.key]))),
|
config.fields.some((f) => asText(draft[f.key]) !== asText(open[f.key]))),
|
||||||
);
|
);
|
||||||
|
|
||||||
useSaveTarget(dirty, busy !== null, save);
|
useAutosave(dirty ? 'dirty' : null, busy !== null, save);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col gap-4 overflow-auto p-4">
|
<div className="flex size-full flex-col gap-4 overflow-auto p-4">
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A change writes itself.
|
||||||
|
*
|
||||||
|
* ── Why there is no Save button ──
|
||||||
|
*
|
||||||
|
* There was one, in the panel's bar, announced by whichever section was open — see the git history for
|
||||||
|
* `SaveTarget`. Every screen here is a preview of the thing being edited, so the change is already
|
||||||
|
* visible before it is written; a button then asks somebody to confirm what they can see. What it
|
||||||
|
* really did was make "did that take?" a question, and answer it only after a press.
|
||||||
|
*
|
||||||
|
* ── The debounce is the whole mechanism ──
|
||||||
|
*
|
||||||
|
* A select fires per choice and a text field per keystroke. Waiting for a pause turns a burst into one
|
||||||
|
* request without anybody deciding when the burst ended.
|
||||||
|
*
|
||||||
|
* ── `pending` is a STRING, not a flag ──
|
||||||
|
*
|
||||||
|
* It is the unsaved state, serialised — whatever the panel would send. That is what makes this safe to
|
||||||
|
* retry-free: an attempt is remembered by its value, so a save that FAILS does not immediately run
|
||||||
|
* again on the next render. With a boolean it would: `busy` goes false, `dirty` is still true, and the
|
||||||
|
* effect starts another timer — a failing request every second for as long as the panel is open.
|
||||||
|
*
|
||||||
|
* Null means there is nothing to write, which is different from an empty string.
|
||||||
|
*/
|
||||||
|
const DELAY = 600;
|
||||||
|
|
||||||
|
export const useAutosave = (pending: string | null, busy: boolean, save: () => void | Promise<void>): void => {
|
||||||
|
// Held in refs so an ordinary function rebuilt every render does not restart the timer.
|
||||||
|
const saveRef = useRef(save);
|
||||||
|
saveRef.current = save;
|
||||||
|
const pendingRef = useRef(pending);
|
||||||
|
pendingRef.current = pending;
|
||||||
|
|
||||||
|
/** The last value handed to `save`. Compared, so one value is attempted once. */
|
||||||
|
const tried = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pending === null || busy || tried.current === pending) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
tried.current = pending;
|
||||||
|
void saveRef.current();
|
||||||
|
}, DELAY);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [pending, busy]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
Leaving with something unwritten.
|
||||||
|
|
||||||
|
The panel unmounts when the section changes, and a debounce that has not fired yet would go with
|
||||||
|
it — so a choice made and then navigated away from within half a second would be lost, silently and
|
||||||
|
only sometimes. The request outlives the component; the state updates it makes are no-ops, which is
|
||||||
|
fine because there is nothing left to show them.
|
||||||
|
*/
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (pendingRef.current !== null && pendingRef.current !== tried.current) void saveRef.current();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user