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:
+51
-6
@@ -1,4 +1,5 @@
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One titled block of Administration.
|
* 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
|
* 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
|
* 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.
|
* 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 = ({
|
export const Block = ({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
title,
|
title,
|
||||||
hint,
|
hint,
|
||||||
status,
|
status,
|
||||||
|
collapsible = false,
|
||||||
|
collapsed = false,
|
||||||
|
onCollapsedChange,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
@@ -28,21 +43,51 @@ export const Block = ({
|
|||||||
hint: string;
|
hint: string;
|
||||||
/** Top-right. Usually ephemeral — see `useFlash`. */
|
/** Top-right. Usually ephemeral — see `useFlash`. */
|
||||||
status?: React.ReactNode;
|
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;
|
children?: React.ReactNode;
|
||||||
}) => (
|
}) => {
|
||||||
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
const shut = collapsible && collapsed;
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
|
const header = (
|
||||||
|
<div className="flex items-start justify-between gap-3 text-left">
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4" />
|
||||||
{title}
|
{title}
|
||||||
|
{collapsible && (
|
||||||
|
<ChevronDown
|
||||||
|
className={`size-3.5 text-muted-foreground transition-transform ${shut ? '-rotate-90' : ''}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</h3>
|
</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>
|
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Reserves nothing when empty. The status is short and the title is not, so there is no reflow
|
{/* 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. */}
|
to guard against — and an always-present placeholder would read as a field. */}
|
||||||
{status && <div className="shrink-0">{status}</div>}
|
{status && <div className="shrink-0">{status}</div>}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
);
|
||||||
</section>
|
|
||||||
);
|
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) =>
|
const count = (entries: number | null) =>
|
||||||
entries === null ? '' : entries === 0 ? 'empty' : `${entries} item${entries === 1 ? '' : 's'}`;
|
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 client = useClient();
|
||||||
const clientRef = useRef(client);
|
const clientRef = useRef(client);
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
@@ -39,6 +45,9 @@ export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
|
|||||||
const [status, setStatus] = useState<Status | null>(null);
|
const [status, setStatus] = useState<Status | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -55,6 +64,10 @@ export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
|
|||||||
void load();
|
void load();
|
||||||
}, [load, rootPath]);
|
}, [load, rootPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSettled?.(Boolean(status?.complete) && synced);
|
||||||
|
}, [status?.complete, synced, onSettled]);
|
||||||
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
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
|
{/* 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. */}
|
that is not there, and Create above is the thing to press. */}
|
||||||
{status.complete && <MediaSync />}
|
{status.complete && <MediaSync onSettled={setSynced} />}
|
||||||
</div>
|
</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`;
|
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 client = useClient();
|
||||||
const clientRef = useRef(client);
|
const clientRef = useRef(client);
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
@@ -164,6 +164,17 @@ export const MediaSync = () => {
|
|||||||
const reconciliation = ledger?.reconciliation ?? null;
|
const reconciliation = ledger?.reconciliation ?? null;
|
||||||
const outstanding = (reconciliation?.missing.length ?? 0) + (reconciliation?.stale.length ?? 0);
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-2 pt-1">
|
<div className="flex flex-col gap-2 pt-1">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
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 { useClient } from 'hooks/useClient';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -63,6 +63,36 @@ export const RootLocationBlock = () => {
|
|||||||
const [forbidden, setForbidden] = useState(false);
|
const [forbidden, setForbidden] = useState(false);
|
||||||
const [flashing, flash] = useFlash();
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const current = await clientRef.current.get<Settings>('/fofos-admin/_config/root');
|
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) => (
|
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}
|
{children}
|
||||||
</Block>
|
</Block>
|
||||||
);
|
);
|
||||||
@@ -164,11 +204,11 @@ export const RootLocationBlock = () => {
|
|||||||
path are one question — split apart, somebody could change the root here and read a status
|
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
|
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. */}
|
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
|
{/* 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. */}
|
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
|
{/* The block's bottom row. Everything you can DO is here, on its own line, so the path above it
|
||||||
gets the full width. */}
|
gets the full width. */}
|
||||||
@@ -199,6 +239,10 @@ export const RootLocationBlock = () => {
|
|||||||
<Check className="size-3.5" />
|
<Check className="size-3.5" />
|
||||||
Saved
|
Saved
|
||||||
</span>
|
</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,
|
) : null,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+13
-1
@@ -52,7 +52,13 @@ type Status = {
|
|||||||
|
|
||||||
type RunResult = { ok: boolean; output: string; status: 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 client = useClient();
|
||||||
const clientRef = useRef(client);
|
const clientRef = useRef(client);
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
@@ -80,6 +86,12 @@ export const WebsiteCheckout = ({ rootPath }: { rootPath: string }) => {
|
|||||||
void load();
|
void load();
|
||||||
}, [load, rootPath]);
|
}, [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') => {
|
const act = async (what: 'clone' | 'update') => {
|
||||||
setBusy(what);
|
setBusy(what);
|
||||||
setOutput(null);
|
setOutput(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user