Files
mcpctl/src/web/src/pages/Dashboard.tsx
Michal e8c3803fac feat(web): bold redesign — Tailwind v4 + shadcn-style primitives + Skills/Proposals/Revisions UI
Phase 6 of the Skills + Revisions + Proposals work. The web UI gets a
new design language and first-class affordances for everything the
backend now supports.

## Visual direction

- Tailwind v4 with custom @theme block (oklch tokens). Dark-mode-only
  (internal tool — light mode doubles QA surface).
- Inter for UI, JetBrains Mono for code/IDs (loaded via Google Fonts;
  trivial to swap for self-hosted geist later — the fallback stack
  reads identically).
- Sidebar layout (always-visible at desktop widths) replacing the
  previous top-bar nav. Pending-proposals badge polls every 30 s so
  reviewers see a queue building without refreshing.
- Lucide icons throughout.
- Spacing and radii on Tailwind defaults.

Existing inline-styled pages (Projects, Agents, AgentDetail,
ProjectPrompts, PersonalityDetail, Login) continue to work unchanged
inside the new Layout — Tailwind doesn't conflict with their inline
styles. A follow-up can migrate them incrementally.

## What's added

### Build infra (src/web/)
- package.json: tailwindcss@^4 + @tailwindcss/vite, lucide-react,
  class-variance-authority, clsx, tailwind-merge, diff, geist (held
  for future self-hosting).
- vite.config.ts: registers the @tailwindcss/vite plugin.
- src/index.css: Tailwind import + @theme tokens + @layer base.
- src/main.tsx: imports index.css.
- src/lib/utils.ts: shadcn-style cn() helper.

### shadcn-style primitives (src/components/ui/)
Hand-written rather than generated via `npx shadcn` so the repo doesn't
depend on a CLI tool that needs interactive runtime:

- button.tsx — variants: primary / secondary / ghost / danger / link;
  sizes: sm / md / lg / icon.
- card.tsx — Card + Header/Title/Description/Content/Footer subparts.
- badge.tsx — variants: default / info / success / warning / danger /
  outline.
- input.tsx — Input + Textarea + Label.
- tabs.tsx — no-dep accessible Tabs (no Radix needed for our use).
- separator.tsx — h/v separator with role=separator.

### Diff component (src/components/Diff.tsx)
Wraps the `diff` package (already added in PR-2) for inline unified-
diff display with color-coded add/remove rows. Used by both the
proposal review page and the skill revision-history tab.

### New pages (src/pages/)
- Dashboard.tsx — at-a-glance home. Counts for skills, prompts,
  projects, agents, proposals; pending-proposals call-out card if any.
- Skills.tsx — list view, separated into Global vs Project/Agent-
  scoped sections.
- SkillDetail.tsx — name + semver + description; tabs for SKILL.md /
  Files / Metadata / History. History tab shows revisions with
  click-to-diff against the live body.
- Proposals.tsx — queue with Pending/Approved/Rejected tabs. Pending
  count is highlighted in amber.
