Files
fofos-admin/web/PublishButton.tsx
pastilhasandClaude Opus 5 6e33e89e2c finish the carousel rename in the panels
Two of these were live: PageBuilderPanel still compared kind === 'gallery' in
two places, so a carousel block's editor and its Choose button were both dead
branches. The type checker could not see them — both compare against string.

The rest is copy somebody reads: "Change gallery", "Name a new gallery",
"Reading the gallery", "Add to this gallery", Home's Gallery heading, and the
"Create Gallery" file action, now "Carousel from folder".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:06:06 +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 carousel, 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>
);
};