Files
fofos-admin/web/SyncModal.tsx
T
pastilhasandClaude Opus 5 ddcba1c95d Sync says what it is doing, file by file
The counters said how MUCH and never which file, so a run that was working normally and one that had
wedged produced the same screen: `done` stops moving either way. A photograph is around thirty encodes
and a video is three plus a poster ladder, and none of that was visible from outside.

So the run keeps a transcript and the button opens it.

Every rung reports itself by CONSTRUCTION. Ten `variants.push` call sites became one `add`, which is
the point — a rung added later is described without anybody remembering to describe it. The slow
stages that produce nothing until they finish say so BEFORE they start instead: the HEIC decode, each
video rung, the poster frame. Those are the pauses that read as a hang.

`?since=<n>` is what keeps it cheap. The transcript is thousands of lines and polls once a second, so
sending all of it every tick would be the same megabyte over and over; the client keeps what it has
and asks for the rest. No `since` means everything, which is what a modal opened mid-run wants.

The transcript lives in the shared slot, not the modal, so closing it does not throw away the run —
somebody who looks, closes, and looks again is watching one run rather than two. `startedAt` is what
identifies a run, because `n` restarts at 1 and appending across a boundary would interleave two.

Polling, not a socket. Each line transfers once and the events are seconds apart, so a second
transport to open, authenticate and reconnect would buy nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 12:16:52 +01:00

164 lines
7.1 KiB
TypeScript

import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Check, Copy, Loader2, RefreshCw } from 'lucide-react';
import { copyToClipboard } from 'helpers/clipboard';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useFlash } from './useFlash';
import { useMediaSync, type LogLine } from './useMediaSync';
/**
* What the sync is doing, line by line, while it does it.
*
* ── Why a transcript and not a progress bar ──
*
* The bar answers "how much" and it was the only answer available. It cannot say WHICH file, and the
* question people actually have during a long run is "is it stuck?" — which looks identical to "it is
* decoding a 40-megapixel HEIC" from outside. A photograph is around thirty encodes and a video is
* three plus a poster ladder; none of that was visible, so a run that was working normally and one
* that had wedged produced the same screen.
*
* ── The lines come from the poll, not a socket ──
*
* The state is already polled once a second while a run is going, and `?since=` means each line is
* transferred once. So this is a stream in the only sense that matters here — lines appear as they
* happen — without a second transport to open, authenticate and reconnect. Sub-second arrival would
* need SSE and would buy nothing: the events themselves are seconds apart.
*
* ── It is not the only place a failure is recorded ──
*
* Closing this loses nothing. The transcript lives in the shared slot and the ledger in Administration
* outlives the run entirely — this is the live view, not the record.
*/
const COLOURS: Record<LogLine['level'], string> = {
info: 'text-muted-foreground',
// The busiest level by far, and the least interesting line at a time. Dimmer than `info` so the
// structure of a run — its starts and its finishes — stays readable as the rungs scroll past.
step: 'text-muted-foreground/60',
done: 'text-foreground',
warn: 'text-amber-500',
error: 'text-destructive',
};
const clock = (iso: string) =>
new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
export const SyncModal = ({ open, onClose }: { open: boolean; onClose: () => void }) => {
const { log, state, running, percent, error, start, busy } = useMediaSync();
const [copied, flashCopied] = useFlash(1500);
/*
Follow the tail, unless somebody has scrolled away from it.
A transcript that always jumps to the bottom is unreadable while it is being written: the line
somebody is reading leaves the screen a second after they find it. So this follows only while the
view is already AT the bottom, which is the state it starts in and returns to on its own.
*/
const box = useRef<HTMLDivElement | null>(null);
const [following, setFollowing] = useState(true);
useLayoutEffect(() => {
const node = box.current;
if (!node || !following) return;
node.scrollTop = node.scrollHeight;
}, [log.length, following]);
// Reset to following whenever it opens, so reopening mid-run lands on the newest line rather than
// wherever the last visit left off.
useEffect(() => {
if (open) setFollowing(true);
}, [open]);
const transcript = log.map((line) => `${clock(line.at)} ${line.file ? `${line.file} — ` : ''}${line.text}`).join('\n');
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
{running && <Loader2 className="size-3.5 animate-spin" />}
Sync
<span className="font-normal text-muted-foreground">
{running
? state?.total
? `${state.done} of ${state.total}${percent === null ? '' : ` · ${percent}%`}`
: 'reading originals…'
: state?.finishedAt
? 'finished'
: 'idle'}
</span>
</DialogTitle>
</DialogHeader>
{running && (
<div className="h-1 w-full shrink-0 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>
)}
<div
ref={box}
// `onScroll` rather than a button: following is a thing somebody stops by scrolling, which is
// what they were already doing, and resumes by scrolling back down.
onScroll={(event) => {
const node = event.currentTarget;
setFollowing(node.scrollHeight - node.scrollTop - node.clientHeight < 24);
}}
className="min-h-[16rem] flex-1 overflow-auto rounded-md border border-border bg-muted/30 p-3 font-mono text-[11px] leading-relaxed"
>
{log.length === 0 ? (
<p className="text-muted-foreground">
Nothing yet. Press Sync to build web-ready copies of everything in originals.
</p>
) : (
log.map((line) => (
<div key={line.n} className={`flex gap-2 ${COLOURS[line.level]}`}>
<span className="shrink-0 opacity-50">{clock(line.at)}</span>
{/* The file is not repeated on every rung of the same file — the eye follows a column
of identical names badly, and the interesting part is what changed. */}
{line.file && <span className="min-w-0 shrink-0 max-w-[45%] truncate">{line.file}</span>}
<span className="min-w-0 flex-1 break-words">{line.text}</span>
</div>
))
)}
</div>
{error && <p className="shrink-0 text-xs text-destructive">{error}</p>}
<div className="flex shrink-0 items-center justify-between gap-2">
<Button
size="sm"
variant="outline"
className="h-7 gap-1.5 text-xs"
disabled={log.length === 0}
onClick={() => {
void copyToClipboard(transcript);
flashCopied();
}}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? 'Copied' : 'Copy'}
</Button>
{/* Start from in here too. Opening the modal to watch and then having to close it to press
the button is the sort of thing that only reads as obvious to whoever built it. */}
<Button
size="sm"
className="h-7 gap-1.5 text-xs"
onClick={() => void start()}
disabled={busy || running}
>
{running ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{running ? 'Syncing' : 'Sync'}
</Button>
</div>
</DialogContent>
</Dialog>
);
};