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 ( +
+
+

+ {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.'} +

+ + +
+ + {running && ( +
+ {/* 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 && ( -
-
-
-
-

- {total === null ? 'Reading originals…' : `${done} of ${total}`} - {state?.current.length ? ` · ${state.current.join(', ')}` : ''} -

-
- )} - - {!running && state && (state.built > 0 || state.skipped > 0 || state.errors.length > 0) && ( -

- {state.built} built · {state.skipped} already current - {state.errors.length > 0 ? ` · ${state.errors.length} failed` : ''} -

- )} - - {state?.fatal && ( -

- - {state.fatal} -

- )} - - {state && state.errors.length > 0 && ( -
    - {state.errors.map((failure) => ( -
  • - {failure.file} - — {failure.message} -
  • - ))} -
- )} - - {error &&

{error}

} - - {/* Bottom row, like every other block here: the actions are not part of what you are reading. */} -
- -
- - ); -};