Files
fofos-admin/web/PublishButton.tsx
T
pastilhasandClaude Opus 5 28798ad67b 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>
2026-08-30 10:22:05 +01:00

158 lines
6.3 KiB
TypeScript

import { useRef, useState } from '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';
import { useFlash } from './useFlash';
import { useReady } from './useReady';
/**
* Publish and Build, from wherever you happen to be.
*
* ── 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 simply does not find it, and concludes their edit went live when it did not.
*
* 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.
*
* ── Why both, when Publish already builds ──
*
* 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".
*
* ── 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<Action | null>(null);
const [failure, setFailure] = useState<string | null>(null);
const [done, flashDone] = useFlash(6000);
const [finished, setFinished] = useState<Action | null>(null);
const run = async (action: Action) => {
setBusy(action);
setFailure(null);
try {
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 {
setFinished(action);
flashDone();
}
} catch (caught) {
setFailure(readableError(caught));
} finally {
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 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="outline"
className="h-6 gap-1.5 px-2 text-xs"
onClick={() => void run('build')}
disabled={busy !== null || !ready}
title={ready ? LABELS.build.title : 'Connect fofos in Administration first'}
>
{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">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>
<Button variant="ghost" size="sm" className="mt-2 h-6 text-xs" onClick={() => setFailure(null)}>
Dismiss
</Button>
</div>
)}
</div>
);
};