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>
This commit is contained in:
+50
-5
@@ -1,4 +1,5 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* One titled block of Administration.
|
||||
@@ -15,12 +16,26 @@ import type { LucideIcon } from 'lucide-react';
|
||||
* Each block renders its OWN Block, rather than the panel wrapping them. The status belongs to the
|
||||
* setting that produced it, and lifting that state up to the panel would put one block's timer in a
|
||||
* component that has nothing to do with it.
|
||||
*
|
||||
* ── Collapsing is CONTROLLED, and that is the whole design ──
|
||||
*
|
||||
* This component does not decide when a block is finished — it cannot, because "finished" is different
|
||||
* for every setting and only the block itself knows. So the owner passes `collapsed` and gets told
|
||||
* about a click, and what remains here is the chrome: a header that is a button, and children that are
|
||||
* not rendered while it is shut.
|
||||
*
|
||||
* NOT RENDERED rather than hidden. A collapsed block's children keep polling if they are merely
|
||||
* display:none, which is how a settled panel goes on making requests all afternoon about something
|
||||
* nobody is looking at.
|
||||
*/
|
||||
export const Block = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
hint,
|
||||
status,
|
||||
collapsible = false,
|
||||
collapsed = false,
|
||||
onCollapsedChange,
|
||||
children,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
@@ -28,21 +43,51 @@ export const Block = ({
|
||||
hint: string;
|
||||
/** Top-right. Usually ephemeral — see `useFlash`. */
|
||||
status?: React.ReactNode;
|
||||
/** The header becomes a button. The owner decides when this is worth offering. */
|
||||
collapsible?: boolean;
|
||||
collapsed?: boolean;
|
||||
onCollapsedChange?: (collapsed: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
}) => {
|
||||
const shut = collapsible && collapsed;
|
||||
|
||||
const header = (
|
||||
<div className="flex items-start justify-between gap-3 text-left">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
<Icon className="size-4" />
|
||||
{title}
|
||||
{collapsible && (
|
||||
<ChevronDown
|
||||
className={`size-3.5 text-muted-foreground transition-transform ${shut ? '-rotate-90' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</h3>
|
||||
{/* The hint is what the block is FOR, so it stays whether or not the block is open — a shut
|
||||
row with only a title says nothing to somebody who has not opened it before. */}
|
||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||
</div>
|
||||
{/* Reserves nothing when empty. The status is short and the title is not, so there is no reflow
|
||||
to guard against — and an always-present placeholder would read as a field. */}
|
||||
{status && <div className="shrink-0">{status}</div>}
|
||||
</div>
|
||||
{children}
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
||||
{collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCollapsedChange?.(!collapsed)}
|
||||
aria-expanded={!shut}
|
||||
className="-m-1 cursor-pointer rounded p-1 hover:bg-muted/40"
|
||||
>
|
||||
{header}
|
||||
</button>
|
||||
) : (
|
||||
header
|
||||
)}
|
||||
{!shut && children}
|
||||
</section>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
+15
-2
@@ -31,7 +31,13 @@ type Status = { path: string; present: boolean; blocked: boolean; children: Chil
|
||||
const count = (entries: number | null) =>
|
||||
entries === null ? '' : entries === 0 ? 'empty' : `${entries} item${entries === 1 ? '' : 's'}`;
|
||||
|
||||
export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
|
||||
export const MediaFolder = ({
|
||||
rootPath,
|
||||
onSettled,
|
||||
}: {
|
||||
rootPath: string;
|
||||
onSettled?: (settled: boolean) => void;
|
||||
}) => {
|
||||
const client = useClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
@@ -39,6 +45,9 @@ export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
|
||||
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 {
|
||||
@@ -55,6 +64,10 @@ export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
|
||||
void load();
|
||||
}, [load, rootPath]);
|
||||
|
||||
useEffect(() => {
|
||||
onSettled?.(Boolean(status?.complete) && synced);
|
||||
}, [status?.complete, synced, onSettled]);
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -126,7 +139,7 @@ export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
|
||||
|
||||
{/* 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 />}
|
||||
{status.complete && <MediaSync onSettled={setSynced} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+12
-1
@@ -86,7 +86,7 @@ const took = (from: string, to: string) => {
|
||||
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
};
|
||||
|
||||
export const MediaSync = () => {
|
||||
export const MediaSync = ({ onSettled }: { onSettled?: (settled: boolean) => void }) => {
|
||||
const client = useClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
@@ -164,6 +164,17 @@ export const MediaSync = () => {
|
||||
const reconciliation = ledger?.reconciliation ?? null;
|
||||
const outstanding = (reconciliation?.missing.length ?? 0) + (reconciliation?.stale.length ?? 0);
|
||||
|
||||
/*
|
||||
One bit, upward: is there anything left to do?
|
||||
|
||||
Reported rather than lifted, because everything else about this — the polling, the run history, the
|
||||
errors — belongs here and would only be harder to read in a parent. What the parent needs is the
|
||||
single fact it cannot compute: whether this part of the setup is finished.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (reconciliation) onSettled?.(outstanding === 0);
|
||||
}, [reconciliation, outstanding, onSettled]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Check, FolderRoot, Loader2, Pencil } from 'lucide-react';
|
||||
import { Check, CircleCheck, FolderRoot, Loader2, Pencil } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -63,6 +63,36 @@ export const RootLocationBlock = () => {
|
||||
const [forbidden, setForbidden] = useState(false);
|
||||
const [flashing, flash] = useFlash();
|
||||
|
||||
/*
|
||||
Finished, and therefore worth folding away.
|
||||
|
||||
Three bits reported up 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 here — each child fetches its
|
||||
own status, and lifting all of it into this component to answer one question would be a much worse
|
||||
trade than three booleans.
|
||||
*/
|
||||
const [websiteReady, setWebsiteReady] = useState(false);
|
||||
const [mediaReady, setMediaReady] = useState(false);
|
||||
const settled = Boolean(saved?.isSet) && websiteReady && mediaReady && !editing;
|
||||
|
||||
/*
|
||||
Shut on the first arrival at a settled block, and open the moment anything needs attention.
|
||||
|
||||
`null` means nobody has decided yet, which is what lets a deliberate expand SURVIVE — once somebody
|
||||
has opened it, `collapsed` is false and this effect will not run again while it stays settled. A
|
||||
plain `useState(true)` would slam it shut on every re-render that touched the dependency.
|
||||
*/
|
||||
const [collapsed, setCollapsed] = useState<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
setCollapsed((chosen) => {
|
||||
// Not settled yet: force it open if somebody had shut it, but LEAVE `null` alone. Writing false
|
||||
// here was a bug — `settled` is false on the first render, so the sentinel was spent before it
|
||||
// could mean anything and the block never folded itself away.
|
||||
if (!settled) return chosen === null ? null : false;
|
||||
return chosen === null ? true : chosen;
|
||||
});
|
||||
}, [settled]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const current = await clientRef.current.get<Settings>('/fofos-admin/_config/root');
|
||||
@@ -99,7 +129,17 @@ export const RootLocationBlock = () => {
|
||||
};
|
||||
|
||||
const shell = (children: React.ReactNode, status?: React.ReactNode) => (
|
||||
<Block icon={FolderRoot} title="Root location" hint="Where the fofos site is on this machine." status={status}>
|
||||
<Block
|
||||
icon={FolderRoot}
|
||||
title="Root location"
|
||||
hint="Where the fofos site is on this machine."
|
||||
status={status}
|
||||
// Only offered once there is nothing to do. A block you can shut on unfinished setup is a way to
|
||||
// hide the thing you came here to fix.
|
||||
collapsible={settled}
|
||||
collapsed={settled && collapsed !== false}
|
||||
onCollapsedChange={setCollapsed}
|
||||
>
|
||||
{children}
|
||||
</Block>
|
||||
);
|
||||
@@ -164,11 +204,11 @@ export const RootLocationBlock = () => {
|
||||
path are one question — split apart, somebody could change the root here and read a status
|
||||
about the old one there, with both looking equally current. Hidden while editing: it
|
||||
describes what is SAVED, and a half-typed path has nothing at it. */}
|
||||
{!editing && <WebsiteCheckout rootPath={saved.rootPath} />}
|
||||
{!editing && <WebsiteCheckout rootPath={saved.rootPath} onSettled={setWebsiteReady} />}
|
||||
|
||||
{/* Beside the checkout, under the same root — a backup of the root is then the whole site, the
|
||||
code and the things it serves. Hidden while editing for the same reason as the checkout. */}
|
||||
{!editing && <MediaFolder rootPath={saved.rootPath} />}
|
||||
{!editing && <MediaFolder rootPath={saved.rootPath} onSettled={setMediaReady} />}
|
||||
|
||||
{/* The block's bottom row. Everything you can DO is here, on its own line, so the path above it
|
||||
gets the full width. */}
|
||||
@@ -199,6 +239,10 @@ export const RootLocationBlock = () => {
|
||||
<Check className="size-3.5" />
|
||||
Saved
|
||||
</span>
|
||||
) : settled ? (
|
||||
// Big, and the only thing in the row. Everything under here is in order, and a shut block whose
|
||||
// whole message is "nothing to do" should say it in one glance rather than in a sentence.
|
||||
<CircleCheck className="size-6 text-success" />
|
||||
) : null,
|
||||
);
|
||||
};
|
||||
|
||||
+13
-1
@@ -52,7 +52,13 @@ type Status = {
|
||||
|
||||
type RunResult = { ok: boolean; output: string; status: Status };
|
||||
|
||||
export const WebsiteCheckout = ({ rootPath }: { rootPath: string }) => {
|
||||
export const WebsiteCheckout = ({
|
||||
rootPath,
|
||||
onSettled,
|
||||
}: {
|
||||
rootPath: string;
|
||||
onSettled?: (settled: boolean) => void;
|
||||
}) => {
|
||||
const client = useClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
@@ -80,6 +86,12 @@ export const WebsiteCheckout = ({ rootPath }: { rootPath: string }) => {
|
||||
void load();
|
||||
}, [load, rootPath]);
|
||||
|
||||
// One bit upward — see the note in `MediaSync`. A checkout that is present but not a repository is
|
||||
// NOT settled: something is in the way, and that is exactly the state worth staying open for.
|
||||
useEffect(() => {
|
||||
onSettled?.(Boolean(status?.isRepo));
|
||||
}, [status?.isRepo, onSettled]);
|
||||
|
||||
const act = async (what: 'clone' | 'update') => {
|
||||
setBusy(what);
|
||||
setOutput(null);
|
||||
|
||||
Reference in New Issue
Block a user