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]); };