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'; /** * Building `derived/` from `originals/`, shown where those two folders are. * * ── Why it is not its own section ── * * 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 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. * * ── 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. */ type SyncError = { file: string; message: string }; type SyncState = { running: boolean; total: number | null; done: number; built: number; skipped: 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; 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; 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 client = useClient(); const clientRef = useRef(client); clientRef.current = client; 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. */ useEffect(() => { if (reconciliation) onSettled?.(outstanding === 0); }, [reconciliation, 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. */}

{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.'}

{running && (
{/* Indeterminate until the walk has counted: a bar that starts full and shrinks is worse than one that has not started. */}
)} {state?.fatal && (

{state.fatal}

)} {state && state.errors.length > 0 && (
    {state.errors.map((failure) => (
  • {failure.file} — {failure.message}
  • ))}
)} {error &&

{error}

} {/* 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 && (
    {ledger.runs.slice(0, SHOWN_RUNS).map((run) => (
  • {day(run.finishedAt)} {clock(run.finishedAt)} {run.scope ? `${run.scope} · ` : ''} {run.built} built {run.skipped ? `, ${run.skipped} current` : ''} {run.errors.length ? `, ${run.errors.length} failed` : ''} {run.fatal ? ` — ${run.fatal}` : ''} {run.trigger === 'watcher' ? ' · on upload' : ''} {took(run.startedAt, run.finishedAt)}
  • ))}
)}
); };