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