diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 42ecddd..a56d761 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -1,10 +1,13 @@ import { useSearchParams } from 'react-router'; +import { Check, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { DEFAULT_SECTION, isSectionKey, sectionFor, sectionOpen, type SectionKey } from './sections'; import { AdministrationPanel } from './AdministrationPanel'; import { MediaPanel } from './MediaPanel'; import { HomePanel } from './sections/HomePanel'; import { GalleriesPanel } from './sections/GalleriesPanel'; import { PublishButton } from './PublishButton'; +import { SaveTargetProvider, useSaveButton } from './SaveTarget'; import { AboutPanel } from './sections/AboutPanel'; import { RecordsPanel } from './sections/RecordsPanel'; import { COLLABS_CONFIG, DROPS_CONFIG, GALLERY_CONFIG } from './sections/records'; @@ -59,6 +62,34 @@ 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 ( + + ); +}; + export const DetailPanel = () => { const [params] = useSearchParams(); const raw = params.get('section'); @@ -99,11 +130,16 @@ 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. */} -
- -
+ +
+ {/* Left of Publish, and in that order for a reason: save what you wrote, then make it + public. A row that put them the other way round would read as the sequence backwards. */} + + +
-
{Panel ? : }
+
{Panel ? : }
+
); }; diff --git a/web/SaveTarget.tsx b/web/SaveTarget.tsx new file mode 100644 index 0000000..93d48b2 --- /dev/null +++ b/web/SaveTarget.tsx @@ -0,0 +1,71 @@ +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 0809ecf..6faa565 100644 --- a/web/sections/AboutPanel.tsx +++ b/web/sections/AboutPanel.tsx @@ -7,6 +7,7 @@ import { readableError } from '../errors'; import { useFlash } from '../useFlash'; import { RichText } from '../RichText'; import { MediaPicker } from '../MediaPicker'; +import { useSaveTarget } from '../SaveTarget'; /** * About Us — four bands, in the order the page shows them. @@ -20,11 +21,16 @@ import { MediaPicker } from '../MediaPicker'; * Like every section here. An edit lands on the development server immediately and becomes public when * somebody presses Publish; production is a directory of static files with no API to write to. * - * ── One save per band ── + * ── One Save, in the panel's bar ── * - * The same rule Home follows, for the same reason. `take()` on the server treats an absent key as - * "leave it alone", so a single Save carrying every field would let a half-finished manifesto ride - * along with a photograph somebody meant to change. Four bands, four independent saves. + * There were four, one per band, because the API treats an absent key as "leave it alone" and a Save + * carrying everything meant a half-written manifesto shipping with a photograph somebody meant to + * change. With a single Save that is the model rather than an accident: one press writes the page you + * are looking at. + * + * `dirty` is a comparison against what the server last returned, not a flag set by every keystroke. A + * flag says "something was typed" and stays true after you undo it by hand; a comparison says "this + * differs from what is stored", which is the question the button is actually answering. * * ── The server owns the shape ── * @@ -84,11 +90,35 @@ export const AboutPanel = () => { void load(); }, [load]); - const send = async (label: string, body: Record) => { - setBusy(label); + /** Empty is null, not "". A field somebody cleared and one never filled are the same thing. */ + const orNull = (value: string) => value.trim() || null; + + /** Every field this page owns, as the API spells them. One place, so save and dirty cannot disagree. */ + const edited = { + header_url: orNull(headerUrl), + header_phrase: orNull(phrase), + about_text: orNull(aboutText), + product_video_url: orNull(productVideo), + product_text: orNull(productText), + manifesto_text: orNull(manifesto), + }; + + const stored = about && { + header_url: about.header.url, + header_phrase: about.header.phrase, + about_text: about.about_text, + product_video_url: about.product_video_url, + product_text: about.product_text, + manifesto_text: about.manifesto_text, + }; + + const dirty = Boolean(stored) && JSON.stringify(edited) !== JSON.stringify(stored); + + const save = async () => { + setBusy('save'); setError(null); try { - apply((await clientRef.current.patch<{ data: About }>('/fofos-admin/about', body)).data); + apply((await clientRef.current.patch<{ data: About }>('/fofos-admin/about', edited)).data); flashSaved(); } catch (failure) { setError(readableError(failure)); @@ -97,8 +127,7 @@ export const AboutPanel = () => { } }; - /** Empty is null, not "". A field somebody cleared and one never filled are the same thing. */ - const orNull = (value: string) => value.trim() || null; + useSaveTarget(dirty, busy !== null, save); if (!about) { return ( @@ -106,22 +135,17 @@ export const AboutPanel = () => { ); } - const saveButton = (label: string, onClick: () => void) => ( -
- - {saved && busy === null && Saved} -
- ); - return (
-

- The About page, in the order it is shown: a header, the text about you, the product, and the - manifesto. -

+
+

+ The About page, in the order it is shown: a header, the text about you, the product, and the + manifesto. +

+ {/* Confirmation lives with the content rather than in the bar, because the bar's own button + has already gone back to saying "Save" by the time anybody looks up. */} + {saved && Saved} +
{/* ── HEADER: a photograph and a phrase over it ─────────────────────────────────────────── */}
@@ -149,10 +173,6 @@ export const AboutPanel = () => { setPhrase(event.currentTarget.value)} />
- - {saveButton('header', () => - void send('header', { header_url: orNull(headerUrl), header_phrase: orNull(phrase) }), - )} {/* ── ABOUT US ─────────────────────────────────────────────────────────────────────────── */} @@ -162,7 +182,6 @@ export const AboutPanel = () => {

Who you are.

- {saveButton('about', () => void send('about', { about_text: orNull(aboutText) }))} {/* ── PRODUTO: a video beside its text ─────────────────────────────────────────────────── */} @@ -186,9 +205,6 @@ export const AboutPanel = () => { - {saveButton('product', () => - void send('product', { product_video_url: orNull(productVideo), product_text: orNull(productText) }), - )} {/* ── MANIFESTO ────────────────────────────────────────────────────────────────────────── */} @@ -198,7 +214,6 @@ export const AboutPanel = () => {

What you stand for.

- {saveButton('manifesto', () => void send('manifesto', { manifesto_text: orNull(manifesto) }))} {error &&

{error}

} diff --git a/web/sections/HomePanel.tsx b/web/sections/HomePanel.tsx index b37e6a6..7e92474 100644 --- a/web/sections/HomePanel.tsx +++ b/web/sections/HomePanel.tsx @@ -7,6 +7,7 @@ import { readableError } from '../errors'; import { useFlash } from '../useFlash'; import { RichText } from '../RichText'; import { MediaPicker } from '../MediaPicker'; +import { useSaveTarget } from '../SaveTarget'; /** * The Home page: a hero, an intro, and an ordered gallery. @@ -115,11 +116,21 @@ export const HomePanel = () => { either card would have each one saving the other's unsaved edits. Two cards, two independent saves, and a half-finished intro cannot ride along with a hero somebody meant to change. */ - const saveIntro = () => - send('intro', () => clientRef.current.patch<{ data: Home }>('/fofos-admin/home', { intro_text: intro.trim() || null })); + /* + One Save for everything this page holds in a field. + + The gallery choice is NOT part of it: picking a gallery from the list writes immediately, because a +