- ProposalDetail.tsx — full body, diff against current resource (or
  "would create new" if it doesn't exist), approve button + reject-
  with-required-note flow.

### usePolling hook (src/hooks/)
Tiny polling-with-cancellation hook used by Layout and Proposals.

### Layout rewrite (src/components/Layout.tsx)
Sidebar with nav items: Dashboard, Projects, Agents, Skills,
Proposals. Lucide icons. Active-route highlighting via NavLink.
Pending-proposals warning badge on the Proposals item.

### Routes (src/App.tsx)
New routes: /dashboard, /skills, /skills/:name, /proposals,
/proposals/:id. Default redirects to /dashboard.

### API types (src/api.ts)
Type defs for Skill, VisibleSkill, Proposal, Revision (with the
shapes the new pages consume).

## Tests

Existing 7 web tests still pass (Login + api). New page-level tests
deferred — the new pages are mostly compositions of primitives and
fetch hooks that round-trip to the backend; the backend tests already
cover what they call. PR-7 polish can add render-and-click tests if
coverage drift surfaces.

## Verification

- `pnpm --filter @mcpctl/web build` clean, no warnings.
- `pnpm test:run` whole monorepo: 162 test files / 2157 tests green.
- Visual smoke deferred — needs a running mcpd to populate the
  fixtures. Manual smoke tested locally is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:54:55 +01:00

134 lines
5.0 KiB
TypeScript

import * as React from 'react';
import { Link } from 'react-router-dom';
import { Sparkles, Inbox, FolderKanban, Bot, ScrollText } from 'lucide-react';
import { api, type Skill, type Proposal, type Project, type Agent } from '../api';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
import { Badge } from '../components/ui/badge';
/**
* At-a-glance home page. Counts come from the `/api/v1/<resource>`
* lists; pending proposals are highlighted with an amber badge to draw
* the reviewer in.
*/
export function DashboardPage(): React.JSX.Element {
const [counts, setCounts] = React.useState<{
skills: number;
proposals: { pending: number; approved: number; rejected: number };
projects: number;
agents: number;
prompts: number;
} | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
async function load(): Promise<void> {
try {
const [skills, proposals, projects, agents, prompts] = await Promise.all([
api.get<Skill[]>('/api/v1/skills'),
api.get<Proposal[]>('/api/v1/proposals'),
api.get<Project[]>('/api/v1/projects'),
api.get<Agent[]>('/api/v1/agents'),
api.get<unknown[]>('/api/v1/prompts'),
]);
if (cancelled) return;
setCounts({
skills: skills.length,
proposals: {
pending: proposals.filter((p) => p.status === 'pending').length,
approved: proposals.filter((p) => p.status === 'approved').length,
rejected: proposals.filter((p) => p.status === 'rejected').length,
},
projects: projects.length,
agents: agents.length,
prompts: prompts.length,
});
} catch (err) {
if (!cancelled) setError((err as Error).message);
}
}
void load();
return () => { cancelled = true; };
}, []);
if (error !== null) return <div className="text-(--color-danger)">Error: {error}</div>;
if (counts === null) return <div className="text-(--color-fg-muted)">Loading</div>;
return (
<div className="space-y-6">
<header className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-sm text-(--color-fg-muted)">
A glance at what's in mcpd. Numbers update on page load.
</p>
</header>
{counts.proposals.pending > 0 && (
<Link to="/proposals">
<Card className="border-(--color-warning)/40 bg-(--color-warning-bg)/30 transition-colors hover:bg-(--color-warning-bg)/50">
<CardContent className="flex items-center gap-4 p-5 pt-5">
<Inbox className="size-6 text-(--color-warning)" />
<div className="flex-1">
<div className="font-medium text-(--color-fg)">
{counts.proposals.pending}{' '}
pending {counts.proposals.pending === 1 ? 'proposal' : 'proposals'}
</div>
<div className="text-sm text-(--color-fg-muted)">
Review the queue to approve or reject incoming changes.
</div>
</div>
<Badge variant="warning">Review</Badge>
</CardContent>
</Card>
</Link>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<CountCard to="/skills" icon={Sparkles} label="Skills" value={counts.skills} />
<CountCard to="/projects" icon={FolderKanban} label="Projects" value={counts.projects} />
<CountCard to="/agents" icon={Bot} label="Agents" value={counts.agents} />
<CountCard to="/projects" icon={ScrollText} label="Prompts" value={counts.prompts} />
<CountCard
to="/proposals"
icon={Inbox}
label="Proposals"
value={counts.proposals.pending + counts.proposals.approved + counts.proposals.rejected}
subtitle={`${counts.proposals.pending} pending · ${counts.proposals.approved} approved · ${counts.proposals.rejected} rejected`}
/>
</div>
</div>
);
}
function CountCard({
to,
icon: Icon,
label,
value,
subtitle,
}: {
to: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
value: number;
subtitle?: string;
}): React.JSX.Element {
return (
<Link to={to} className="block">
<Card className="transition-colors hover:bg-(--color-surface-hi)">
<CardHeader className="flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-(--color-fg-muted)">{label}</CardTitle>
<Icon className="size-4 text-(--color-fg-muted)" />
</CardHeader>
<CardContent>
<div className="font-mono text-3xl font-semibold tabular-nums">{value}</div>
{subtitle !== undefined && (
<p className="mt-1 text-xs text-(--color-fg-muted)">{subtitle}</p>
)}
</CardContent>
</Card>
</Link>
);
}