From b5ca67e68e0e036cb3b3cdd25dd74472f4b2762c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 1 Sep 2026 07:34:34 +0100 Subject: [PATCH] every control writes itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/DetailPanel.tsx | 78 ++++++++++------------------------- web/SaveTarget.tsx | 71 ------------------------------- web/sections/AboutPanel.tsx | 4 +- web/sections/HomePanel.tsx | 4 +- web/sections/LayoutPanel.tsx | 5 ++- web/sections/PagesPanel.tsx | 5 ++- web/sections/RecordsPanel.tsx | 4 +- web/useAutosave.ts | 64 ++++++++++++++++++++++++++++ 8 files changed, 98 insertions(+), 137 deletions(-) delete mode 100644 web/SaveTarget.tsx create mode 100644 web/useAutosave.ts diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 749ddc8..0afe5c6 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -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 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 ( - - ); -}; - - /** 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 ; }; - 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. */} - - - {/* + + {/* 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. */} -
- {/* 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 changes width. It held Edit/View — see the note in ViewMode about why there is no longer a mode to toggle. */} -
+
-
- -
- -
- {/* 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. */} - - - -
+
+
-
{Panel ? : }
- - +
+ {/* 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. */} + + +
+
+ +
{Panel ? : }
+
); }; diff --git a/web/SaveTarget.tsx b/web/SaveTarget.tsx deleted file mode 100644 index 93d48b2..0000000 --- a/web/SaveTarget.tsx +++ /dev/null @@ -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; -}; - -const SaveTargetContext = createContext<{ - target: SaveTarget | null; - announce: (target: SaveTarget | null) => void; -}>({ target: null, announce: () => {} }); - -export const SaveTargetProvider = ({ children }: { children: ReactNode }) => { - const [target, setTarget] = useState(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 {children}; -}; - -/** 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 => { - const { announce } = useContext(SaveTargetContext); - const saveRef = useRef(save); - saveRef.current = save; - - useEffect(() => { - announce({ dirty, busy, save: () => saveRef.current() }); - return () => announce(null); - }, [dirty, busy, announce]); -}; diff --git a/web/sections/AboutPanel.tsx b/web/sections/AboutPanel.tsx index 5806bf6..0546246 100644 --- a/web/sections/AboutPanel.tsx +++ b/web/sections/AboutPanel.tsx @@ -8,7 +8,7 @@ import { useFlash } from '../useFlash'; import { RichText } from '../RichText'; import { MediaField } from '../MediaField'; 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. @@ -139,7 +139,7 @@ export const AboutPanel = () => { } }; - useSaveTarget(dirty, busy !== null, save); + useAutosave(dirty ? 'dirty' : null, busy !== null, save); if (!about) { return ( diff --git a/web/sections/HomePanel.tsx b/web/sections/HomePanel.tsx index 2902d9e..661e4a7 100644 --- a/web/sections/HomePanel.tsx +++ b/web/sections/HomePanel.tsx @@ -7,7 +7,7 @@ import { readableError } from '../errors'; import { useFlash } from '../useFlash'; import { RichText } from '../RichText'; import { MediaPicker } from '../MediaPicker'; -import { useSaveTarget } from '../SaveTarget'; +import { useAutosave } from '../useAutosave'; /** * 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)); - useSaveTarget(dirty, busy !== null, save); + useAutosave(dirty ? 'dirty' : null, busy !== null, save); diff --git a/web/sections/LayoutPanel.tsx b/web/sections/LayoutPanel.tsx index e58fdb7..12b80e5 100644 --- a/web/sections/LayoutPanel.tsx +++ b/web/sections/LayoutPanel.tsx @@ -4,7 +4,7 @@ import { useSearchParams } from 'react-router'; import { useClient } from 'hooks/useClient'; import { readableError } from '../errors'; import { useFlash } from '../useFlash'; -import { useSaveTarget } from '../SaveTarget'; +import { useAutosave } from '../useAutosave'; import { usePreviewable, useViewMode } from '../ViewMode'; import { applySpacing, paintMeasure, type SpacingDraft } from '../canvas'; 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. usePreviewable(); const { device } = useViewMode(); diff --git a/web/sections/PagesPanel.tsx b/web/sections/PagesPanel.tsx index 107fa24..a863563 100644 --- a/web/sections/PagesPanel.tsx +++ b/web/sections/PagesPanel.tsx @@ -5,7 +5,7 @@ import { Input } from '@/components/ui/input'; import { useClient } from 'hooks/useClient'; import { readableError } from '../errors'; import { useFlash } from '../useFlash'; -import { useSaveTarget } from '../SaveTarget'; +import { useAutosave } from '../useAutosave'; 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) => { setBusy(true); diff --git a/web/sections/RecordsPanel.tsx b/web/sections/RecordsPanel.tsx index 86019f4..e445db6 100644 --- a/web/sections/RecordsPanel.tsx +++ b/web/sections/RecordsPanel.tsx @@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input'; import { readableError } from '../errors'; import { useFlash } from '../useFlash'; import { MediaPicker } from '../MediaPicker'; -import { useSaveTarget } from '../SaveTarget'; +import { useAutosave } from '../useAutosave'; 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]))), ); - useSaveTarget(dirty, busy !== null, save); + useAutosave(dirty ? 'dirty' : null, busy !== null, save); return (
diff --git a/web/useAutosave.ts b/web/useAutosave.ts new file mode 100644 index 0000000..d81ea2e --- /dev/null +++ b/web/useAutosave.ts @@ -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 => { + // 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(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(); + }, + [], + ); +};