defaults to `<owner home>/fofos`, and stores the FULL path — never the tilde. a tilde means "whoever is asking", and the people asking will not all be the same person: this plugin is grantable, and the site is in one place regardless of who is looking at it. stored as `~/fofos` it would give every user a different answer to a question that has one. THE DEFAULT IS COMPUTED, NOT WRITTEN. no row means the default is worked out on every read, so it follows the machine. writing it in at install freezes whichever home existed on install day, and a restore onto a different machine then points at a directory that is not there while looking deliberately chosen. a saved path is a decision and is stored exactly as given; only its absence is derived. the form says which of the two it is showing, because "not set" reading identically to "set to the default" is how somebody comes to believe they configured something they did not. THE PICKER USES THE PLATFORM'S FILE API — /file-browser/ls and /mkdir — not routes of this plugin's own. they already refuse to leave home, and they already tell a missing directory from a forbidden one, which is a distinction that cost somebody an afternoon and would have been re-guessed here. shipping a second listing and mkdir would be a second filesystem surface to audit for the sake of one form. that API is an execution capability and never grantable, so for anybody but the owner it 403s — said in a sentence rather than shown as an empty tree, because an empty listing is data and must never be how a refusal looks. it also browses the owner's home and nothing above it; a path outside home is still reachable by typing, since the picker fills the field and the FIELD is what saves. one value on screen, one Save, and picking a folder commits nothing by itself. "Use this folder" takes the one that is OPEN rather than one highlighted in the list. that makes looking inside and choosing one gesture, and it is the only way to choose a folder with nothing in it — which a freshly made one always is. a SECOND table, not a column on fofos_admin_connection, whose url and token are both notNull: a root path could not be saved there without also having an HTTP connection, and that connection is being replaced rather than extended. when it goes, its table is a clean drop instead of surgery on columns that outlived it. the rule against tables in this plugin is unbent — what it forbids is the CLIENT'S work, and this row holds a path somebody can retype. needs `bun db:push`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 lines
4.9 KiB
TypeScript
141 lines
4.9 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { Check, Loader2, RotateCcw } 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.
|
|
*/
|
|
|
|
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 [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);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const reset = async () => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const result = await clientRef.current.delete<Settings>('/fofos-admin/_config/root');
|
|
setSaved(result);
|
|
setValue(result.rootPath);
|
|
} 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;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Input
|
|
value={value}
|
|
onChange={(event) => setValue(event.target.value)}
|
|
placeholder="/home/you/fofos"
|
|
className="min-w-0 flex-1 font-mono text-xs"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
<Button variant="outline" size="sm" onClick={() => setPicking((open) => !open)}>
|
|
{picking ? 'Close' : '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>
|
|
{saved.isSet && (
|
|
<Button variant="ghost" size="sm" onClick={() => void reset()} disabled={busy} title="Back to the default">
|
|
<RotateCcw className="size-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{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>
|
|
</div>
|
|
);
|
|
};
|