diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx
index 526691f..a901f9c 100644
--- a/web/DetailPanel.tsx
+++ b/web/DetailPanel.tsx
@@ -9,6 +9,7 @@ import { LayoutPanel } from './sections/LayoutPanel';
import { PagesPanel } from './sections/PagesPanel';
import { PageBuilderPanel } from './sections/PageBuilderPanel';
import { PublishButton } from './PublishButton';
+import { SyncButton } from './SyncButton';
import { SaveTargetProvider, useSaveButton } from './SaveTarget';
import { DEVICES, ViewModeProvider, useViewMode, type DeviceName, type Mode } from './ViewMode';
import { MessagesPanel } from './MessagesPanel';
@@ -217,9 +218,14 @@ export const DetailPanel = () => {
- {/* Left of Publish, and in that order for a reason: save what you wrote, then make it
- public. A row that put them the other way round would read as the sequence backwards. */}
+ {/* The order is the sequence: save what you wrote, make the media web-ready, then make
+ it public. A row that put them any other way round would read as the steps backwards.
+
+ Sync sits left of Build for the same reason Build sits left of Publish — it is what
+ has to have happened first, and a stale `derived/` is a picture missing from the page
+ you just built. */}
+
diff --git a/web/MediaSync.tsx b/web/MediaSync.tsx
index 4d55342..a58dbe0 100644
--- a/web/MediaSync.tsx
+++ b/web/MediaSync.tsx
@@ -1,30 +1,17 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
-import { CircleAlert, Loader2, RefreshCw } from 'lucide-react';
-import { useClient } from 'hooks/useClient';
-import { Button } from '@/components/ui/button';
-import { readableError } from './errors';
+import { useEffect } from 'react';
+import { CircleAlert } from 'lucide-react';
+import { useMediaSync } from './useMediaSync';
/**
- * Building `derived/` from `originals/`, shown where those two folders are.
+ * What syncing `derived/` is DOING, shown where the two folders are.
*
- * ── Why it is not its own section ──
+ * ── The button is not here any more ──
*
- * It was, briefly. But a section called "Derived media" sitting beside the block that lists
- * `originals/` and `derived/` is the same subject described twice, and the reader has to work out that
- * the two are about one thing. The action belongs to the folders it acts on, so it lives under them.
+ * It moved to the panel's bar, beside Build and Publish — see SyncButton for why. What stayed is
+ * everything that needs room to read: how far a run has got, which files failed, and the history.
*
- * ── It only appears once there is somewhere to put the output ──
- *
- * No `media/`, or a partial one, means the button would fail on a missing directory. The Create button
- * above it is the thing to press first, and offering both at once is offering a choice where there is
- * an order.
- *
- * ── It polls, and only while something is running ──
- *
- * A full pass takes minutes, so the request that starts it returns immediately and this asks how it is
- * going. The interval is torn down when the answer says it has stopped, so an idle panel makes no
- * requests — which is what keeps a screen somebody left open from being a request a second all
- * afternoon.
+ * Both read one `useGlobal` slot, so the button's label and this card cannot disagree about what is
+ * true. Nothing here fetches: the bar is mounted whenever the plugin is open and does the asking.
*
* ── Built and skipped are counted separately ──
*
@@ -38,46 +25,6 @@ import { readableError } from './errors';
* anybody has to act on.
*/
-type SyncError = { file: string; message: string };
-
-type SyncState = {
- running: boolean;
- total: number | null;
- done: number;
- built: number;
- skipped: number;
- /** Ladders renamed into place because their original moved, rather than rebuilt. */
- moved: number;
- /** Derived folders removed because their original is gone. Whole-tree syncs only. */
- pruned: number;
- errors: SyncError[];
- current: string[];
- startedAt: string | null;
- finishedAt: string | null;
- fatal: string | null;
-};
-
-type Run = {
- startedAt: string;
- finishedAt: string;
- trigger: 'manual' | 'watcher';
- scope: string;
- total: number;
- built: number;
- skipped: number;
- moved: number;
- pruned: number;
- errors: SyncError[];
- fatal: string | null;
-};
-
-type Reconciliation = { originals: number; current: number; missing: string[]; stale: string[] };
-
-type Ledger = { runs: Run[]; reconciliation: Reconciliation };
-
-/** How often to ask, while something is happening. Fast enough to look live, slow enough to be free. */
-const POLL_MS = 1000;
-
/** Recent runs worth showing. Enough to see a pattern, few enough to stay a footnote. */
const SHOWN_RUNS = 5;
@@ -93,122 +40,43 @@ const took = (from: string, to: string) => {
};
export const MediaSync = ({ onSettled }: { onSettled?: (settled: boolean) => void }) => {
- const client = useClient();
- const clientRef = useRef(client);
- clientRef.current = client;
+ const { state, ledger, running, percent, outstanding, known, error } = useMediaSync();
- const [state, setState] = useState(null);
- const [ledger, setLedger] = useState(null);
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
-
- const load = useCallback(async () => {
- try {
- setState(await clientRef.current.get('/fofos-admin/_config/media/sync'));
- } catch {
- // Owner-only. Root Location above has already said so once if that is the reason.
- setState(null);
- }
- }, []);
-
- /*
- The ledger is NOT polled.
-
- It walks the whole tree reading manifests, so asking for it once a second beside a progress bar
- would have every tick re-read the library. It is read on arrival and again when a run stops, which
- are the only two moments its answer can have changed.
- */
- const loadLedger = useCallback(async () => {
- try {
- setLedger(await clientRef.current.get('/fofos-admin/_config/media/ledger'));
- } catch {
- setLedger(null);
- }
- }, []);
-
- useEffect(() => {
- void load();
- void loadLedger();
- }, [load, loadLedger]);
-
- useEffect(() => {
- if (!state?.running) return;
- const timer = setInterval(() => void load(), POLL_MS);
- return () => clearInterval(timer);
- }, [state?.running, load]);
-
- // Re-read when a run STOPS. `running` going true→false is the edge, so a finished sync updates what
- // is left to do without the panel being reopened.
- const wasRunning = useRef(false);
- useEffect(() => {
- const running = state?.running ?? false;
- if (wasRunning.current && !running) void loadLedger();
- wasRunning.current = running;
- }, [state?.running, loadLedger]);
-
- const start = async () => {
- setBusy(true);
- setError(null);
- try {
- const result = await clientRef.current.post<{ started: boolean; state: SyncState }>(
- '/fofos-admin/_config/media/sync',
- {},
- );
- setState(result.state);
- } catch (caught) {
- setError(readableError(caught));
- } finally {
- setBusy(false);
- }
- };
-
- const running = state?.running ?? false;
- const total = state?.total ?? null;
- const done = state?.done ?? 0;
- // Null total means the walk has not finished, so there is nothing honest to divide by yet.
- const percent = total && total > 0 ? Math.round((done / total) * 100) : null;
const reconciliation = ledger?.reconciliation ?? null;
- const outstanding = (reconciliation?.missing.length ?? 0) + (reconciliation?.stale.length ?? 0);
/*
One bit, upward: is there anything left to do?
- Reported rather than lifted, because everything else about this — the polling, the run history, the
- errors — belongs here and would only be harder to read in a parent. What the parent needs is the
- single fact it cannot compute: whether this part of the setup is finished.
+ Reported rather than lifted, because the parent needs the single fact it cannot compute — whether
+ this part of the setup is finished — and nothing else about the sync belongs to it.
*/
useEffect(() => {
- if (reconciliation) onSettled?.(outstanding === 0);
- }, [reconciliation, outstanding, onSettled]);
+ if (known) onSettled?.(outstanding === 0);
+ }, [known, outstanding, onSettled]);
return (
-
- {/* Not what the last run did — what is TRUE NOW. A run's counts age the moment somebody uploads
- a file, and "62 built" three days ago says nothing about whether derived/ is current. This is
- recomputed from the filesystem, using the same freshness test the sync itself uses. */}
-
+ {/* Not what the last run did — what is TRUE NOW. A run's counts age the moment somebody uploads
+ a file, and "62 built" three days ago says nothing about whether derived/ is current. This is
+ recomputed from the filesystem, using the same freshness test the sync itself uses.
-
-
+ Full width, because the button that used to sit beside it is in the bar now. */}
+
diff --git a/web/SyncButton.tsx b/web/SyncButton.tsx
new file mode 100644
index 0000000..1e5aa49
--- /dev/null
+++ b/web/SyncButton.tsx
@@ -0,0 +1,66 @@
+import { Loader2, RefreshCw } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { useMediaSync, useMediaSyncFeed } from './useMediaSync';
+import { useReady } from './useReady';
+
+/**
+ * Sync, in the panel's bar beside Build and Publish.
+ *
+ * ── Why it left the card ──
+ *
+ * It sat under the two folders it acts on, which was the right place while it was a detail of setting
+ * fofos up. It is not one: `derived/` being stale is the reason a picture is missing from a page
+ * somebody is looking at, and the fix was three sections away, in a card about where the site lives.
+ * Beside Build and Publish it reads as what it is — the third thing you do to a site.
+ *
+ * The card keeps everything there is no room for up here: the progress bar, the failures, and the run
+ * history. Both read one slot, so they cannot disagree about what is true.
+ *
+ * ── It owns the fetching ──
+ *
+ * `useMediaSyncFeed` has exactly one caller and this is it, because the bar is mounted whenever
+ * anything in this plugin is open and the Administration card is mounted only sometimes. The thing
+ * that is always there should be the thing that asks.
+ *
+ * ── The label is the state ──
+ *
+ * `Sync 3` rather than a badge or a dot: the number IS the reason to press it, and a button that says
+ * "Sync now" when there is nothing to sync invites a press that does nothing. Idle and current, it
+ * still says "Sync" — the action stays available, because a file changed outside the watcher's notice
+ * is exactly the case somebody needs it for.
+ */
+export const SyncButton = () => {
+ const { running, percent, outstanding, busy, start } = useMediaSync();
+ const { rootSet } = useReady();
+
+ useMediaSyncFeed();
+
+ /*
+ Gated on the ROOT, not on `ready`.
+
+ Sync reads a folder on this machine and never speaks to fofos, so a broken connection is no reason
+ to withhold it — that distinction is the one `useReady` records as the bug that made `rootSet` a
+ separate answer in the first place.
+ */
+ if (!rootSet) return null;
+
+ return (
+
+ );
+};
diff --git a/web/useMediaSync.ts b/web/useMediaSync.ts
new file mode 100644
index 0000000..5710313
--- /dev/null
+++ b/web/useMediaSync.ts
@@ -0,0 +1,196 @@
+import { useCallback, useEffect, useRef } from 'react';
+import { useClient } from 'hooks/useClient';
+import { useGlobal } from 'hooks/useGlobal';
+import { readableError } from './errors';
+
+/**
+ * The state of `derived/` — what is running, what is outstanding, and how to start a pass.
+ *
+ * ── Why this is shared at all ──
+ *
+ * The button moved to the panel's action bar, beside Build and Publish, because syncing is one of the
+ * things somebody does to a site rather than a detail of one card. The rest of it — the progress bar,
+ * the failures, the run history — stayed in Administration, where there is room to read it.
+ *
+ * So one subject is drawn in two places at once, and they must not disagree. A header saying "Sync
+ * now" beside a card saying "3 changed" is two components each deciding what is true.
+ *
+ * ── `useGlobal`, not a context ──
+ *
+ * A slot in the app-wide query cache, keyed by name: whoever reads the key gets the value, whoever
+ * writes it re-renders every reader. No provider, so nothing has to wrap the bar and the panel
+ * together, and a third place that wants this later just reads the key.
+ *
+ * ── One reader FETCHES, everyone else reads ──
+ *
+ * `useGlobal` shares the value, not the work: mount the same effects twice and the endpoint is polled
+ * twice a second. So the fetching is a separate hook with exactly one caller — `SyncButton`, which
+ * lives in the bar and is therefore mounted whenever anything in this plugin is open. The
+ * Administration card is mounted only sometimes, which is precisely why it must not be the one asking.
+ */
+
+export type SyncError = { file: string; message: string };
+
+export type SyncState = {
+ running: boolean;
+ total: number | null;
+ done: number;
+ built: number;
+ skipped: number;
+ /** Ladders renamed into place because their original moved, rather than rebuilt. */
+ moved: number;
+ /** Derived folders removed because their original is gone. Whole-tree syncs only. */
+ pruned: number;
+ errors: SyncError[];
+ current: string[];
+ startedAt: string | null;
+ finishedAt: string | null;
+ fatal: string | null;
+};
+
+export type Run = {
+ startedAt: string;
+ finishedAt: string;
+ trigger: 'manual' | 'watcher';
+ scope: string;
+ total: number;
+ built: number;
+ skipped: number;
+ moved: number;
+ pruned: number;
+ errors: SyncError[];
+ fatal: string | null;
+};
+
+export type Reconciliation = { originals: number; current: number; missing: string[]; stale: string[] };
+
+export type Ledger = { runs: Run[]; reconciliation: Reconciliation };
+
+/** How often to ask, while something is happening. Fast enough to look live, slow enough to be free. */
+const POLL_MS = 1000;
+
+/** The slot. One name, so a third reader is a one-line addition rather than a refactor. */
+const KEY = ['fofos-admin', 'media-sync'];
+
+type Shared = {
+ state: SyncState | null;
+ ledger: Ledger | null;
+ /** A start request is in flight — distinct from `state.running`, which is the SERVER's answer. */
+ busy: boolean;
+ error: string | null;
+};
+
+const EMPTY: Shared = { state: null, ledger: null, busy: false, error: null };
+
+export type MediaSync = Shared & {
+ /** A pass is under way. */
+ running: boolean;
+ /** How far through, or null while the walk is still counting — nothing honest to divide by yet. */
+ percent: number | null;
+ /** Files with no ladder plus files whose ladder is stale. Zero means `derived/` is current. */
+ outstanding: number;
+ /** Whether the ledger has been read at all. Before that `outstanding` is 0 because it is unknown. */
+ known: boolean;
+ start: () => Promise;
+};
+
+/** Read the sync, and start one. Any number of callers. */
+export const useMediaSync = (): MediaSync => {
+ const client = useClient();
+ const clientRef = useRef(client);
+ clientRef.current = client;
+
+ const [shared, setShared] = useGlobal(KEY, EMPTY);
+
+ const start = useCallback(async () => {
+ setShared((was) => ({ ...was, busy: true, error: null }));
+ try {
+ const result = await clientRef.current.post<{ started: boolean; state: SyncState }>(
+ '/fofos-admin/_config/media/sync',
+ {},
+ );
+ setShared((was) => ({ ...was, state: result.state, busy: false }));
+ } catch (caught) {
+ setShared((was) => ({ ...was, busy: false, error: readableError(caught) }));
+ }
+ // `setShared` is rebuilt every render by useGlobal — see the note in SitePreview about verbs from
+ // useClient. Left out of the deps on purpose, and it closes over nothing that changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const total = shared.state?.total ?? null;
+ const done = shared.state?.done ?? 0;
+ const reconciliation = shared.ledger?.reconciliation ?? null;
+
+ return {
+ ...shared,
+ running: shared.state?.running ?? false,
+ percent: total && total > 0 ? Math.round((done / total) * 100) : null,
+ outstanding: (reconciliation?.missing.length ?? 0) + (reconciliation?.stale.length ?? 0),
+ known: reconciliation !== null,
+ start,
+ };
+};
+
+/**
+ * Keep the slot current. EXACTLY ONE caller — `SyncButton`.
+ *
+ * Separate from reading because `useGlobal` shares the value and not the work: two components running
+ * these effects would poll the same endpoint twice a second and re-read the ledger twice per run.
+ */
+export const useMediaSyncFeed = (): void => {
+ const client = useClient();
+ const clientRef = useRef(client);
+ clientRef.current = client;
+
+ const [shared, setShared] = useGlobal(KEY, EMPTY);
+ const setRef = useRef(setShared);
+ setRef.current = setShared;
+
+ const load = useCallback(async () => {
+ try {
+ const state = await clientRef.current.get('/fofos-admin/_config/media/sync');
+ setRef.current((was) => ({ ...was, state }));
+ } catch {
+ // Owner-only. Root Location has already said so once if that is the reason.
+ setRef.current((was) => ({ ...was, state: null }));
+ }
+ }, []);
+
+ /*
+ The ledger is NOT polled.
+
+ It walks the whole tree reading manifests, so asking for it once a second beside a progress bar
+ would have every tick re-read the library. It is read on arrival and again when a run stops, which
+ are the only two moments its answer can have changed.
+ */
+ const loadLedger = useCallback(async () => {
+ try {
+ const ledger = await clientRef.current.get('/fofos-admin/_config/media/ledger');
+ setRef.current((was) => ({ ...was, ledger }));
+ } catch {
+ setRef.current((was) => ({ ...was, ledger: null }));
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ void loadLedger();
+ }, [load, loadLedger]);
+
+ const running = shared.state?.running ?? false;
+
+ useEffect(() => {
+ if (!running) return;
+ const timer = setInterval(() => void load(), POLL_MS);
+ return () => clearInterval(timer);
+ }, [running, load]);
+
+ // Re-read when a run STOPS. `running` going true→false is the edge, so a finished sync updates what
+ // is left to do without the panel being reopened.
+ const wasRunning = useRef(false);
+ useEffect(() => {
+ if (wasRunning.current && !running) void loadLedger();
+ wasRunning.current = running;
+ }, [running, loadLedger]);
+};