"Derived media" was a section sitting beside the block that lists `originals/` and `derived/` — the same subject described twice, leaving the reader to work out that the two were about one thing. So the control moves under those folders, inside Root Location, and appears only once both exist: before that the button would be asking to write into a directory that is not there, and Create is the thing to press. An order, not a choice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
5.9 KiB
TypeScript
163 lines
5.9 KiB
TypeScript
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<SyncState | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setState(await clientRef.current.get<SyncState>('/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 (
|
|
<div className="flex flex-col gap-2 pt-1">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
|
{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.'}
|
|
</p>
|
|
|
|
<Button variant="outline" size="sm" className="shrink-0" onClick={() => void start()} disabled={busy || running}>
|
|
{running ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
|
|
{running ? `Syncing${percent === null ? '' : ` ${percent}%`}` : state?.finishedAt ? 'Sync again' : 'Sync now'}
|
|
</Button>
|
|
</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>}
|
|
</div>
|
|
);
|
|
};
|