Files
fofos-admin/web/MediaSyncBlock.tsx
T
pastilhasandClaude Opus 5 e87e19ee46 derived/ is built from originals/, and you can watch it happen
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>
2026-08-29 11:13:56 +01:00

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>
);
};