The last flicker was one line of text being replaced by another. The title and the hint never depend on anything fetched, so they can paint immediately; only the check on the right and the body have to wait. So the loading state renders the header with nothing in it. The settled case — nearly every load — goes from header to header-plus-check, and nothing on screen moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
304 lines
13 KiB
TypeScript
304 lines
13 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;
|
|
|
|
/*
|
|
The same three bits, answered in ONE request before anything renders.
|
|
|
|
Without it the block cannot know whether it is finished until its children have mounted and
|
|
reported — so it paints open and then folds away, on every single load. That flicker is not a
|
|
rendering problem to tune; the information genuinely was not there yet.
|
|
|
|
So this seeds the opening state, and the callbacks above keep it live once the block is open. Two
|
|
sources for one fact, with a clear division: the summary is what is true on ARRIVAL, the callbacks
|
|
are what changes while somebody is looking.
|
|
*/
|
|
const [summary, setSummary] = useState<{ rootSet: boolean; websiteReady: boolean; mediaReady: boolean } | null>(
|
|
null,
|
|
);
|
|
useEffect(() => {
|
|
void (async () => {
|
|
try {
|
|
setSummary(await clientRef.current.get<typeof summary & object>('/fofos-admin/_config/summary'));
|
|
} catch {
|
|
// A member, or the route failing. Neither is a reason to hold the block hostage — fall back to
|
|
// the old behaviour of opening and letting the children answer.
|
|
setSummary({ rootSet: false, websiteReady: false, mediaReady: false });
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
/*
|
|
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);
|
|
|
|
// Seeded from the summary, so the FIRST paint is already right. `null` stays the sentinel for
|
|
// "nobody has decided", which is what lets a deliberate expand survive everything below.
|
|
useEffect(() => {
|
|
if (summary) setCollapsed((chosen) => (chosen === null ? summary.mediaReady && summary.websiteReady && summary.rootSet : chosen));
|
|
}, [summary]);
|
|
|
|
useEffect(() => {
|
|
setCollapsed((chosen) => {
|
|
// Not settled: force it open if it was shut, 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);
|
|
}
|
|
};
|
|
|
|
/*
|
|
Settled, according to whichever source has spoken.
|
|
|
|
The summary answers on arrival; the children answer from then on. Taking the summary while nothing
|
|
has reported yet, and the live value once anything has, is what makes the first paint correct
|
|
without freezing the block on a snapshot.
|
|
*/
|
|
const reported = websiteReady || mediaReady;
|
|
const settledNow = editing ? false : reported ? settled : Boolean(summary?.rootSet && summary.websiteReady && summary.mediaReady);
|
|
|
|
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.
|
|
// `settledNow` rather than `settled`: between the summary arriving and the children reporting,
|
|
// no child has answered yet, and using the live value would open the block for exactly that gap.
|
|
collapsible={settledNow}
|
|
collapsed={settledNow && 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>);
|
|
}
|
|
|
|
/*
|
|
Both, not either — rendering on the path alone puts the block on screen before it knows whether it
|
|
is finished, which is the flicker this was all for.
|
|
|
|
And it renders the HEADER, with nothing in it. Not a "Loading…" row: that is a line of text which
|
|
then gets replaced by a different line of text, which is a flicker in its own right and was the one
|
|
left over after the last fix. The title and the hint are constants and can paint immediately; what
|
|
waits for data is the check on the right and whatever body turns out to be needed.
|
|
|
|
So the settled case — which is nearly every load — goes from header to header-plus-check, and
|
|
nothing moves at all.
|
|
*/
|
|
if (!saved || !summary) return shell(null);
|
|
|
|
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>
|
|
) : settledNow ? (
|
|
// 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,
|
|
);
|
|
};
|