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 `/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(null); const [value, setValue] = useState(''); const [editing, setEditing] = useState(false); const [picking, setPicking] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [forbidden, setForbidden] = useState(false); const [flashing, flash] = useFlash(); const load = useCallback(async () => { try { const current = await clientRef.current.get('/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('/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) => ( {children} ); if (forbidden) { return shell(

Only the owner can change where the site lives.

); } if (!saved) { return shell(

Loading…

); } 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(
{editing ? ( 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.

{saved.rootPath}

)} {editing && picking && ( setPicking(false)} onPick={(absolutePath) => { setValue(absolutePath); setPicking(false); }} /> )} {error &&

{error}

} {/* Only the state worth knowing. Once it is set, this line has nothing to say and goes. */} {!saved.isSet && (

Not set yet — this is the default until you save it.

)} {/* 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 && } {/* 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 && } {/* The block's bottom row. Everything you can DO is here, on its own line, so the path above it gets the full width. */}
{editing ? ( <> ) : ( )}
, flashing ? ( Saved ) : null, ); };