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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { RootLocationBlock } from './RootLocationBlock';
|
||||
import { MediaSyncBlock } from './MediaSyncBlock';
|
||||
import { DatabaseBlock } from './DatabaseBlock';
|
||||
|
||||
/**
|
||||
@@ -7,8 +8,8 @@ import { DatabaseBlock } from './DatabaseBlock';
|
||||
* A list of blocks, and nothing else. Each one owns its own heading, its own state and its own status
|
||||
* message; this file decides only which of them exist and in what order.
|
||||
*
|
||||
* The order is the order things are needed in: where the site is on this machine, then how to read its
|
||||
* data.
|
||||
* The order is the order things are needed in: where the site is on this machine, then turning what
|
||||
* was uploaded into what the site serves, then how to read its data.
|
||||
*
|
||||
* ── What used to be here ──
|
||||
*
|
||||
@@ -21,6 +22,7 @@ import { DatabaseBlock } from './DatabaseBlock';
|
||||
export const AdministrationPanel = () => (
|
||||
<div className="flex size-full flex-col gap-4 overflow-auto p-5">
|
||||
<RootLocationBlock />
|
||||
<MediaSyncBlock />
|
||||
<DatabaseBlock />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user