the sync lives with the folders it syncs

"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>
This commit is contained in:
2026-08-29 11:36:12 +01:00
co-authored by Claude Opus 5
parent b9f543c2b6
commit 792f166c31
4 changed files with 173 additions and 188 deletions
+3 -4
View File
@@ -1,5 +1,4 @@
import { RootLocationBlock } from './RootLocationBlock';
import { MediaSyncBlock } from './MediaSyncBlock';
import { DatabaseBlock } from './DatabaseBlock';
/**
@@ -8,8 +7,9 @@ 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 turning what
* was uploaded into what the site serves, then how to read its data.
* The order is the order things are needed in: where the site is on this machine, then how to read its
* data. Building `derived/` from `originals/` lives with the folders it acts on, inside Root Location,
* rather than as a section here describing the same two directories a second time.
*
* ── What used to be here ──
*
@@ -22,7 +22,6 @@ import { DatabaseBlock } from './DatabaseBlock';
export const AdministrationPanel = () => (
<div className="flex size-full flex-col gap-4 overflow-auto p-5">
<RootLocationBlock />
<MediaSyncBlock />
<DatabaseBlock />
</div>
);
+8
View File
@@ -3,6 +3,7 @@ import { CircleAlert, CircleCheck, CircleDashed, FolderPlus, Loader2 } from 'luc
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { readableError } from './errors';
import { MediaSync } from './MediaSync';
/**
* `<root>/media` — where the images and the files live.
@@ -13,6 +14,9 @@ import { readableError } from './errors';
* No console. Making a directory either works or gives one line about why not, and a black transcript
* box for a single sentence would make a small thing look like a large one.
*
* Filling `derived/` hangs off the bottom of this once both folders exist — see `MediaSync`. It was a
* section of its own for an afternoon, which described the same two folders twice.
*
* ── The children are listed, and the button repairs ──
*
* `originals/` and `derived/` are shown whether or not they are there, so a structure that is missing
@@ -119,6 +123,10 @@ export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
{/* Only once both folders exist. Until then the button would be asking to write into a directory
that is not there, and Create above is the thing to press. */}
{status.complete && <MediaSync />}
</div>
);
};
+162
View File
@@ -0,0 +1,162 @@
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>
);
};
-184
View File
@@ -1,184 +0,0 @@
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>
);
};