Files
fofos-admin/web/sections/MediaBlockSummary.tsx
T
pastilhasandClaude Opus 5 52164a7fec say when a media block points at something not converted yet
The site serves nothing but derived files, so a block pointing at an original
the pipeline has not been through renders an empty box — on the page, in the
preview and in the editor. That is a normal thing to hit: drop a file in, choose
it in the same minute, and you are ahead of a conversion that only starts twenty
seconds after the folder goes quiet.

The action bar already said "Sync 3". What was missing was the link from THIS
blank block to that number, which is the half that costs an afternoon: an empty
box looks like a block saved wrong, so it gets looked for in the wrong place.

So the failed thumbnail is caught and named, using the state the bar already
polls: converting now, in a run that started before this file, outstanding for
the next one, or nothing running — with Sync now in place for the last case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:29:28 +01:00

112 lines
5.0 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Loader2, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { posterUrl, thumbUrl } from '../thumbnail';
import { useMediaSync } from '../useMediaSync';
const IS_VIDEO = /\.(mp4|mov|webm|m4v)$/i;
/**
* Which file a media block points at, and whether anything has been made from it yet.
*
* ── Why "not converted" has to be SAID ──
*
* Nothing on the site renders an ORIGINAL. Every picture and every video is served from the derived
* ladder, so a block pointing at a file the pipeline has not been through renders an empty box — on
* the page, in the preview, and here.
*
* That is a normal thing to hit rather than a mistake: drop a file in, choose it in the same minute,
* and you are ahead of a conversion that only starts twenty seconds after the folder goes quiet. An
* empty box is indistinguishable from a block saved wrong, so it gets looked for in the wrong place.
*
* ── Why it reads the sync rather than guessing ──
*
* "Not converted" and "converting right now" want different things from somebody: the second wants
* waiting and the first wants a button. `useMediaSync` already holds both — `current` is what the run
* has open this second, and the ledger's `missing`/`stale` are what a run would pick up — so this
* says which of the two it is instead of offering one answer for both.
*
* Reading is free here: the state is a `useGlobal` slot kept current by SyncButton in the action bar,
* which is mounted whenever this panel is. Nothing extra is polled by drawing this.
*
* ── The path is shown whole ──
*
* From `originals/`, and not truncated. Half these files are called the same thing — every collection
* has a hero, every folder a `_1` — so a bare filename identifies one only to somebody who already
* knows which it is, and `truncate` cuts the end, which is the wrong half of a path.
*/
export const MediaBlockSummary = ({ url, onChoose }: { url: string | null; onChoose: () => void }) => {
const [missing, setMissing] = useState(false);
const { running, state, ledger, busy, start } = useMediaSync();
// Cleared when the block is pointed somewhere else, or the old file's verdict sticks to the new one.
useEffect(() => setMissing(false), [url]);
const reconciliation = ledger?.reconciliation ?? null;
// `current` is what the run has open this second — see SyncState.
const converting = Boolean(url) && (state?.current ?? []).includes(url!);
const queued =
Boolean(url) &&
(reconciliation?.missing.includes(url!) === true || reconciliation?.stale.includes(url!) === true);
return (
<>
<div className="flex flex-wrap items-center gap-3">
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={onChoose}>
Choose media…
</Button>
<span className="min-w-0 flex-1 font-mono text-[11px] break-all text-muted-foreground">
{url || 'Nothing chosen'}
</span>
</div>
{url && !missing && (
// A video shows its POSTER rather than playing: the question is which file this is.
<img
src={IS_VIDEO.test(url) ? posterUrl(url) : thumbUrl(url, 640)}
alt=""
className="w-full rounded border border-border"
onError={() => setMissing(true)}
/>
)}
{url && missing && (
<div className="space-y-2 rounded border border-amber-500/40 bg-amber-500/5 p-2 text-[11px] text-muted-foreground">
{converting ? (
<p className="flex items-center gap-1.5">
<Loader2 className="size-3 shrink-0 animate-spin" />
<span>
<span className="font-medium text-foreground">Converting now.</span> This file is in the
run that is going. It appears here when it finishes.
</span>
</p>
) : (
<>
<p>
<span className="font-medium text-foreground">Not converted yet.</span> The site serves
converted files only, so this block renders nothing until the pipeline has been through
it.{' '}
{running
? 'A run is going, but this file is not in it — it was picked up after the run started.'
: queued
? 'It is outstanding and the next run will take it.'
: 'A run starts on its own about twenty seconds after a folder goes quiet. A file renamed or replaced while one was going needs another pass.'}
</p>
<Button
size="sm"
variant="outline"
className="h-7 gap-1.5 text-[11px]"
disabled={busy || running}
onClick={() => void start()}
>
<RefreshCw className={`size-3 ${running ? 'animate-spin' : ''}`} />
{running ? 'Syncing…' : 'Sync now'}
</Button>
</>
)}
</div>
)}
</>
);
};