Files
fofos-admin/web/RootLocationBlock.tsx
T
pastilhasandClaude Opus 5 df52045eaa the path gets the row, the actions get their own
Root location is two states now. reading, it is a path and one Edit. editing, it
is a field and the things you can do to it.

the actions moved to the block's BOTTOM row, off the value's line. an absolute
path is long, and it was being truncated to make room for buttons that are not
needed most of the time — the thing somebody opened this to read was the thing
squeezed. shown, it wraps with `break-all` rather than truncating, because a path
is read from both ends: the machine at the front, the folder at the back, and
hiding either is worse than two lines.

Edit is a mode and not an always-live field. this is looked at far more often than
it is changed, and a live input invites a stray keystroke into a path everything
else will depend on. Cancel restores what is stored, so leaving the mode cannot
leave half a path on screen looking like the setting.

"back to the default" is gone, as asked — and `clearRootPath` and its DELETE route
with it, since that button was the only caller. an unreferenced endpoint is the
same rot as an unreferenced component, and this one was a second, quieter Save
that wrote a value nobody chose.

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

169 lines
6.3 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, Loader2, Pencil } 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 { DirectoryPicker } from './DirectoryPicker';
/**
* Where the fofos site is on this machine.
*
* ── The default is a suggestion until it is saved ──
*
* An unset root reports `<owner home>/fofos` and says so. It is not written to the database by being
* looked at — a value somebody has never agreed to must not become one they appear to have chosen, and
* a default that follows the machine is more useful than one frozen on install day.
*
* ── Typing and picking are the same field ──
*
* The picker fills the field; the field is what saves. So there is one value on screen and one Save,
* and picking a folder never commits anything by itself — the path is visible and editable before it
* is stored. It also means a location OUTSIDE the owner's home can still be typed, which the picker
* cannot reach.
*
* ── Two states, and the actions are on their own row ──
*
* Reading, it is a path and one Edit. Editing, it is a field and the things you can do to it. The
* actions sit on the block's BOTTOM row rather than beside the value, so the path gets the full width
* — it is long, absolute and the thing somebody came here to read, and it was being truncated to make
* room for buttons that are not needed most of the time.
*
* Edit is a mode rather than an always-live field because this is a setting that is looked at far more
* often than it is changed, and a live input invites a stray keystroke into a path that everything
* else depends on.
*/
type Settings = { rootPath: string; isSet: boolean };
export const RootLocationBlock = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [saved, setSaved] = useState<Settings | null>(null);
const [value, setValue] = useState('');
const [editing, setEditing] = useState(false);
const [picking, setPicking] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [forbidden, setForbidden] = useState(false);
const load = useCallback(async () => {
try {
const current = await clientRef.current.get<Settings>('/fofos-admin/_config/root');
setSaved(current);
setValue(current.rootPath);
} catch (failure) {
// A member looking at an owner-only setting is not a fault and needs a different sentence.
if ((failure as { status?: number })?.status === 403) setForbidden(true);
else setError(readableError(failure));
}
}, []);
useEffect(() => {
void load();
}, [load]);
const save = async () => {
setBusy(true);
setError(null);
try {
const result = await clientRef.current.put<Settings>('/fofos-admin/_config/root', { path: value });
setSaved(result);
// From the server, not from what was typed — `~` is expanded and trailing slashes go, so the
// field must show what was actually stored rather than what was sent.
setValue(result.rootPath);
setEditing(false);
setPicking(false);
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
if (forbidden) {
return <p className="text-xs text-muted-foreground">Only the owner can change where the site lives.</p>;
}
if (!saved) {
return <p className="text-xs text-muted-foreground">Loading…</p>;
}
const dirty = value.trim() !== saved.rootPath;
const stopEditing = () => {
// Back to what is stored, so leaving the mode cannot leave a half-typed path on screen looking
// like the setting.
setValue(saved.rootPath);
setEditing(false);
setPicking(false);
setError(null);
};
return (
<div className="flex flex-col gap-3">
{editing ? (
<Input
autoFocus
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="/home/you/fofos"
className="w-full font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
) : (
// `break-all`, not `truncate`. An absolute path is read from BOTH ends — the machine at the
// front and the folder at the back — so hiding either one is worse than wrapping.
<p className="rounded-md border border-border bg-muted/30 px-3 py-2 font-mono text-xs break-all">
{saved.rootPath}
</p>
)}
{editing && picking && (
<DirectoryPicker
onCancel={() => setPicking(false)}
onPick={(absolutePath) => {
setValue(absolutePath);
setPicking(false);
}}
/>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{/* Says which of the two states this is in. "Not set" reading identically to "set to exactly the
default" is how somebody comes to believe they configured something they did not. */}
<p className="text-xs text-muted-foreground/80">
{saved.isSet ? 'Saved.' : 'Not set yet — this is the default until you save it.'}
</p>
{/* The block's bottom row. Everything you can DO is here, on its own line, so the path above it
gets the full width. */}
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
{editing ? (
<>
<Button variant="outline" size="sm" onClick={() => setPicking((open) => !open)}>
{picking ? 'Close browser' : 'Browse'}
</Button>
<Button size="sm" onClick={() => void save()} disabled={busy || !dirty || !value.trim()}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
Save
</Button>
<Button variant="ghost" size="sm" onClick={stopEditing} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil className="size-3.5" />
Edit
</Button>
)}
</div>
</div>
);
};