the marketplace nav, in the house section style
taken from fofos-admin/web/NavPanel.tsx, which is the pattern from here on. three changes, each doing a job rather than being a preference: SEPARATION. `border-b border-border` on every row, replacing gapped pills. a gapped list leaves the boundary between one item and the next to guesswork; a ruled edge says where a row ends. THE LABEL IS NO LONGER MUTED. it was `text-duck-dark/70` — every row whispering, with nothing for the eye to land on. the muting moves down to the second line, where a count genuinely is secondary. THE COUNT MOVES OUT OF THE RIGHT-HAND COLUMN. it was a number at the far edge at 60% opacity, which is where the eye goes last. as the row's second line — "3 plugins" — it is read WITH the label rather than hunted for, and it gives every row the same two-line shape the fofos panel has. active is `bg-muted`, a filled row, rather than a tinted label. colour on the text was carrying both "this is a link" and "this one is selected", which is why nothing looked selected. the footer moved to the same border and the same `muted-foreground` token rather than the duck palette, so it sits under the rows instead of looking like another kind of thing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -32,14 +32,31 @@ const TAG_ICONS: Record<string, string> = {
|
||||
Fun: 'Gamepad2',
|
||||
};
|
||||
|
||||
const ROW = 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors w-full text-left';
|
||||
/*
|
||||
The house section-nav row, taken from `fofos-admin/web/NavPanel.tsx`.
|
||||
|
||||
Three things carry the look, and each is doing a job:
|
||||
|
||||
`border-b border-border` — the SEPARATION. Gapped pills read as a floating list where the boundary
|
||||
between one item and the next is guesswork; a ruled edge says where a row ends.
|
||||
|
||||
The label is NOT muted. It was `text-duck-dark/70`, so every row whispered and the eye had nothing to
|
||||
land on. The muting moves to the line BELOW it, where a count is genuinely secondary.
|
||||
|
||||
Active is `bg-muted` — a filled row rather than a tinted label. Colour on the text was carrying both
|
||||
"this is a link" and "this one is selected", which is why nothing looked selected.
|
||||
*/
|
||||
const rowClass = (active: boolean) =>
|
||||
`flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-left transition-colors ${
|
||||
active ? 'bg-muted' : 'hover:bg-muted/50'
|
||||
}`;
|
||||
|
||||
export const TagNav = () => {
|
||||
const { plugins, available, broken, orphans, isLoading, updateAll, updatingAll } = usePlugins();
|
||||
const [params] = useSearchParams();
|
||||
const active = params.get('tag');
|
||||
|
||||
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
|
||||
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading…</div>;
|
||||
|
||||
// Counted over everything the grid can show — installed and available together — so a number here
|
||||
// always matches what clicking it produces.
|
||||
@@ -65,61 +82,58 @@ export const TagNav = () => {
|
||||
return { search: `?${next}` };
|
||||
};
|
||||
|
||||
/** One row: icon, a label that is actually legible, and what it holds underneath. */
|
||||
const row = (
|
||||
key: string,
|
||||
to: ReturnType<typeof link>,
|
||||
Icon: typeof Package,
|
||||
label: string,
|
||||
n: number,
|
||||
on: boolean,
|
||||
) => (
|
||||
<Link key={key} to={to} className={rowClass(on)}>
|
||||
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm">{label}</span>
|
||||
{/* The count moves here from the right-hand column. It was a number floating at the edge at 60%
|
||||
opacity, which is where the eye goes last; as the row's second line it is read WITH the label
|
||||
rather than hunted for. */}
|
||||
<span className="block truncate text-xs text-muted-foreground tabular-nums">
|
||||
{n} plugin{n === 1 ? '' : 's'}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="flex h-full flex-col overflow-auto">
|
||||
<div className="flex flex-col gap-0.5 p-2">
|
||||
<Link
|
||||
to={link(null)}
|
||||
className={`${ROW} ${!active ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/70 hover:bg-duck-dark/5'}`}
|
||||
>
|
||||
<Package className="size-4 shrink-0" />
|
||||
<span className="flex-1">All</span>
|
||||
<span className="text-xs tabular-nums opacity-60">{all.length}</span>
|
||||
</Link>
|
||||
|
||||
{ordered.map(([tag, n]) => {
|
||||
const Icon = resolveIcon(TAG_ICONS[tag] ?? 'Puzzle');
|
||||
return (
|
||||
<Link
|
||||
key={tag}
|
||||
to={link(tag)}
|
||||
className={`${ROW} ${active === tag ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/70 hover:bg-duck-dark/5'}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{tag}</span>
|
||||
<span className="text-xs tabular-nums opacity-60">{n}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-col">
|
||||
{row('__all', link(null), Package, 'All', all.length, !active)}
|
||||
{ordered.map(([tag, n]) =>
|
||||
row(tag, link(tag), resolveIcon(TAG_ICONS[tag] ?? 'Puzzle'), tag, n, active === tag),
|
||||
)}
|
||||
|
||||
{/* Shown only when there are any. A plugin with no tags is reachable from All but from no filter,
|
||||
and that is worth being able to SEE rather than discovering by its absence. */}
|
||||
{untagged > 0 && (
|
||||
<Link
|
||||
to={link('__untagged')}
|
||||
className={`${ROW} ${active === '__untagged' ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
|
||||
>
|
||||
<Puzzle className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Untagged</span>
|
||||
<span className="text-xs tabular-nums opacity-60">{untagged}</span>
|
||||
</Link>
|
||||
)}
|
||||
{untagged > 0 && row('__untagged', link('__untagged'), Puzzle, 'Untagged', untagged, active === '__untagged')}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-1 border-t border-duck-dark/5 p-2 text-xs">
|
||||
{/* Below the rows, and separated by the same border they use — these are facts about the store
|
||||
rather than filters you can click, so they must not look like another section. */}
|
||||
<div className="mt-auto flex flex-col gap-1 border-t border-border p-3 text-xs">
|
||||
{/* Update-all belongs here rather than in a plugin's detail: it is the one action about all of
|
||||
them at once. Hidden when nothing is installed, where it would read as a broken button. */}
|
||||
{plugins.length > 0 && (
|
||||
<button
|
||||
onClick={() => updateAll()}
|
||||
disabled={updatingAll}
|
||||
className="rounded-lg px-3 py-2 text-left text-duck-dark/60 hover:bg-duck-dark/5 disabled:opacity-50"
|
||||
className="rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/50 disabled:opacity-50"
|
||||
>
|
||||
{updatingAll ? 'Updating…' : 'Update all'}
|
||||
</button>
|
||||
)}
|
||||
{broken.length > 0 && (
|
||||
<div className="flex items-start gap-2 px-3 py-1.5 text-busy-ink">
|
||||
<div className="flex items-start gap-2 px-2 py-1.5 text-busy-ink">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{broken.length} broken plugin{broken.length === 1 ? '' : 's'}
|
||||
@@ -127,7 +141,7 @@ export const TagNav = () => {
|
||||
</div>
|
||||
)}
|
||||
{orphans.length > 0 && (
|
||||
<div className="flex items-start gap-2 px-3 py-1.5 text-duck-dark/50">
|
||||
<div className="flex items-start gap-2 px-2 py-1.5 text-muted-foreground">
|
||||
<Database className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{orphans.length} orphaned table{orphans.length === 1 ? '' : 's'} from removed plugins
|
||||
|
||||
Reference in New Issue
Block a user