diff --git a/manifest.ts b/manifest.ts index 737500f..673de5d 100644 --- a/manifest.ts +++ b/manifest.ts @@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest'; */ export const manifest: PluginManifest = { publisher: 'officerdev', - version: '2026.08.29.08', + version: '2026.08.29.09', platform: '>=1.0.0', label: 'Fofos', diff --git a/web/AdministrationPanel.tsx b/web/AdministrationPanel.tsx index 9e99305..4818775 100644 --- a/web/AdministrationPanel.tsx +++ b/web/AdministrationPanel.tsx @@ -1,62 +1,26 @@ -import { Database, FolderRoot } from 'lucide-react'; import { RootLocationBlock } from './RootLocationBlock'; +import { DatabaseBlock } from './DatabaseBlock'; /** * Administration — this plugin's own machinery, not the client's site. * - * ── It is a list of blocks ── + * A list of blocks, and nothing else. Each one owns its own heading, its own state and its own status + * message; this file decides only which of them exist and in what order. * - * Not one form. Administration holds several unrelated settings, and giving each its own titled block - * means adding the next one moves nothing — as against a single form that has to be broken up the - * first time anything joins it. The second block cost one entry, which is the point. - * - * The order is the order things are needed in: where the site is on disk, then how to read its data. + * The order is the order things are needed in: where the site is on this machine, then how to read its + * data. * * ── What used to be here ── * - * An address and a token: where fofos is, over HTTP. That is gone. It has NOT been replaced yet, so - * nothing can currently configure this plugin — which is why `useReady` stays hardcoded false and - * every other section stays locked. The two are the same change and neither is finished. - * - * The server half is untouched: `/_config` still stores a URL and a token, and the proxy in - * `api/router.ts` still reads them. Only the way in from the screen is gone. + * An address and a token: where fofos is, over HTTP. That is gone from the screen. The server half is + * untouched — `/_config` still stores a URL and a token, and the proxy in `api/router.ts` still reads + * them — but nothing can set them, which is why `useReady` stays hardcoded false and every other + * section stays locked. The two are the same unfinished change. */ -/** One titled block. The shape every Administration setting gets, so the second one costs nothing. */ -const Block = ({ - icon: Icon, - title, - hint, - children, -}: { - icon: typeof Database; - title: string; - hint: string; - children?: React.ReactNode; -}) => ( -
-
-

- - {title} -

-

{hint}

-
- {children} -
-); - export const AdministrationPanel = () => (
- - - - - - {/* Deliberately empty. The block exists so the shape is settled and so the screen says what is - coming rather than looking like a section nobody finished — but nothing is decided about what - goes in it, and guessing at fields would be a decision made in the wrong place. */} -

Nothing to set yet.

-
+ +
); diff --git a/web/Block.tsx b/web/Block.tsx new file mode 100644 index 0000000..78cc1d0 --- /dev/null +++ b/web/Block.tsx @@ -0,0 +1,48 @@ +import type { LucideIcon } from 'lucide-react'; + +/** + * One titled block of Administration. + * + * Every setting gets one, so the second and third cost nothing to add — as against a single form that + * has to be broken up the first time anything joins it. + * + * ── `status` is top-right, and it is for things that come and go ── + * + * Beside the title rather than under the controls: it is a remark about the block, not part of what + * you are doing, and put in the flow it pushes everything below it down by a line and then pulls it + * back up — a layout that jumps every time somebody saves. + * + * Each block renders its OWN Block, rather than the panel wrapping them. The status belongs to the + * setting that produced it, and lifting that state up to the panel would put one block's timer in a + * component that has nothing to do with it. + */ +export const Block = ({ + icon: Icon, + title, + hint, + status, + children, +}: { + icon: LucideIcon; + title: string; + hint: string; + /** Top-right. Usually ephemeral — see `useFlash`. */ + status?: React.ReactNode; + children?: React.ReactNode; +}) => ( +
+
+
+

+ + {title} +

+

{hint}

+
+ {/* Reserves nothing when empty. The status is short and the title is not, so there is no reflow + to guard against — and an always-present placeholder would read as a field. */} + {status &&
{status}
} +
+ {children} +
+); diff --git a/web/DatabaseBlock.tsx b/web/DatabaseBlock.tsx new file mode 100644 index 0000000..af3d5b0 --- /dev/null +++ b/web/DatabaseBlock.tsx @@ -0,0 +1,15 @@ +import { Database } from 'lucide-react'; +import { Block } from './Block'; + +/** + * Where the fofos site's own data lives. + * + * Deliberately empty. The block exists so the shape is settled and so the screen says what is coming + * rather than looking like a section nobody finished — but nothing is decided about what goes in it, + * and inventing fields to fill the space would be deciding it in the wrong place. + */ +export const DatabaseBlock = () => ( + +

Nothing to set yet.

+
+); diff --git a/web/RootLocationBlock.tsx b/web/RootLocationBlock.tsx index aa8626d..25446be 100644 --- a/web/RootLocationBlock.tsx +++ b/web/RootLocationBlock.tsx @@ -1,14 +1,24 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Check, Loader2, Pencil } from 'lucide-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 { 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 @@ -48,6 +58,7 @@ export const RootLocationBlock = () => { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [forbidden, setForbidden] = useState(false); + const [flashing, flash] = useFlash(); const load = useCallback(async () => { try { @@ -76,6 +87,7 @@ export const RootLocationBlock = () => { setValue(result.rootPath); setEditing(false); setPicking(false); + flash(); } catch (failure) { setError(readableError(failure)); } finally { @@ -83,12 +95,18 @@ export const RootLocationBlock = () => { } }; + const shell = (children: React.ReactNode, status?: React.ReactNode) => ( + + {children} + + ); + if (forbidden) { - return

Only the owner can change where the site lives.

; + return shell(

Only the owner can change where the site lives.

); } if (!saved) { - return

Loading…

; + return shell(

Loading…

); } const dirty = value.trim() !== saved.rootPath; @@ -102,7 +120,7 @@ export const RootLocationBlock = () => { setError(null); }; - return ( + return shell(
{editing ? ( { {error &&

{error}

} - {/* 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. */} -

- {saved.isSet ? 'Saved.' : 'Not set yet — this is the default until you save it.'} -

+ {/* 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 block's bottom row. Everything you can DO is here, on its own line, so the path above it gets the full width. */} @@ -163,6 +180,12 @@ export const RootLocationBlock = () => { )}
- + , + flashing ? ( + + + Saved + + ) : null, ); }; diff --git a/web/useFlash.ts b/web/useFlash.ts new file mode 100644 index 0000000..375d0f4 --- /dev/null +++ b/web/useFlash.ts @@ -0,0 +1,33 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * A message that shows itself and then goes away — "Saved", and whatever joins it. + * + * ── Why this is not a piece of state somebody sets and clears ── + * + * The timer is held in a ref and CLEARED before each new one is set. Without that, saving twice in + * quick succession leaves the first timeout running, and it fires against the second save — the + * confirmation vanishes a moment after appearing, which looks like the second save failed. + * + * The cleanup on unmount matters for the same reason it always does: this lives in a panel that is + * swapped out whenever the nav moves, and a timeout that outlives its component sets state on nothing. + */ +export const useFlash = (ms = 5000) => { + const [showing, setShowing] = useState(false); + const timer = useRef | null>(null); + + const clear = () => { + if (timer.current) clearTimeout(timer.current); + timer.current = null; + }; + + const flash = useCallback(() => { + clear(); + setShowing(true); + timer.current = setTimeout(() => setShowing(false), ms); + }, [ms]); + + useEffect(() => clear, []); + + return [showing, flash] as const; +};