diff --git a/web/AdministrationPanel.tsx b/web/AdministrationPanel.tsx
index 9835b03..b100e5e 100644
--- a/web/AdministrationPanel.tsx
+++ b/web/AdministrationPanel.tsx
@@ -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 = () => (
-
);
diff --git a/web/MediaFolder.tsx b/web/MediaFolder.tsx
index d377d4c..a8af961 100644
--- a/web/MediaFolder.tsx
+++ b/web/MediaFolder.tsx
@@ -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';
/**
* `/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 }) => {
{error &&
{error}
}
+
+ {/* 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 && }
);
};
diff --git a/web/MediaSync.tsx b/web/MediaSync.tsx
new file mode 100644
index 0000000..2ceb25a
--- /dev/null
+++ b/web/MediaSync.tsx
@@ -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(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async () => {
+ try {
+ setState(await clientRef.current.get('/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 (
+
+ {/* Indeterminate until the walk has counted: a bar that starts full and shrinks is worse than
+ one that has not started. */}
+
+
+ )}
+
+ {state?.fatal && (
+
+
+ {state.fatal}
+
+ )}
+
+ {state && state.errors.length > 0 && (
+
+ {state.errors.map((failure) => (
+
+ {failure.file}
+ — {failure.message}
+
+ ))}
+
+ )}
+
+ {error &&
{error}
}
+
+ );
+};
diff --git a/web/MediaSyncBlock.tsx b/web/MediaSyncBlock.tsx
deleted file mode 100644
index acd07e0..0000000
--- a/web/MediaSyncBlock.tsx
+++ /dev/null
@@ -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(null);
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
-
- const load = useCallback(async () => {
- try {
- setState(await clientRef.current.get('/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 (
-
-
- {percent === null ? 'Looking' : `${percent}%`}
-
- ) : state?.finishedAt ? (
-
-
- Finished
-
- ) : undefined
- }
- >
- {running && (
-