Files
pastilhasandClaude Opus 5 5da573b219 the sync button exists somewhere again
It moved out to the panel's bar beside Build and Publish, and then the bar was removed — so for the
last few days there was no way to start a media sync from anywhere in this admin. The API route was
fine the whole time; nothing pressed it.

Quieter and worse: `SyncButton` is the only caller of `useMediaSyncFeed`, so with it unmounted the
progress card in Administration was not being polled either. It would have sat there through a running
sync showing nothing.

Back beside the two folders it operates on, which is where it was before and where somebody looking
after the media already is. Under the status line rather than beside it — that line runs to two clauses
and a button sharing the row would move as the counts changed.

Sync, Build and Publish still want a home of their own where all three are to hand without opening
Administration. This is the one that could not wait for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:04:32 +01:00

156 lines
6.8 KiB
TypeScript

import { useEffect } from 'react';
import { CircleAlert } from 'lucide-react';
import { useMediaSync } from './useMediaSync';
import { SyncButton } from './SyncButton';
/**
* What syncing `derived/` is DOING, shown where the two folders are.
*
* ── The button is back, because the bar it left for no longer exists ──
*
* It moved out to the panel's bar beside Build and Publish, and then the bar was removed — so for a
* while there was no way to start a sync from anywhere in this admin. Worse quietly: `SyncButton` is
* the only caller of `useMediaSyncFeed`, so with it unmounted this card was not being polled either
* and showed a run that was going nowhere.
*
* Here is where it was before and where it belongs: beside the two folders it operates on, in the
* section somebody opens to look after the media. Both read one `useGlobal` slot, so the label and
* this card cannot disagree about what is true.
*
* `[open]` Sync, Build and Publish still want a home of their own where all three are to hand without
* opening Administration. This is the one that could not wait for it.
*
* ── 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>
{/* Under the sentence rather than beside it: the line above can run to two clauses, and a button
sharing that row moves as the counts change. */}
<div>
<SyncButton />
</div>
{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>
);
};