Files
fofos-admin/web/MediaFolder.tsx
T
pastilhasandClaude Opus 5 792f166c31 the sync lives with the folders it syncs
"Derived media" was a section sitting beside the block that lists `originals/`
and `derived/` — the same subject described twice, leaving the reader to work
out that the two were about one thing.

So the control moves under those folders, inside Root Location, and appears only
once both exist: before that the button would be asking to write into a
directory that is not there, and Create is the thing to press. An order, not a
choice.

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

133 lines
5.6 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 }: { rootPath: string }) => {
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);
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]);
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 />}
</div>
);
};