import { useCallback, useEffect, useRef, useState } from 'react'; import { CircleAlert, CircleCheck, Loader2, RefreshCw, Wand2 } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { readableError } from './errors'; import { Block } from './Block'; /** * Building `derived/` from `originals/`. * * ── Why this is visible at all ── * * The work happens without anybody watching: a file arrives and something converts it minutes later. * That is the right behaviour and the wrong experience — a person who has just uploaded forty * photographs has no way to tell "being converted" from "nothing happened". So the state of the run is * on the screen, whether the run was started here or by an upload. * * ── 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 poll stops the moment the answer says it is not running — an idle panel makes no requests, * which is what keeps a screen somebody left open from being a request every second all afternoon. * * ── The counts distinguish built from skipped, deliberately ── * * Re-running is normal and cheap: everything already correct is skipped, because each derived folder * carries the size and mtime it was built from. A run reporting "412 skipped" is the system saying it * had nothing to do, which is a different and more reassuring answer than "412 done". * * ── Failures stay ── * * A file that could not be converted is listed with the reason and does not clear itself. One bad file * out of four hundred is exactly the thing 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 MediaSyncBlock = () => { 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, and Root Location above has already said so once if that is the reason. setState(null); } }, []); useEffect(() => { void load(); }, [load]); /* The poll, keyed on whether a run is in progress. The interval is torn down and not replaced when `running` goes false, so the last tick that observes the finish is also the one that stops the asking. No separate "should I still be polling" flag to get out of step with the answer. */ 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 ( {percent === null ? 'Looking' : `${percent}%`} ) : state?.finishedAt ? ( Finished ) : undefined } > {running && (

{total === null ? 'Reading originals…' : `${done} of ${total}`} {state?.current.length ? ` · ${state.current.join(', ')}` : ''}

)} {!running && state && (state.built > 0 || state.skipped > 0 || state.errors.length > 0) && (

{state.built} built · {state.skipped} already current {state.errors.length > 0 ? ` · ${state.errors.length} failed` : ''}

)} {state?.fatal && (

{state.fatal}

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

{error}

} {/* Bottom row, like every other block here: the actions are not part of what you are reading. */}
); };