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>
146 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
};
|