Files
fofos-admin/web/MediaSync.tsx
T
pastilhasandClaude Opus 5 78db209ea1 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>
2026-08-31 11:58:28 +01:00

143 lines
6.2 KiB
TypeScript

import { useEffect } from 'react';
import { CircleAlert } from 'lucide-react';
import { useMediaSync } from './useMediaSync';
/**
* What syncing `derived/` is DOING, shown where the two folders are.
*
* ── The button is not here any more ──
*
* 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.
*
* 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 ──
*
* Re-running is normal and cheap: everything already correct is skipped, because each derived folder
* carries the size and mtime it was built from. "412 already current" is the system saying it had
* nothing to do, which is a different and more useful answer than "412 done".
*
* ── Failures stay ──
*
* One bad file in four hundred is exactly what a progress bar hides, and it is the only part of this
* anybody has to act on.
*/
/** Recent runs worth showing. Enough to see a pattern, few enough to stay a footnote. */
const SHOWN_RUNS = 5;
const clock = (iso: string) =>
new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const day = (iso: string) => new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
/** `1m 12s`, or `8s`. Minutes matter here and hours have never happened. */
const took = (from: string, to: string) => {
const seconds = Math.max(0, Math.round((Date.parse(to) - Date.parse(from)) / 1000));
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
};
export const MediaSync = ({ onSettled }: { onSettled?: (settled: boolean) => void }) => {
const { state, ledger, running, percent, outstanding, known, error } = useMediaSync();
const reconciliation = ledger?.reconciliation ?? null;
/*
One bit, upward: is there anything left to do?
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 (known) onSettled?.(outstanding === 0);
}, [known, outstanding, onSettled]);
return (
<div className="flex flex-col gap-2 pt-1">
{/* 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. */}
<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">
{/* Indeterminate until the walk has counted: a bar that starts full and shrinks is worse than
one that has not started. */}
<div
className="h-full rounded-full bg-primary transition-[width] duration-500"
style={{ width: `${percent ?? 0}%` }}
/>
</div>
)}
{state?.fatal && (
<p className="flex items-start gap-1.5 text-xs text-destructive">
<CircleAlert className="mt-px size-3.5 shrink-0" />
{state.fatal}
</p>
)}
{state && state.errors.length > 0 && (
<ul className="max-h-32 space-y-1 overflow-auto rounded border border-border/60 p-2">
{state.errors.map((failure) => (
<li key={failure.file} className="text-xs">
<span className="font-medium">{failure.file}</span>
<span className="text-muted-foreground"> — {failure.message}</span>
</li>
))}
</ul>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{/*
The ledger. Survives a restart, which the live state above does not — and it is the only place a
failure from a run nobody watched is ever recorded, since a file that did not build leaves no
folder and no manifest behind it.
*/}
{ledger && ledger.runs.length > 0 && (
<ul className="space-y-0.5 pt-1">
{ledger.runs.slice(0, SHOWN_RUNS).map((run) => (
<li key={run.finishedAt} className="flex items-baseline gap-2 text-xs text-muted-foreground/70">
<span className="shrink-0 font-mono">
{day(run.finishedAt)} {clock(run.finishedAt)}
</span>
<span className="min-w-0 flex-1 truncate">
{run.scope ? `${run.scope} · ` : ''}
{run.built} built
{run.skipped ? `, ${run.skipped} current` : ''}
{/* Only when they happened, so an ordinary run stays one short line. */}
{run.moved ? `, ${run.moved} moved` : ''}
{run.pruned ? `, ${run.pruned} orphans removed` : ''}
{run.errors.length ? `, ${run.errors.length} failed` : ''}
{run.fatal ? ` — ${run.fatal}` : ''}
{run.trigger === 'watcher' ? ' · on upload' : ''}
</span>
<span className="shrink-0 font-mono">{took(run.startedAt, run.finishedAt)}</span>
</li>
))}
</ul>
)}
</div>
);
};