From 69c9c23e48aec25340a75c897cee8b86c22b30cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 30 Aug 2026 10:17:11 +0100 Subject: [PATCH] Publish is in the header of every section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 it. Somebody who does not know the panel does not find it at all, and concludes their edit is live when it is not. Administration's card stays. Two entry points to one action is right here: that card belongs to a row about the production server and reports what a build produced, while this is the verb at the end of a sentence somebody has just written. It takes minutes and says so, because 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. Failure opens a panel under the header rather than replacing the label. The server's own words are usually a build error, which is long and worth reading, and a header is one line. Positioned absolutely so nothing shifts, and it stays until dismissed — a message that vanishes on a timer is one somebody was still reading. Disabled until `useReady`, the same answer the nav uses to lock the sections, so the button is available exactly when the sections are. Co-Authored-By: Claude Opus 5 (1M context) --- web/DetailHeader.tsx | 9 ++++ web/PublishButton.tsx | 108 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 web/PublishButton.tsx 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. */ +
+

Publish failed

+
+            {failure}
+          
+ +
+ )} +
+ ); +};