Files
fofos-admin/web/MediaFolder.tsx
T
pastilhasandClaude Opus 5 cc283ee7b6 Root location folds away once there is nothing left to do
Everything under it is in order most of the time, and a card that is four
paragraphs of "fine" pushes the settings that are not fine off the screen. So it
shuts, with a check on the right, and opens on a click.

"Finished" is three bits reported upward by the parts that own them: the path is
saved, the checkout is a repository, and media/ both exists and is current. None
of those is knowable in the parent — each child fetches its own status — and
lifting all of it up to answer one question would be a far worse trade than
three booleans.

It is only collapsible while settled. A block you can shut on unfinished setup
is a way to hide the thing you came here to fix. And the children are NOT
RENDERED while it is shut rather than hidden, because a collapsed block whose
children still poll is a settled panel making requests all afternoon about
something nobody is looking at.

The `null` in `collapsed` is a sentinel for "nobody has decided", which is what
lets a deliberate expand survive. Setting it to false while unsettled spent it
on the first render — before any status had arrived — so the block could never
fold itself away at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:52:54 +01:00

146 lines
6.0 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { CircleAlert, CircleCheck, CircleDashed, FolderPlus, Loader2 } from 'lucide-react';
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.
*
* Same row shape as the checkout above it, because they are the same kind of fact: a thing that either
* is or is not at a known path under the root, with one button that puts it there.
*
* 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
* one reads as INCOMPLETE rather than as fine. The button follows: "Create" when there is nothing, "Create
* missing" when part of it exists. It is `mkdir -p` on every path either way, so pressing it can only
* add and can never fail on something already correct.
*/
type Child = { name: string; hint: string; present: boolean; entries: number | null };
type Status = { path: string; present: boolean; blocked: boolean; children: Child[]; complete: boolean };
const count = (entries: number | null) =>
entries === null ? '' : entries === 0 ? 'empty' : `${entries} item${entries === 1 ? '' : 's'}`;
export const MediaFolder = ({
rootPath,
onSettled,
}: {
rootPath: string;
onSettled?: (settled: boolean) => void;
}) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [status, setStatus] = useState<Status | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
/* The folders existing is only half of finished; the other half is `derived/` being current, which
only `MediaSync` knows. Both have to be true before this reports settled upward. */
const [synced, setSynced] = useState(false);
const load = useCallback(async () => {
try {
setStatus(await clientRef.current.get<Status>('/fofos-admin/_config/media'));
} catch {
// Owner-only. The block above already says so once, and twice is two refusals for one cause.
setStatus(null);
}
}, []);
// Re-read whenever the root moves — this describes `<root>/media`, and a stale answer is about a
// directory nobody is looking at any more.
useEffect(() => {
void load();
}, [load, rootPath]);
useEffect(() => {
onSettled?.(Boolean(status?.complete) && synced);
}, [status?.complete, synced, onSettled]);
const create = async () => {
setBusy(true);
setError(null);
try {
const result = await clientRef.current.post<{ ok: boolean; error?: string; status: Status }>(
'/fofos-admin/_config/media/create',
{},
);
setStatus(result.status);
if (!result.ok && result.error) setError(result.error);
} catch (failure) {
setError(readableError(failure));
void load();
} finally {
setBusy(false);
}
};
if (!status) return null;
// Three states and not two: complete, partly there, and absent. A `media/` with one folder missing
// looked identical to a finished one under `present`, which is the state most worth seeing.
const Icon = status.blocked ? CircleAlert : status.complete ? CircleCheck : CircleDashed;
const tone = status.blocked ? 'text-destructive' : status.complete ? 'text-success' : 'text-muted-foreground';
return (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-start gap-2">
<Icon className={`mt-0.5 size-3.5 shrink-0 ${tone}`} />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="font-mono text-xs break-all">{status.path}</p>
{status.blocked ? (
<p className="text-xs text-destructive">Something is already here, and it is not a directory.</p>
) : status.complete ? (
<p className="text-xs text-muted-foreground">Ready.</p>
) : status.present ? (
<p className="text-xs text-muted-foreground">Partly there.</p>
) : (
<p className="text-xs text-muted-foreground">Not created yet.</p>
)}
{/* The structure, always — the missing folders are the point of showing it. The hint beside
each name carries the one distinction that matters: which of the two can be rebuilt. */}
<ul className="pt-1">
{status.children.map((child) => (
<li key={child.name} className="flex items-baseline gap-2 text-xs">
<span className={`font-mono ${child.present ? '' : 'text-muted-foreground/60 line-through'}`}>
{child.name}/
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground/70">
{child.hint}
{child.present && count(child.entries) ? ` · ${count(child.entries)}` : ''}
</span>
</li>
))}
</ul>
</div>
{!status.blocked && !status.complete && (
<Button size="sm" className="shrink-0" onClick={() => void create()} disabled={busy}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <FolderPlus className="size-3.5" />}
{status.present ? 'Create missing' : 'Create'}
</Button>
)}
</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 onSettled={setSynced} />}
</div>
);
};