kanban wanted the same one and a plugin cannot import from a sibling, so it now lives in the officerdev barrel and this imports it from there. nothing in it was fofos-specific — it browses the owner's home over the platform's file API. deleted here rather than kept as a second copy. the copy that drifts is always the one somebody is looking at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
205 lines
8.0 KiB
TypeScript
205 lines
8.0 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { Check, 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();
|
|
|
|
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}>
|
|
{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} />}
|
|
|
|
{/* 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} />}
|
|
|
|
{/* 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>
|
|
) : null,
|
|
);
|
|
};
|