Files
fofos-admin/web/RootLocationBlock.tsx
T
pastilhasandClaude Opus 5 003613872d provision <root>/media, beside the checkout
where the images and the files will live. same row shape as the checkout above it,
because it is the same kind of fact: a thing that either is or is not at a known
path under the root, with one button that puts it there.

BESIDE `website`, never inside it, and that is the point of it being here rather
than a folder in the repository:

  - it must SURVIVE the checkout. deleting `website` and cloning it again is a
    normal thing to do when something is wrong with it, and it must never be the
    thing that destroys the client's photographs.
  - it must never be TRACKED. media in git is a repository nobody can clone a year
    from now, and a .gitignore is a promise somebody eventually breaks with
    `git add -f`.

both under one root, so a backup of the root is the whole site — the code and the
things it serves. that is the property that makes the root worth being a setting.

NOTHING ABOUT ITS INSIDES IS DECIDED. this creates the directory and reports
whether it is there; the layout, who writes to it and what goes where are still
being described. inventing a folder tree now would settle that in the file least
able to change its mind, because directories on disk are not a migration you can
run.

`mkdir -p`, so it also creates a root that is still only a default — saving a root
creates nothing, and provisioning into a path that does not exist is the ordinary
first run. idempotent by the same flag, which is what makes the button safe to
offer next to a status that might be stale.

no console for this one: making a directory either works or gives one line about
why not, and a black transcript box for a single sentence makes a small thing look
like a large one.

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

204 lines
7.9 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';
import { DirectoryPicker } from './DirectoryPicker';
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,
);
};