The mapping: derived/ mirrors originals/ directory for directory, and where originals holds a FILE, derived holds a DIRECTORY of the same name — extension included — with the renditions inside it. Keeping the extension is what makes it total: a.jpg and a.png are different originals and must not share a folder. The inverse is pure string work, so the filesystem is the record and there is no second copy of the truth. Images get a WebP ladder at 320/640/960/1280/1920/2560 plus full.webp, and nothing is ever upscaled — a rung wider than the original would be a soft file that srcset then confidently prefers over the sharp one. full.webp always exists because for a HEIC it is the only browser-readable copy of the picture there is. Video gets av-club's web-1080 and web-720 presets by name, plus a poster. Images are named by width because srcset descriptors are widths; video by height because that is av-club's vocabulary and nobody misreads 1080.mp4. The ladder is fixed here and the SITE chooses from it. This never reads website/src/media/slots.ts — a plugin that knows a website's layout is a plugin for one website, and that boundary is the difference between a template and something fofos-shaped. Each derived folder carries a manifest naming the size and mtime it was built from, so the sync is idempotent and resumable: a run that died halfway is finished by running it again. That is what makes an in-memory job acceptable rather than merely convenient. It builds into <name>.partial and renames, so an interruption can never leave a folder that looks finished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
185 lines
6.8 KiB
TypeScript
185 lines
6.8 KiB
TypeScript
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<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, 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 (
|
|
<Block
|
|
icon={Wand2}
|
|
title="Derived media"
|
|
hint="Web-ready copies of everything in originals — resized, converted, and safe to rebuild."
|
|
status={
|
|
running ? (
|
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
{percent === null ? 'Looking' : `${percent}%`}
|
|
</span>
|
|
) : state?.finishedAt ? (
|
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<CircleCheck className="size-3.5" />
|
|
Finished
|
|
</span>
|
|
) : undefined
|
|
}
|
|
>
|
|
{running && (
|
|
<div className="space-y-2">
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className="h-full rounded-full bg-primary transition-[width] duration-500"
|
|
// Indeterminate until the walk has counted: a bar that starts full and shrinks is worse
|
|
// than one that has not started.
|
|
style={{ width: `${percent ?? 0}%` }}
|
|
/>
|
|
</div>
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
{total === null ? 'Reading originals…' : `${done} of ${total}`}
|
|
{state?.current.length ? ` · ${state.current.join(', ')}` : ''}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{!running && state && (state.built > 0 || state.skipped > 0 || state.errors.length > 0) && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{state.built} built · {state.skipped} already current
|
|
{state.errors.length > 0 ? ` · ${state.errors.length} failed` : ''}
|
|
</p>
|
|
)}
|
|
|
|
{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-40 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>}
|
|
|
|
{/* Bottom row, like every other block here: the actions are not part of what you are reading. */}
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => void start()} disabled={busy || running}>
|
|
{running ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
|
|
{running ? 'Syncing' : state?.finishedAt ? 'Sync again' : 'Sync now'}
|
|
</Button>
|
|
</div>
|
|
</Block>
|
|
);
|
|
};
|