Build sits beside Publish in the header

They answer different questions, and the difference is not obvious from the words:

  Publish  copies what you edited in development over production, then renders the site.
  Build    renders production as it already is. Nothing moves.

Publish is the one to press after editing anything. Build is for when the content is already promoted
and only the code changed — a template fix, a new rendition ladder, a failed build to retry. Offering
only Publish would mean every retry of a build also re-copied a database and took another backup,
which is a lot of machinery for "try that again".

Build reads first and quieter, Publish last and solid: the row runs towards the action somebody
actually came for.

One busy state and one failure panel between them, because only one can be running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 10:22:05 +01:00
co-authored by Claude Opus 5
parent 69c9c23e48
commit 28798ad67b
+98 -49
View File
@@ -1,5 +1,5 @@
import { useRef, useState } from 'react';
import { Check, CircleAlert, Loader2, Upload } from 'lucide-react';
import { Check, CircleAlert, Hammer, Loader2, Upload } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { readableError } from './errors';
@@ -7,94 +7,143 @@ import { useFlash } from './useFlash';
import { useReady } from './useReady';
/**
* Publish, from wherever you happen to be.
* Publish and Build, from wherever you happen to be.
*
* ── Why it is in the header and not only in Administration ──
* ── Why they are 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.
* panel 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.
* They are the same actions Administration's card offers. Two entry points to one thing is right here:
* that card belongs to a row about the production server and reports what a build produced, while
* these are the verbs at the end of a sentence somebody has just written.
*
* ── It takes minutes, so it says so ──
* ── Why both, when Publish already builds ──
*
* 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.
* They answer different questions, and the difference is not obvious from the words:
*
* ── Failure has to be readable, and there is no room for it ──
* Publish copies what you edited in DEVELOPMENT over production, then renders the site.
* Build renders production as it already is. Nothing moves.
*
* 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.
* Publish is the one to press after editing anything. Build is for when the content is already
* promoted and only the code changed — a template fix, a new rendition ladder, a failed build to
* retry. Offering only Publish would mean every retry of a build also re-copied a database and took
* another backup, which is a lot of machinery for "try that again".
*
* ── One busy state, one failure panel ──
*
* Only one of these can be running, so they share both. A header is one line, and a failure here is
* usually a build error — long, and worth reading — so it opens a panel underneath rather than
* replacing a label. Positioned absolutely so nothing shifts, and it stays until dismissed: a message
* that vanishes on a timer is one somebody was still reading.
*/
type Action = 'publish' | 'build';
const ROUTES: Record<Action, string> = {
publish: '/fofos-admin/_config/static/publish',
build: '/fofos-admin/_config/static/build',
};
const LABELS: Record<Action, { idle: string; busy: string; done: string; title: string }> = {
publish: {
idle: 'Publish',
busy: 'Publishing…',
done: 'Published',
title: 'Copy what you have edited into production, then rebuild the site',
},
build: {
idle: 'Build',
busy: 'Building…',
done: 'Built',
title: 'Rebuild the site from what production already holds. Nothing is copied.',
},
};
export const PublishButton = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const { ready } = useReady();
const [busy, setBusy] = useState(false);
const [busy, setBusy] = useState<Action | null>(null);
const [failure, setFailure] = useState<string | null>(null);
const [done, flashDone] = useFlash(6000);
const [finished, setFinished] = useState<Action | null>(null);
const publish = async () => {
setBusy(true);
const run = async (action: Action) => {
setBusy(action);
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.
const result = await clientRef.current.post<{ ok: boolean; output: string }>(ROUTES[action], {});
// `ok: false` is a REFUSAL the server explains — a backup that could not be taken, a build that
// failed. It answers 400 with the reason, which the client throws on, so this is the belt to
// that braces rather than the usual path.
if (!result.ok) setFailure(result.output || 'It did not say why.');
else flashDone();
else {
setFinished(action);
flashDone();
}
} catch (caught) {
setFailure(readableError(caught));
} finally {
setBusy(false);
setBusy(null);
}
};
const label = (action: Action) => {
const words = LABELS[action];
if (busy === action) return words.busy;
if (done && finished === action) return words.done;
return words.idle;
};
const icon = (action: Action) => {
if (busy === action) return <Loader2 className="size-3 animate-spin" />;
if (done && finished === action) return <Check className="size-3" />;
if (action === 'publish') return <Upload className="size-3" />;
return <Hammer className="size-3" />;
};
return (
<div className="relative shrink-0">
<div className="relative flex shrink-0 items-center gap-1.5">
{failure && (
<CircleAlert className="size-3.5 text-destructive" aria-hidden />
)}
{/* Build first and quieter. Publish is the one to press, so it reads last and solid — the row
runs left to right towards the action somebody actually came for. */}
<Button
size="sm"
variant={failure ? 'destructive' : 'default'}
variant="outline"
className="h-6 gap-1.5 px-2 text-xs"
onClick={() => void publish()}
// Nothing to publish to until fofos is connected. `useReady` is the same answer the nav uses
// to lock the sections, so the button is available exactly when the sections are.
disabled={busy || !ready}
title={
ready
? 'Copy what you have edited into production, then rebuild the site'
: 'Connect fofos in Administration first'
}
onClick={() => void run('build')}
disabled={busy !== null || !ready}
title={ready ? LABELS.build.title : 'Connect fofos in Administration first'}
>
{busy ? (
<Loader2 className="size-3 animate-spin" />
) : failure ? (
<CircleAlert className="size-3" />
) : done ? (
<Check className="size-3" />
) : (
<Upload className="size-3" />
)}
{busy ? 'Publishing…' : failure ? 'Failed' : done ? 'Published' : 'Publish'}
{icon('build')}
{label('build')}
</Button>
<Button
size="sm"
className="h-6 gap-1.5 px-2 text-xs"
onClick={() => void run('publish')}
// Nothing to publish to until fofos is connected. `useReady` is the same answer the nav uses
// to lock the sections, so these are available exactly when the sections are.
disabled={busy !== null || !ready}
title={ready ? LABELS.publish.title : 'Connect fofos in Administration first'}
>
{icon('publish')}
{label('publish')}
</Button>
{failure && (
/* Absolute, so the header does not grow and push the panel down while somebody reads it. */
<div className="absolute right-0 top-7 z-50 w-96 max-w-[80vw] rounded-md border border-destructive/50 bg-background p-3 shadow-lg">
<p className="mb-2 text-xs font-medium text-destructive">Publish failed</p>
<p className="mb-2 text-xs font-medium text-destructive">That did not work</p>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] text-muted-foreground">
{failure}
</pre>