Sync moves to the bar, beside Build and Publish

It sat under the two folders it acts on, which was right while it was a detail of setting fofos up. It
is not one: a stale `derived/` is why 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, and the one that has to happen first.

The card keeps what needs room: the progress bar, the failures, the run history.

Shared through `useGlobal`, keyed `['fofos-admin','media-sync']` — a slot in the app-wide query cache,
so no provider has to wrap the bar and the panel together and a third reader is one line. Not a
context: this codebase has one for Save because a SECTION owns that state and announces it upward.
Nobody owns this one; it is a fact about a folder, which is what a keyed slot is for.

`useGlobal` shares the value and not the work, so the fetching is a separate hook with exactly one
caller — the button. The bar is mounted whenever the plugin is open and the Administration card is
mounted only sometimes, so the thing that is always there is the thing that asks. Two callers would
poll the same endpoint twice a second.

Gated on `rootSet`, not `ready`: sync reads a folder on this machine and never speaks to fofos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 11:58:28 +01:00
co-authored by Claude Opus 5
parent 307a7d665a
commit 78db209ea1
4 changed files with 304 additions and 168 deletions
+34 -166
View File
@@ -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<SyncState | null>(null);
const [ledger, setLedger] = useState<Ledger | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setState(await clientRef.current.get<SyncState>('/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<Ledger>('/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 (
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center justify-between gap-3">
{/* 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. */}
<p className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{running
? total === null
? 'Reading originals…'
: `${done} of ${total}${state?.current.length ? ` · ${state.current.join(', ')}` : ''}`
: reconciliation
? outstanding === 0
? `${reconciliation.current} of ${reconciliation.originals} current`
: [
reconciliation.missing.length ? `${reconciliation.missing.length} missing` : '',
reconciliation.stale.length ? `${reconciliation.stale.length} changed` : '',
]
.filter(Boolean)
.join(' · ')
: 'Web-ready copies of everything in originals.'}
</p>
{/* 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.
<Button variant="outline" size="sm" className="shrink-0" onClick={() => void start()} disabled={busy || running}>
{running ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
{running ? `Syncing${percent === null ? '' : ` ${percent}%`}` : outstanding > 0 ? `Sync ${outstanding}` : 'Sync now'}
</Button>
</div>
Full width, because the button that used to sit beside it is in the bar now. */}
<p className="min-w-0 truncate text-xs text-muted-foreground">
{running
? state?.total === null || state?.total === undefined
? 'Reading originals…'
: `${state.done} of ${state.total}${state.current.length ? ` · ${state.current.join(', ')}` : ''}`
: reconciliation
? outstanding === 0
? `${reconciliation.current} of ${reconciliation.originals} current`
: [
reconciliation.missing.length ? `${reconciliation.missing.length} missing` : '',
reconciliation.stale.length ? `${reconciliation.stale.length} changed` : '',
]
.filter(Boolean)
.join(' · ')
: 'Web-ready copies of everything in originals.'}
</p>
{running && (
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">