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>
134 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|