Files
fofos-admin/web/RootLocationBlock.tsx
T
pastilhasandClaude Opus 5 cc283ee7b6 Root location folds away once there is nothing left to do
Everything under it is in order most of the time, and a card that is four
paragraphs of "fine" pushes the settings that are not fine off the screen. So it
shuts, with a check on the right, and opens on a click.

"Finished" is three bits reported upward by the parts that own them: the path is
saved, the checkout is a repository, and media/ both exists and is current. None
of those is knowable in the parent — each child fetches its own status — and
lifting all of it up to answer one question would be a far worse trade than
three booleans.

It is only collapsible while settled. A block you can shut on unfinished setup
is a way to hide the thing you came here to fix. And the children are NOT
RENDERED while it is shut rather than hidden, because a collapsed block whose
children still poll is a settled panel making requests all afternoon about
something nobody is looking at.

The `null` in `collapsed` is a sentinel for "nobody has decided", which is what
lets a deliberate expand survive. Setting it to false while unsettled spent it
on the first render — before any status had arrived — so the block could never
fold itself away at all.

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

249 lines
10 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, CircleCheck, FolderRoot, 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';
// Shared, since kanban wanted the same picker — see the note in its own file.
import { DirectoryPicker } from 'officerdev';
import { Block } from './Block';
import { WebsiteCheckout } from './WebsiteCheckout';
import { MediaFolder } from './MediaFolder';
import { useFlash } from './useFlash';
/**
* Where the fofos site is on this machine.
*
* ── "Saved" is a moment, not a state ──
*
* It appears top-right when a save actually lands and is gone five seconds later. As a permanent line
* it said nothing — everything on this screen has been saved, all the time — while looking exactly
* like feedback, so the one moment it meant something was indistinguishable from the rest.
*
* "Not set yet" is the opposite and stays: that IS a state, and it is the one worth knowing.
*
* ── 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 [flashing, flash] = useFlash();
/*
Finished, and therefore worth folding away.
Three bits reported up by the parts that own them: the path is saved, the checkout is a repository,
and `media/` both exists and is current. None of those is knowable here — each child fetches its
own status, and lifting all of it into this component to answer one question would be a much worse
trade than three booleans.
*/
const [websiteReady, setWebsiteReady] = useState(false);
const [mediaReady, setMediaReady] = useState(false);
const settled = Boolean(saved?.isSet) && websiteReady && mediaReady && !editing;
/*
Shut on the first arrival at a settled block, and open the moment anything needs attention.
`null` means nobody has decided yet, which is what lets a deliberate expand SURVIVE — once somebody
has opened it, `collapsed` is false and this effect will not run again while it stays settled. A
plain `useState(true)` would slam it shut on every re-render that touched the dependency.
*/
const [collapsed, setCollapsed] = useState<boolean | null>(null);
useEffect(() => {
setCollapsed((chosen) => {
// Not settled yet: force it open if somebody had shut it, but LEAVE `null` alone. Writing false
// here was a bug — `settled` is false on the first render, so the sentinel was spent before it
// could mean anything and the block never folded itself away.
if (!settled) return chosen === null ? null : false;
return chosen === null ? true : chosen;
});
}, [settled]);
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);
flash();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const shell = (children: React.ReactNode, status?: React.ReactNode) => (
<Block
icon={FolderRoot}
title="Root location"
hint="Where the fofos site is on this machine."
status={status}
// Only offered once there is nothing to do. A block you can shut on unfinished setup is a way to
// hide the thing you came here to fix.
collapsible={settled}
collapsed={settled && collapsed !== false}
onCollapsedChange={setCollapsed}
>
{children}
</Block>
);
if (forbidden) {
return shell(<p className="text-xs text-muted-foreground">Only the owner can change where the site lives.</p>);
}
if (!saved) {
return shell(<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 shell(
<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>}
{/* Only the state worth knowing. Once it is set, this line has nothing to say and goes. */}
{!saved.isSet && (
<p className="text-xs text-muted-foreground/80">Not set yet — this is the default until you save it.</p>
)}
{/* The checkout that lives under that path. Same section, because the path and the thing at the
path are one question — split apart, somebody could change the root here and read a status
about the old one there, with both looking equally current. Hidden while editing: it
describes what is SAVED, and a half-typed path has nothing at it. */}
{!editing && <WebsiteCheckout rootPath={saved.rootPath} onSettled={setWebsiteReady} />}
{/* Beside the checkout, under the same root — a backup of the root is then the whole site, the
code and the things it serves. Hidden while editing for the same reason as the checkout. */}
{!editing && <MediaFolder rootPath={saved.rootPath} onSettled={setMediaReady} />}
{/* 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>,
flashing ? (
<span className="flex items-center gap-1 text-xs text-success">
<Check className="size-3.5" />
Saved
</span>
) : settled ? (
// Big, and the only thing in the row. Everything under here is in order, and a shut block whose
// whole message is "nothing to do" should say it in one glance rather than in a sentence.
<CircleCheck className="size-6 text-success" />
) : null,
);
};