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; }; /** How often to ask, while something is happening. Fast enough to look live, slow enough to be free. */ const POLL_MS = 1000; export const MediaSync = () => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [state, setState] = 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); } }, []); useEffect(() => { void load(); }, [load]); useEffect(() => { if (!state?.running) return; const timer = setInterval(() => void load(), POLL_MS); return () => clearInterval(timer); }, [state?.running, load]); 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; return (

{running ? total === null ? 'Reading originals…' : `${done} of ${total}${state?.current.length ? ` · ${state.current.join(', ')}` : ''}` : state?.finishedAt ? `${state.built} built · ${state.skipped} already current${ state.errors.length ? ` · ${state.errors.length} failed` : '' }` : '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}

}
); };