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>
158 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
};
|