From 9cbbc28c82875a4f6089e0c69e37ef1b658e370a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 00:44:46 +0100 Subject: [PATCH] "Saved" is a moment, top-right, and gone in five seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit it was a permanent line under the controls saying "Saved." — which said nothing, because everything on this screen has been saved, all the time. it looked exactly like feedback, so the one moment it actually meant something was indistinguishable from every moment it did not. now it appears when a save LANDS and clears itself. top-right of the block, beside the title rather than in the flow: it is a remark about the block, not part of what you are doing, and inline it pushes everything below down a line and then pulls it back up — a layout that jumps every time somebody saves. "Not set yet" stays. that IS a state, and it is the one worth knowing; it goes when it stops being true rather than being replaced by a line that never changes. the timer is a ref and is cleared before each new one. without that, saving twice in quick succession leaves the first timeout running and it fires against the second save — the confirmation vanishing a moment after appearing, which reads as the second save failing. Block moved to its own file and each setting now renders its own. the status belongs to the setting that produced it, and lifting one block's timer into the panel would put it in a component with nothing to do with it. that is also what made the top-right slot possible without absolutely positioning anything. Co-Authored-By: Claude Opus 5 --- manifest.ts | 2 +- web/AdministrationPanel.tsx | 58 +++++++------------------------------ web/Block.tsx | 48 ++++++++++++++++++++++++++++++ web/DatabaseBlock.tsx | 15 ++++++++++ web/RootLocationBlock.tsx | 43 ++++++++++++++++++++------- web/useFlash.ts | 33 +++++++++++++++++++++ 6 files changed, 141 insertions(+), 58 deletions(-) create mode 100644 web/Block.tsx create mode 100644 web/DatabaseBlock.tsx create mode 100644 web/useFlash.ts 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; +};