Files
fofos-admin/web/Console.tsx
T
pastilhasandClaude Opus 5 bda59bdf03 one spinner per action, one button for both, and the shared console
Four things, all of them the panel telling a small lie.

A single `busy` boolean put BOTH rows into a spinner while only the pressed one
was doing anything — the other looked started, then finished without having done
a thing. It is now which action is running, so a row spins when it is the one
running or when both are.

Pushing both is the common case and was two clicks. It is one, running
production then development sequentially: they run the same drizzle against one
Postgres, and two at once buys nothing but an interleaved transcript.

The status is re-read after every push regardless of outcome. It was taken from
the route's reply, which only exists on the path where the push SUCCEEDED — a
failure answers 400 and throws, and a push that failed halfway can still have
changed the table count. That is the difference between the panel being right
and the panel being right when nothing went wrong.

And the output is the same console the checkout uses, extracted rather than
copied: black, copyable, gone a few seconds after a success and never after a
failure. WebsiteCheckout keeps its behaviour and loses the state that used to
implement it.

Restore stays disabled. What a backup IS here is still undecided, and a button
that guesses at the source of a restore does not belong near a client's data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:47:58 +01:00

66 lines
2.7 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { copyToClipboard } from 'helpers/clipboard';
import { useFlash } from './useFlash';
/**
* The output of something that ran, in the shape of the thing that ran it.
*
* A console, so a transcript from git or drizzle looks like a transcript rather than like a paragraph
* of the form around it. Scrolled, not truncated: the useful line of a failure is usually the last one.
*
* ── A success goes away; a failure never does ──
*
* Nobody reads a successful transcript twice, and one that stays turns every panel into a log. A
* FAILURE has no timer at all rather than a long one — nothing should be able to take it off the screen
* except the next attempt, because it is the only thing here somebody has to act on.
*
* ── The copy button is pinned to the box, not to the text ──
*
* A button that scrolls away with a long transcript is missing at exactly the moment the transcript is
* worth copying. `pr-10` on the `pre` keeps the text out from under it.
*
* ── Re-running with identical output ──
*
* The effect keys on `output`, so pressing a button twice and getting the same text back would not
* re-show a console that had timed out. Callers clear it to `null` before starting, which they do
* anyway to avoid showing the previous run's words next to a spinner.
*/
export const Console = ({ output, failed, ms = 6000 }: { output: string | null; failed: boolean; ms?: number }) => {
const [visible, setVisible] = useState(false);
const [copied, flashCopied] = useFlash(1500);
useEffect(() => {
if (!output) {
setVisible(false);
return;
}
setVisible(true);
if (failed) return;
const timer = setTimeout(() => setVisible(false), ms);
return () => clearTimeout(timer);
}, [output, failed, ms]);
if (!output || !visible) return null;
return (
<div className="relative">
<pre className="max-h-40 overflow-auto rounded-md border border-border bg-black p-3 pr-10 font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
<code className={failed ? 'text-red-400' : 'text-neutral-200'}>{output}</code>
</pre>
<button
type="button"
onClick={async () => {
if (await copyToClipboard(output)) flashCopied();
}}
aria-label="Copy this output"
title="Copy"
className="absolute top-1.5 right-1.5 rounded border border-border/60 bg-black/80 p-1.5 text-neutral-400 transition-colors hover:text-neutral-100"
>
{copied ? <Check className="size-3.5 text-success" /> : <Copy className="size-3.5" />}
</button>
</div>
);
};