Files
fofos-admin/web/DatabaseBlock.tsx
T
pastilhasandClaude Opus 5 30c52e6449 one button, and it appears when there is something to press
Two buttons was the wrong model, and it failed in the worst way: Create keyed
off the SAVED names, so on a fresh screen with both fields filled it did not
render at all — and the hint explaining why was behind the same condition. Two
filled fields, one Save, and nothing saying what came next.

Naming and creating are not separate decisions from the outside. You type two
names because you want two databases. So one button that records them and
creates whatever is missing, labelled with whichever of those it is about to do,
and disabled only when there is genuinely nothing to press.

The tick beside each field now asks pg_database about the text in the BOX rather
than the text last saved. That is the question somebody typing a name has, and
answering it about a different name was worse than silence.

The PUT that only stored names goes with the second button. Nothing called it,
and a second way to write the same two columns is a second thing that can drift.

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

169 lines
6.4 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, CircleCheck, CircleDashed, Database, Loader2, Plus } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { readableError } from './errors';
import { Block } from './Block';
import { useFlash } from './useFlash';
/**
* Where the fofos site's own data lives — two databases, named here.
*
* ── ONE button ──
*
* It shipped with two — Save, then Create — on the reasoning that naming and creating are different
* decisions. They are not, from the outside: you type two names because you want two databases. Worse,
* Create keyed off the SAVED names, so on a fresh screen with both fields filled it did not appear at
* all, and the hint explaining why was behind the same condition. Two filled fields, one Save button,
* and no way to tell what came next.
*
* So one button that records the names and creates whatever is not there, labelled with whichever of
* those it is actually about to do. Recording the name you just used to make a database is not a side
* effect worth a second click.
*
* ── It reports what is THERE, against what is TYPED ──
*
* The tick beside each field asks `pg_database` whether a database of that name exists, and it asks
* about the text in the box rather than the text that was last saved. That is the question somebody
* typing a name actually has, and answering it about a different name would be worse than silence.
*/
type Named = { name: string | null; exists: boolean | null };
type Status = { production: Named; development: Named; available: string[] };
export const DatabaseBlock = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [status, setStatus] = useState<Status | null>(null);
const [production, setProduction] = useState('');
const [development, setDevelopment] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [flashing, flash] = useFlash();
const load = useCallback(async () => {
try {
const current = await clientRef.current.get<Status>('/fofos-admin/_config/databases');
setStatus(current);
// Only seed the fields from the server when they are untouched, so a reload landing mid-typing
// cannot replace what somebody is in the middle of writing.
setProduction((typed) => typed || (current.production.name ?? ''));
setDevelopment((typed) => typed || (current.development.name ?? ''));
} catch {
setStatus(null);
}
}, []);
useEffect(() => {
void load();
}, [load]);
/** Records the names and creates whichever is missing. The server does both, in that order. */
const apply = async () => {
setBusy(true);
setError(null);
try {
const result = await clientRef.current.post<{ created: string[]; status: Status }>(
'/fofos-admin/_config/databases/create',
{ production, development },
);
setStatus(result.status);
flash();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const field = (label: string, hint: string, value: string, onChange: (next: string) => void) => {
// Against the typed text and the real list from `pg_database`. Nothing to say about an empty box.
const typed = value.trim();
const exists = typed ? (status?.available.includes(typed) ?? false) : null;
const Icon = exists ? CircleCheck : CircleDashed;
return (
<div className="space-y-1">
<div className="flex items-baseline gap-2">
<label className="text-xs font-medium">{label}</label>
<span className="text-xs text-muted-foreground/70">{hint}</span>
</div>
<div className="flex items-center gap-2">
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={label === 'Production' ? 'fofos_website' : 'fofos_website_dev'}
className="w-full font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
<span className="flex w-24 shrink-0 items-center gap-1 text-xs text-muted-foreground">
{exists === null ? null : (
<>
<Icon className={`size-3.5 ${exists ? 'text-success' : ''}`} />
{exists ? 'exists' : 'not yet'}
</>
)}
</span>
</div>
</div>
);
};
const dirty =
production.trim() !== (status?.production.name ?? '') || development.trim() !== (status?.development.name ?? '');
// Typed, non-empty, and not on the instance. What the one button is about to make.
const missing = [production.trim(), development.trim()].filter(
(name) => name && !(status?.available.includes(name) ?? false),
);
const label = missing.length ? `Create ${missing.length === 1 ? missing[0] : 'databases'}` : 'Save names';
return (
<Block
icon={Database}
title="Database"
hint="Where the fofos site's own data lives — on Officer's own Postgres."
status={
flashing ? (
<span className="flex items-center gap-1 text-xs text-success">
<Check className="size-3.5" />
Saved
</span>
) : undefined
}
>
{!status ? null : (
<>
{field('Production', 'what the live site reads', production, setProduction)}
{field('Development', 'what you work against', development, setDevelopment)}
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex justify-end border-t border-border pt-3">
{/* Nothing missing and nothing changed means there is genuinely nothing to press. */}
<Button
size="sm"
onClick={() => void apply()}
disabled={busy || (!missing.length && !dirty) || !production.trim() || !development.trim()}
>
{busy ? (
<Loader2 className="size-3.5 animate-spin" />
) : missing.length ? (
<Plus className="size-3.5" />
) : (
<Check className="size-3.5" />
)}
{label}
</Button>
</div>
</>
)}
</Block>
);
};