diff --git a/web/DetailHeader.tsx b/web/DetailHeader.tsx
index 176bb80..f5d879a 100644
--- a/web/DetailHeader.tsx
+++ b/web/DetailHeader.tsx
@@ -1,5 +1,6 @@
import { useSearchParams } from 'react-router';
import { DEFAULT_SECTION, isSectionKey, sectionFor } from './sections';
+import { PublishButton } from './PublishButton';
/**
* The detail panel's chrome header — the section's own name, not the word "Detail".
@@ -17,6 +18,11 @@ import { DEFAULT_SECTION, isSectionKey, sectionFor } from './sections';
*
* "Photos and video the site is built from" is useful in the NAV, where somebody is choosing between
* ten sections. Repeated above the thing they chose, it is a caption for a decision already made.
+ *
+ * ── And Publish, on the right ──
+ *
+ * The one action that belongs to every section rather than to any of them. It is here because it is
+ * what you do after editing anything, and a header is the one piece of chrome every panel already has.
*/
export const DetailHeader = () => {
const [params] = useSearchParams();
@@ -28,6 +34,9 @@ export const DetailHeader = () => {
<>
{section.label}
+ {/* Right of the name, on every section. Publishing is the last step of every edit and it used to
+ live in one card at the bottom of one section — see PublishButton for why that was wrong. */}
+
>
);
};
diff --git a/web/PublishButton.tsx b/web/PublishButton.tsx
new file mode 100644
index 0000000..57cc704
--- /dev/null
+++ b/web/PublishButton.tsx
@@ -0,0 +1,108 @@
+import { useRef, useState } from 'react';
+import { Check, CircleAlert, Loader2, Upload } from 'lucide-react';
+import { useClient } from 'hooks/useClient';
+import { Button } from '@/components/ui/button';
+import { readableError } from './errors';
+import { useFlash } from './useFlash';
+import { useReady } from './useReady';
+
+/**
+ * Publish, from wherever you happen to be.
+ *
+ * ── Why it is in the header and not only in Administration ──
+ *
+ * Publishing is the last step of every edit — write something on Home, choose a gallery, add a drop,
+ * then make it public. It lived in one card at the bottom of one section, so finishing a change meant
+ * leaving the thing you were working on to go and find the button. A person who does not know the
+ * panel well simply does not find it, and concludes their edit went live when it did not.
+ *
+ * It is the same action Administration's card offers. Two entry points to one thing is right here:
+ * that card belongs to a row about the production server and shows what a build produced, while this
+ * is the verb at the end of a sentence you have just written.
+ *
+ * ── It takes minutes, so it says so ──
+ *
+ * The whole site is re-rendered. A button that greys out and does nothing visible for three minutes
+ * reads as broken, and the second press is somebody assuming the first did not register — which is why
+ * the label changes rather than only the cursor.
+ *
+ * ── Failure has to be readable, and there is no room for it ──
+ *
+ * A header is one line. So a failure opens a panel underneath it, positioned absolutely so nothing
+ * shifts, holding the server's own words — usually a build error, which is long and worth reading. It
+ * stays until dismissed: a message that vanishes on a timer is one somebody was still reading.
+ */
+
+export const PublishButton = () => {
+ const client = useClient();
+ const clientRef = useRef(client);
+ clientRef.current = client;
+
+ const { ready } = useReady();
+ const [busy, setBusy] = useState(false);
+ const [failure, setFailure] = useState(null);
+ const [done, flashDone] = useFlash(6000);
+
+ const publish = async () => {
+ setBusy(true);
+ setFailure(null);
+ try {
+ const result = await clientRef.current.post<{ ok: boolean; output: string }>(
+ '/fofos-admin/_config/static/publish',
+ {},
+ );
+ // `ok: false` is a REFUSAL the server explains, not an exception — a backup that could not be
+ // taken, a build that failed. It answers 400 with the reason, which the client throws on, so
+ // this branch is the belt to that braces.
+ if (!result.ok) setFailure(result.output || 'It did not say why.');
+ else flashDone();
+ } catch (caught) {
+ setFailure(readableError(caught));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ {failure && (
+ /* Absolute, so the header does not grow and push the panel down while somebody reads it. */
+