2026-02-04 23:01:37 -05:00

1351 lines
50 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MCP Command Center</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--bg-card: #1c2128;
--bg-hover: #262c36;
--border-primary: #30363d;
--border-subtle: #21262d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-tertiary: #6e7681;
--accent-blue: #3B82F6;
--accent-purple: #8B5CF6;
--accent-amber: #F59E0B;
--accent-teal: #14B8A6;
--accent-rose: #F43F5E;
--accent-emerald: #10B981;
--accent-gold: #EAB308;
--shadow: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.4), 0 4px 6px -2px rgba(0,0,0,0.3);
--radius: 8px;
--radius-sm: 6px;
--transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
}
html, body, #root { height: 100%; font-family: var(--font); background: var(--bg-primary); color: var(--text-primary); }
body { overflow: hidden; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-primary); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
.app { display: flex; flex-direction: column; height: 100vh; }
/* TOP BAR */
.topbar {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
padding: 12px 20px;
flex-shrink: 0;
z-index: 100;
}
.topbar-main { display: flex; align-items: center; gap: 16px; margin-bottom: 10px; }
.topbar-title {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.3px;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.topbar-title svg { width: 22px; height: 22px; }
.topbar-search {
flex: 1;
max-width: 320px;
position: relative;
}
.topbar-search input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
padding: 7px 12px 7px 32px;
color: var(--text-primary);
font-size: 13px;
outline: none;
transition: border-color var(--transition);
}
.topbar-search input:focus { border-color: var(--accent-blue); }
.topbar-search input::placeholder { color: var(--text-tertiary); }
.topbar-search svg {
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
width: 14px; height: 14px; color: var(--text-tertiary); pointer-events: none;
}
.topbar-search .shortcut {
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
font-size: 10px; color: var(--text-tertiary); background: var(--bg-primary);
padding: 2px 5px; border-radius: 3px; border: 1px solid var(--border-primary);
pointer-events: none;
}
.btn-add {
background: var(--accent-blue);
color: #fff;
border: none;
border-radius: var(--radius-sm);
padding: 7px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: all var(--transition);
white-space: nowrap;
}
.btn-add:hover { background: #2563eb; transform: translateY(-1px); }
/* STATS ROW */
.stats-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.stat-pill {
display: flex; align-items: center; gap: 5px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 20px;
padding: 3px 10px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.stat-pill .num { font-weight: 700; color: var(--text-primary); }
.stat-pill.blocked .num { color: var(--accent-rose); }
.stat-sep { width: 1px; height: 18px; background: var(--border-primary); margin: 0 4px; }
/* PROGRESS BAR (overall) */
.overall-progress {
display: flex; align-items: center; gap: 8px; margin-left: auto;
}
.overall-progress-label { font-size: 11px; color: var(--text-tertiary); white-space: nowrap; }
.overall-progress-bar {
width: 140px; height: 5px; background: var(--bg-primary);
border-radius: 3px; overflow: hidden;
}
.overall-progress-fill {
height: 100%; border-radius: 3px;
background: linear-gradient(90deg, var(--accent-blue), var(--accent-purple), var(--accent-emerald));
transition: width 0.6s ease;
}
.overall-progress-pct { font-size: 12px; font-weight: 700; color: var(--text-primary); min-width: 32px; }
/* PHASE FILTER PILLS */
.phase-filters { display: flex; gap: 4px; margin-left: 12px; }
.phase-pill {
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: all var(--transition);
user-select: none;
white-space: nowrap;
}
.phase-pill.active { opacity: 1; }
.phase-pill.inactive { opacity: 0.4; }
.phase-pill:hover { opacity: 0.8; }
/* BOARD */
.board-container {
flex: 1;
overflow-x: auto;
overflow-y: hidden;
padding: 0;
}
.board {
display: flex;
height: 100%;
min-width: max-content;
}
/* PHASE GROUP */
.phase-group {
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.phase-group:last-child { border-right: none; }
.phase-header {
padding: 8px 16px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 2px solid;
flex-shrink: 0;
background: var(--bg-secondary);
position: sticky;
top: 0;
z-index: 10;
}
.phase-header .phase-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.phase-header .phase-stats {
font-weight: 400;
opacity: 0.6;
margin-left: auto;
font-size: 10px;
text-transform: none;
letter-spacing: 0;
}
.phase-stages {
display: flex;
flex: 1;
overflow: hidden;
}
/* STAGE COLUMN */
.stage-column {
width: 260px;
min-width: 260px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-subtle);
background: var(--bg-primary);
}
.stage-column:last-child { border-right: none; }
.stage-header {
padding: 10px 12px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
background: var(--bg-primary);
position: sticky;
top: 0;
z-index: 5;
}
.stage-header-top {
display: flex; align-items: center; justify-content: space-between;
}
.stage-num {
font-size: 10px;
font-weight: 700;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stage-name {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
margin-left: 8px;
flex: 1;
line-height: 1.2;
}
.stage-count {
font-size: 11px;
color: var(--text-tertiary);
background: var(--bg-tertiary);
border-radius: 10px;
padding: 1px 7px;
font-weight: 600;
min-width: 22px;
text-align: center;
}
.stage-desc {
font-size: 10px;
color: var(--text-tertiary);
margin-top: 4px;
line-height: 1.3;
padding-left: 28px;
}
.stage-cards {
flex: 1;
overflow-y: auto;
padding: 6px 8px 60px;
min-height: 60px;
}
.stage-cards.drag-over {
background: rgba(56, 130, 246, 0.04);
}
/* CARD */
.card {
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius);
padding: 10px 12px;
margin-bottom: 6px;
cursor: grab;
transition: all var(--transition);
position: relative;
user-select: none;
}
.card:hover {
border-color: var(--text-tertiary);
background: var(--bg-hover);
transform: translateY(-1px);
box-shadow: var(--shadow);
}
.card:active { cursor: grabbing; }
.card.dragging {
opacity: 0.5;
transform: rotate(2deg);
}
.card-border-accent {
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 0 2px 2px 0;
}
.card-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
padding-left: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.card-name .blocker-icon {
color: var(--accent-rose);
font-size: 14px;
line-height: 1;
flex-shrink: 0;
}
.card-meta {
display: flex;
gap: 8px;
padding-left: 8px;
flex-wrap: wrap;
}
.card-meta span {
font-size: 10px;
color: var(--text-tertiary);
display: flex;
align-items: center;
gap: 3px;
}
.card-meta span svg { width: 11px; height: 11px; }
.card-progress {
height: 2px;
background: var(--bg-tertiary);
border-radius: 1px;
margin-top: 8px;
overflow: hidden;
}
.card-progress-fill {
height: 100%;
border-radius: 1px;
transition: width 0.4s ease;
}
.card-type-badge {
position: absolute;
top: 8px;
right: 8px;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
/* MODAL OVERLAY */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
justify-content: flex-end;
animation: fadeIn 150ms ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.modal-panel {
width: 460px;
max-width: 90vw;
background: var(--bg-secondary);
border-left: 1px solid var(--border-primary);
height: 100%;
overflow-y: auto;
animation: slideIn 200ms cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
}
.modal-header {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border-primary);
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-shrink: 0;
}
.modal-close {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition);
}
.modal-close:hover { color: var(--text-primary); background: var(--bg-tertiary); }
.modal-body { padding: 20px 24px; flex: 1; overflow-y: auto; }
.modal-field { margin-bottom: 18px; }
.modal-label {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
display: block;
}
.modal-input, .modal-textarea, .modal-select {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-primary);
font-size: 13px;
font-family: var(--font);
outline: none;
transition: border-color var(--transition);
}
.modal-input:focus, .modal-textarea:focus, .modal-select:focus { border-color: var(--accent-blue); }
.modal-textarea { resize: vertical; min-height: 60px; }
.modal-select { cursor: pointer; }
.modal-row { display: flex; gap: 12px; }
.modal-row .modal-field { flex: 1; }
.modal-name-input {
font-size: 20px;
font-weight: 700;
background: none;
border: none;
color: var(--text-primary);
outline: none;
width: 100%;
padding: 0;
}
.modal-name-input::placeholder { color: var(--text-tertiary); }
/* STAGE HISTORY */
.stage-history { list-style: none; }
.stage-history li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 12px;
color: var(--text-secondary);
border-left: 2px solid var(--border-primary);
padding-left: 12px;
margin-left: 4px;
}
.stage-history li:first-child { border-color: var(--accent-blue); }
.stage-history li .sh-stage { font-weight: 600; color: var(--text-primary); }
.stage-history li .sh-date { color: var(--text-tertiary); font-size: 11px; margin-left: auto; }
/* MODAL FOOTER */
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-primary);
display: flex;
justify-content: space-between;
flex-shrink: 0;
}
.btn-delete {
background: none;
border: 1px solid rgba(244,63,94,0.3);
color: var(--accent-rose);
border-radius: var(--radius-sm);
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
transition: all var(--transition);
}
.btn-delete:hover { background: rgba(244,63,94,0.1); border-color: var(--accent-rose); }
.btn-save {
background: var(--accent-blue);
border: none;
color: #fff;
border-radius: var(--radius-sm);
padding: 6px 18px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition);
}
.btn-save:hover { background: #2563eb; }
/* BLOCKER TOGGLE */
.blocker-toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.blocker-toggle input { display: none; }
.blocker-switch {
width: 34px;
height: 18px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 9px;
position: relative;
transition: all var(--transition);
}
.blocker-switch::after {
content: '';
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-tertiary);
position: absolute;
top: 2px;
left: 2px;
transition: all var(--transition);
}
.blocker-toggle input:checked + .blocker-switch {
background: rgba(244,63,94,0.2);
border-color: var(--accent-rose);
}
.blocker-toggle input:checked + .blocker-switch::after {
background: var(--accent-rose);
transform: translateX(16px);
}
.blocker-text { font-size: 13px; color: var(--text-secondary); }
/* KEYBOARD HINT */
.kbd {
font-size: 10px; background: var(--bg-primary); color: var(--text-tertiary);
padding: 1px 5px; border-radius: 3px; border: 1px solid var(--border-primary);
font-family: var(--font); margin-left: 6px;
}
/* TOAST */
.toast-container {
position: fixed; bottom: 20px; right: 20px; z-index: 2000;
display: flex; flex-direction: column; gap: 8px;
}
.toast {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius);
padding: 10px 16px;
font-size: 13px;
color: var(--text-primary);
box-shadow: var(--shadow-lg);
animation: toastIn 200ms ease;
display: flex;
align-items: center;
gap: 8px;
}
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
/* EMPTY STATE */
.empty-col {
display: flex;
align-items: center;
justify-content: center;
height: 60px;
font-size: 11px;
color: var(--text-tertiary);
opacity: 0.5;
}
/* ADD MODAL */
.add-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 150ms ease;
}
.add-modal {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 12px;
padding: 28px;
width: 420px;
max-width: 90vw;
box-shadow: var(--shadow-lg);
animation: modalPop 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes modalPop { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
.add-modal h2 {
font-size: 18px; font-weight: 700; margin-bottom: 20px;
}
.add-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
.btn-cancel {
background: var(--bg-tertiary); border: 1px solid var(--border-primary);
color: var(--text-secondary); border-radius: var(--radius-sm);
padding: 7px 16px; font-size: 13px; cursor: pointer; transition: all var(--transition);
}
.btn-cancel:hover { color: var(--text-primary); }
/* Confirm delete modal */
.confirm-modal {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 12px;
padding: 28px;
width: 380px;
max-width: 90vw;
box-shadow: var(--shadow-lg);
text-align: center;
}
.confirm-modal h3 { font-size: 16px; margin-bottom: 8px; }
.confirm-modal p { font-size: 13px; color: var(--text-secondary); margin-bottom: 20px; }
.confirm-actions { display: flex; justify-content: center; gap: 10px; }
.btn-confirm-delete {
background: var(--accent-rose); border: none; color: #fff;
border-radius: var(--radius-sm); padding: 7px 18px; font-size: 13px;
font-weight: 600; cursor: pointer; transition: all var(--transition);
}
.btn-confirm-delete:hover { background: #e11d48; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useCallback, useRef, useMemo } = React;
// ──────────────────────────────────────────
// DATA DEFINITIONS
// ──────────────────────────────────────────
const PHASES = [
{ id: 1, name: 'Discovery & Research', color: '#3B82F6', stages: [1,2,3,4] },
{ id: 2, name: 'Build', color: '#8B5CF6', stages: [5,6,7,8] },
{ id: 3, name: 'Testing & Hardening', color: '#F59E0B', stages: [9,10,11,12] },
{ id: 4, name: 'Documentation & Packaging', color: '#14B8A6', stages: [13,14,15] },
{ id: 5, name: 'Launch & Distribution', color: '#F43F5E', stages: [16,17,18] },
{ id: 6, name: 'Adoption & Feedback', color: '#10B981', stages: [19,20,21] },
{ id: 7, name: 'Monetization & Scale', color: '#EAB308', stages: [22,23,24,25] },
];
const STAGES = [
{ id: 1, name: 'Identified', desc: 'API/platform spotted', phase: 1 },
{ id: 2, name: 'Market Research', desc: 'Competitive landscape, TAM, go/no-go', phase: 1 },
{ id: 3, name: 'API Research', desc: 'Docs evaluated, auth, rate limits, endpoints', phase: 1 },
{ id: 4, name: 'Architecture Designed', desc: 'Tool list, UI apps planned, schemas', phase: 1 },
{ id: 5, name: 'Server Scaffolded', desc: 'Project init, dual transport, imports', phase: 2 },
{ id: 6, name: 'Core Tools Built', desc: 'registerAppTool(), Zod schemas', phase: 2 },
{ id: 7, name: 'UI Apps Built', desc: 'registerAppResource(), CSP, dev tested', phase: 2 },
{ id: 8, name: 'Integration Complete', desc: 'Tools+apps wired, text fallback, compiles', phase: 2 },
{ id: 9, name: 'Local Testing', desc: 'Live API, real creds, auth verified', phase: 3 },
{ id: 10, name: 'Edge Case Testing', desc: 'Rate limits, pagination, errors', phase: 3 },
{ id: 11, name: 'Host Compatibility', desc: 'LocalBosses, Claude, ChatGPT, VS Code', phase: 3 },
{ id: 12, name: 'Performance Validated', desc: 'Cold start, response times, memory', phase: 3 },
{ id: 13, name: 'README Written', desc: 'Install guide, auth, tool list, screenshots', phase: 4 },
{ id: 14, name: 'Package Prepared', desc: 'npm pkg, .npmignore, dist, CHANGELOG', phase: 4 },
{ id: 15, name: 'GitHub Published', desc: 'Public repo, license, CI/CD, topics', phase: 4 },
{ id: 16, name: 'Registry Listed', desc: 'npm, Smithery, mcp.so, Glama, ClawdHub', phase: 5 },
{ id: 17, name: 'Launch Marketing', desc: 'X, LinkedIn, Reddit, Discord, demo video', phase: 5 },
{ id: 18, name: 'Content Marketing', desc: 'Blog, walkthrough, SEO landing page', phase: 5 },
{ id: 19, name: 'Early Adopter Feedback', desc: 'Users, bugs, feature requests, UX', phase: 6 },
{ id: 20, name: 'Iteration Cycle', desc: 'Bugs fixed, features shipped, CHANGELOG', phase: 6 },
{ id: 21, name: 'Community Building', desc: 'Issues active, contributors, Discord', phase: 6 },
{ id: 22, name: 'Freemium/Pro Strategy', desc: 'Tiers defined, licensing model', phase: 7 },
{ id: 23, name: 'Enterprise Outreach', desc: 'Target accounts, deployment scoped', phase: 7 },
{ id: 24, name: 'Enterprise Deals', desc: 'Pilot/POC, SLA, signed contracts', phase: 7 },
{ id: 25, name: 'Raving Fans', desc: 'Testimonials, case studies, NPS > 50', phase: 7 },
];
const getPhaseForStage = (stageId) => PHASES.find(p => p.stages.includes(stageId));
const now = new Date().toISOString();
const DEFAULT_SERVERS = [
// Big 4
{ id: 'closebot', name: 'CloseBot MCP', stage: 8, tools: 119, apps: 6, type: 'big4', desc: '119 tools, 14 modules, 6 UI apps', stageHistory: [{stage:8,date:now}], blocker: false, blockerText: '', notes: 'Custom-built. Full integration complete.' },
{ id: 'meta-ads', name: 'Meta Ads MCP', stage: 8, tools: 55, apps: 11, type: 'big4', desc: '~55 tools, 11 categories, 11 UI apps', stageHistory: [{stage:8,date:now}], blocker: false, blockerText: '', notes: 'Custom-built. Full integration complete.' },
{ id: 'google-console', name: 'Google Console MCP', stage: 8, tools: 22, apps: 5, type: 'big4', desc: '22 tools, 5 UI apps', stageHistory: [{stage:8,date:now}], blocker: false, blockerText: '', notes: 'Custom-built. Full integration complete.' },
{ id: 'twilio', name: 'Twilio MCP', stage: 8, tools: 54, apps: 19, type: 'big4', desc: '54 tools, 19 UI apps', stageHistory: [{stage:8,date:now}], blocker: false, blockerText: '', notes: 'Custom-built. Full integration complete.' },
// GoHighLevel
{ id: 'gohighlevel', name: 'GoHighLevel MCP', stage: 8, tools: 240, apps: 65, type: 'ghl', desc: '65 apps, ~240 tools', stageHistory: [{stage:8,date:now}], blocker: false, blockerText: '', notes: 'Massive server. Integration complete.' },
// 31 Standard Servers
...['Acuity Scheduling','BambooHR','Basecamp','BigCommerce','Brevo','Calendly','ClickUp','Close','Clover','Constant Contact','FieldEdge','FreshBooks','FreshDesk','Gusto','HelpScout','Housecall Pro','Jobber','Keap','Lightspeed','Mailchimp','Pipedrive','Rippling','ServiceTitan','Squarespace','Toast','TouchBistro','Trello','Wave','Wrike','Zendesk'].map(name => ({
id: name.toLowerCase().replace(/\s+/g, '-'),
name: name + ' MCP',
stage: 8,
tools: null,
apps: null,
type: 'standard',
desc: 'Compiled clean, not tested against live APIs',
stageHistory: [{stage:8,date:now}],
blocker: false,
blockerText: '',
notes: '',
})),
];
const STORAGE_KEY = 'mcp-command-center';
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && parsed.servers && parsed.servers.length > 0) return parsed;
}
} catch(e) {}
return { servers: DEFAULT_SERVERS, version: 1 };
}
function saveState(state) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
// ──────────────────────────────────────────
// ICONS (inline SVG components)
// ──────────────────────────────────────────
const IconSearch = () => <svg viewBox="0 0 16 16" fill="currentColor"><path d="M11.5 7a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Zm-.82 4.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04Z"/></svg>;
const IconPlus = () => <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 2a.75.75 0 0 1 .75.75v4.5h4.5a.75.75 0 0 1 0 1.5h-4.5v4.5a.75.75 0 0 1-1.5 0v-4.5h-4.5a.75.75 0 0 1 0-1.5h4.5v-4.5A.75.75 0 0 1 8 2Z"/></svg>;
const IconCommand = () => <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3z"/></svg>;
const IconClose = () => <svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/></svg>;
const IconTool = () => <svg viewBox="0 0 16 16" fill="currentColor"><path d="M5.433 2.304A4.494 4.494 0 0 0 3.5 6c0 1.598.832 3.002 2.09 3.802.518.328.929.923.902 1.64v.008l-.164 3.337a.75.75 0 1 1-1.498-.073l.163-3.34c.007-.14-.1-.313-.369-.486A5.994 5.994 0 0 1 2 6a5.994 5.994 0 0 1 3.09-5.249.75.75 0 1 1 .686 1.333l-.343.22ZM9.5 2.25a.75.75 0 0 1 .75-.75 5.99 5.99 0 0 1 0 11.98.75.75 0 0 1 0-1.5 4.49 4.49 0 0 0 0-8.98.75.75 0 0 1-.75-.75Z"/></svg>;
const IconApp = () => <svg viewBox="0 0 16 16" fill="currentColor"><path d="M1.5 3.25c0-.966.784-1.75 1.75-1.75h2.5c.966 0 1.75.784 1.75 1.75v2.5A1.75 1.75 0 0 1 5.75 7.5h-2.5A1.75 1.75 0 0 1 1.5 5.75Zm1.75-.25a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h2.5A.25.25 0 0 0 6 5.75v-2.5a.25.25 0 0 0-.25-.25ZM1.5 10.25c0-.966.784-1.75 1.75-1.75h2.5c.966 0 1.75.784 1.75 1.75v2.5a1.75 1.75 0 0 1-1.75 1.75h-2.5a1.75 1.75 0 0 1-1.75-1.75Zm1.75-.25a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h2.5a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25ZM8.5 3.25c0-.966.784-1.75 1.75-1.75h2.5c.966 0 1.75.784 1.75 1.75v2.5A1.75 1.75 0 0 1 12.75 7.5h-2.5A1.75 1.75 0 0 1 8.5 5.75Zm1.75-.25a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h2.5A.25.25 0 0 0 13 5.75v-2.5a.25.25 0 0 0-.25-.25ZM8.5 10.25c0-.966.784-1.75 1.75-1.75h2.5c.966 0 1.75.784 1.75 1.75v2.5a1.75 1.75 0 0 1-1.75 1.75h-2.5a1.75 1.75 0 0 1-1.75-1.75Zm1.75-.25a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h2.5a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25Z"/></svg>;
const IconClock = () => <svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.556 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z"/></svg>;
const IconBlocker = () => <span className="blocker-icon"></span>;
// ──────────────────────────────────────────
// UTILITY
// ──────────────────────────────────────────
function daysAgo(dateStr) {
if (!dateStr) return 0;
const d = new Date(dateStr);
const now = new Date();
return Math.max(0, Math.floor((now - d) / (1000 * 60 * 60 * 24)));
}
function fmtDate(dateStr) {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function generateId() {
return 'mcp-' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
}
function getTypeBadge(type) {
switch(type) {
case 'big4': return { label: 'BIG 4', bg: 'rgba(139,92,246,0.15)', color: '#a78bfa' };
case 'ghl': return { label: 'GHL', bg: 'rgba(234,179,8,0.15)', color: '#facc15' };
case 'standard': return { label: 'STD', bg: 'rgba(59,130,246,0.1)', color: '#60a5fa' };
default: return null;
}
}
// ──────────────────────────────────────────
// MAIN APP
// ──────────────────────────────────────────
function App() {
const [state, setState] = useState(loadState);
const [search, setSearch] = useState('');
const [selectedCard, setSelectedCard] = useState(null);
const [showAddModal, setShowAddModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(null);
const [phaseFilter, setPhaseFilter] = useState(PHASES.map(p => p.id));
const [dragOverStage, setDragOverStage] = useState(null);
const [toasts, setToasts] = useState([]);
const searchRef = useRef(null);
const dragItem = useRef(null);
// Persist
useEffect(() => { saveState(state); }, [state]);
// Keyboard shortcuts
useEffect(() => {
const handler = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'n') { e.preventDefault(); setShowAddModal(true); }
if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); searchRef.current?.focus(); }
if (e.key === 'Escape') { setSelectedCard(null); setShowAddModal(false); setShowDeleteConfirm(null); }
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// Toast
const addToast = useCallback((msg) => {
const id = Date.now();
setToasts(t => [...t, { id, msg }]);
setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 2500);
}, []);
// Servers
const servers = state.servers;
const updateServer = useCallback((id, updates) => {
setState(prev => ({
...prev,
servers: prev.servers.map(s => s.id === id ? { ...s, ...updates, lastUpdated: new Date().toISOString() } : s)
}));
}, []);
const moveServer = useCallback((id, newStage) => {
setState(prev => ({
...prev,
servers: prev.servers.map(s => {
if (s.id !== id || s.stage === newStage) return s;
return {
...s,
stage: newStage,
stageHistory: [...(s.stageHistory || []), { stage: newStage, date: new Date().toISOString() }],
lastUpdated: new Date().toISOString()
};
})
}));
}, []);
const addServer = useCallback((data) => {
const newServer = {
id: generateId(),
name: data.name,
stage: data.stage || 1,
tools: data.tools || null,
apps: data.apps || null,
type: 'custom',
desc: data.desc || '',
stageHistory: [{ stage: data.stage || 1, date: new Date().toISOString() }],
blocker: false,
blockerText: '',
notes: '',
lastUpdated: new Date().toISOString(),
};
setState(prev => ({ ...prev, servers: [...prev.servers, newServer] }));
addToast(`Added "${data.name}"`);
}, [addToast]);
const deleteServer = useCallback((id) => {
const name = servers.find(s => s.id === id)?.name;
setState(prev => ({ ...prev, servers: prev.servers.filter(s => s.id !== id) }));
setSelectedCard(null);
setShowDeleteConfirm(null);
addToast(`Deleted "${name}"`);
}, [servers, addToast]);
// Stats
const stats = useMemo(() => {
const total = servers.length;
const inBuild = servers.filter(s => s.stage >= 5 && s.stage <= 8).length;
const inTesting = servers.filter(s => s.stage >= 9 && s.stage <= 12).length;
const shipped = servers.filter(s => s.stage >= 16).length;
const blocked = servers.filter(s => s.blocker).length;
const avgProgress = total > 0 ? Math.round(servers.reduce((a, s) => a + (s.stage / 25) * 100, 0) / total) : 0;
return { total, inBuild, inTesting, shipped, blocked, avgProgress };
}, [servers]);
// Filter
const filteredServers = useMemo(() => {
return servers.filter(s => {
if (search) {
const q = search.toLowerCase();
if (!s.name.toLowerCase().includes(q)) return false;
}
const phase = getPhaseForStage(s.stage);
if (phase && !phaseFilter.includes(phase.id)) return false;
return true;
});
}, [servers, search, phaseFilter]);
const serversForStage = useCallback((stageId) => {
return filteredServers.filter(s => s.stage === stageId);
}, [filteredServers]);
// Phase filter toggle
const togglePhase = (phaseId) => {
setPhaseFilter(prev => {
if (prev.includes(phaseId)) {
const next = prev.filter(p => p !== phaseId);
return next.length === 0 ? PHASES.map(p => p.id) : next;
}
return [...prev, phaseId];
});
};
// Drag and drop
const onDragStart = (e, serverId) => {
dragItem.current = serverId;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', serverId);
requestAnimationFrame(() => {
const el = document.getElementById('card-' + serverId);
if (el) el.classList.add('dragging');
});
};
const onDragEnd = (e) => {
const el = document.getElementById('card-' + dragItem.current);
if (el) el.classList.remove('dragging');
dragItem.current = null;
setDragOverStage(null);
};
const onDragOver = (e, stageId) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverStage(stageId);
};
const onDragLeave = (e, stageId) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setDragOverStage(null);
}
};
const onDrop = (e, stageId) => {
e.preventDefault();
const serverId = e.dataTransfer.getData('text/plain') || dragItem.current;
if (serverId) {
const server = servers.find(s => s.id === serverId);
if (server && server.stage !== stageId) {
moveServer(serverId, stageId);
const stageName = STAGES.find(s => s.id === stageId)?.name;
addToast(`Moved "${server.name}" → ${stageName}`);
}
}
setDragOverStage(null);
dragItem.current = null;
};
const selectedServer = selectedCard ? servers.find(s => s.id === selectedCard) : null;
return (
<div className="app">
{/* TOP BAR */}
<div className="topbar">
<div className="topbar-main">
<div className="topbar-title">
<IconCommand />
MCP Command Center
</div>
<div className="topbar-search">
<IconSearch />
<input
ref={searchRef}
type="text"
placeholder="Search servers..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<span className="shortcut">F</span>
</div>
<button className="btn-add" onClick={() => setShowAddModal(true)}>
<IconPlus /> New MCP <span className="kbd">N</span>
</button>
</div>
<div className="stats-row">
<div className="stat-pill"><span className="num">{stats.total}</span> Total</div>
<div className="stat-pill"><span className="num">{stats.inBuild}</span> In Build</div>
<div className="stat-pill"><span className="num">{stats.inTesting}</span> In Testing</div>
<div className="stat-pill"><span className="num">{stats.shipped}</span> Shipped</div>
<div className={"stat-pill" + (stats.blocked > 0 ? " blocked" : "")}><span className="num">{stats.blocked}</span> Blocked</div>
<div className="stat-sep" />
<div className="phase-filters">
{PHASES.map(p => (
<span
key={p.id}
className={"phase-pill " + (phaseFilter.includes(p.id) ? "active" : "inactive")}
style={{
background: phaseFilter.includes(p.id) ? p.color + '20' : 'transparent',
color: p.color,
borderColor: phaseFilter.includes(p.id) ? p.color + '40' : 'transparent',
}}
onClick={() => togglePhase(p.id)}
>
P{p.id}
</span>
))}
</div>
<div className="overall-progress">
<span className="overall-progress-label">Portfolio</span>
<div className="overall-progress-bar">
<div className="overall-progress-fill" style={{ width: stats.avgProgress + '%' }} />
</div>
<span className="overall-progress-pct">{stats.avgProgress}%</span>
</div>
</div>
</div>
{/* BOARD */}
<div className="board-container">
<div className="board">
{PHASES.filter(p => phaseFilter.includes(p.id)).map(phase => {
const phaseServers = servers.filter(s => phase.stages.includes(s.stage));
const phaseCards = filteredServers.filter(s => phase.stages.includes(s.stage));
return (
<div className="phase-group" key={phase.id}>
<div className="phase-header" style={{ borderBottomColor: phase.color, color: phase.color }}>
<span className="phase-dot" style={{ background: phase.color }} />
Phase {phase.id}: {phase.name}
<span className="phase-stats">{phaseCards.length} server{phaseCards.length !== 1 ? 's' : ''}</span>
</div>
<div className="phase-stages">
{phase.stages.map(stageId => {
const stage = STAGES.find(s => s.id === stageId);
const cards = serversForStage(stageId);
return (
<div className="stage-column" key={stageId}>
<div className="stage-header">
<div className="stage-header-top">
<span className="stage-num" style={{ background: phase.color + '20', color: phase.color }}>{stageId}</span>
<span className="stage-name">{stage.name}</span>
<span className="stage-count">{cards.length}</span>
</div>
<div className="stage-desc">{stage.desc}</div>
</div>
<div
className={"stage-cards" + (dragOverStage === stageId ? " drag-over" : "")}
onDragOver={(e) => onDragOver(e, stageId)}
onDragLeave={(e) => onDragLeave(e, stageId)}
onDrop={(e) => onDrop(e, stageId)}
>
{cards.length === 0 && <div className="empty-col">Drop here</div>}
{cards.map(server => (
<Card
key={server.id}
server={server}
phase={phase}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={() => setSelectedCard(server.id)}
/>
))}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
{/* DETAIL PANEL */}
{selectedServer && (
<DetailPanel
server={selectedServer}
onClose={() => setSelectedCard(null)}
onUpdate={(updates) => updateServer(selectedServer.id, updates)}
onMove={(newStage) => {
moveServer(selectedServer.id, newStage);
addToast(`Moved "${selectedServer.name}" → ${STAGES.find(s => s.id === newStage)?.name}`);
}}
onDelete={() => setShowDeleteConfirm(selectedServer.id)}
/>
)}
{/* ADD MODAL */}
{showAddModal && (
<AddModal
onAdd={(data) => { addServer(data); setShowAddModal(false); }}
onClose={() => setShowAddModal(false)}
/>
)}
{/* DELETE CONFIRM */}
{showDeleteConfirm && (
<div className="add-modal-overlay" onClick={() => setShowDeleteConfirm(null)}>
<div className="confirm-modal" onClick={e => e.stopPropagation()}>
<h3>Delete Server?</h3>
<p>This will permanently remove "{servers.find(s => s.id === showDeleteConfirm)?.name}" from the board.</p>
<div className="confirm-actions">
<button className="btn-cancel" onClick={() => setShowDeleteConfirm(null)}>Cancel</button>
<button className="btn-confirm-delete" onClick={() => deleteServer(showDeleteConfirm)}>Delete</button>
</div>
</div>
</div>
)}
{/* TOASTS */}
<div className="toast-container">
{toasts.map(t => <div key={t.id} className="toast"> {t.msg}</div>)}
</div>
</div>
);
}
// ──────────────────────────────────────────
// CARD COMPONENT
// ──────────────────────────────────────────
function Card({ server, phase, onDragStart, onDragEnd, onClick }) {
const progress = Math.round((server.stage / 25) * 100);
const badge = getTypeBadge(server.type);
const lastEntry = server.stageHistory?.[server.stageHistory.length - 1];
const days = daysAgo(lastEntry?.date || server.lastUpdated);
return (
<div
id={'card-' + server.id}
className="card"
draggable
onDragStart={(e) => onDragStart(e, server.id)}
onDragEnd={onDragEnd}
onClick={onClick}
>
<div className="card-border-accent" style={{ background: phase.color }} />
{badge && (
<span className="card-type-badge" style={{ background: badge.bg, color: badge.color }}>{badge.label}</span>
)}
<div className="card-name">
{server.blocker && <IconBlocker />}
{server.name}
</div>
<div className="card-meta">
{server.tools != null && <span><IconTool /> {server.tools} tools</span>}
{server.apps != null && <span><IconApp /> {server.apps} apps</span>}
<span><IconClock /> {days === 0 ? 'Today' : days + 'd'}</span>
</div>
<div className="card-progress">
<div className="card-progress-fill" style={{ width: progress + '%', background: phase.color }} />
</div>
</div>
);
}
// ──────────────────────────────────────────
// DETAIL PANEL
// ──────────────────────────────────────────
function DetailPanel({ server, onClose, onUpdate, onMove, onDelete }) {
const [name, setName] = useState(server.name);
const [desc, setDesc] = useState(server.desc || '');
const [tools, setTools] = useState(server.tools ?? '');
const [apps, setApps] = useState(server.apps ?? '');
const [blockerText, setBlockerText] = useState(server.blockerText || '');
const [notes, setNotes] = useState(server.notes || '');
const [blocker, setBlocker] = useState(server.blocker || false);
// Sync when card changes
useEffect(() => {
setName(server.name);
setDesc(server.desc || '');
setTools(server.tools ?? '');
setApps(server.apps ?? '');
setBlockerText(server.blockerText || '');
setNotes(server.notes || '');
setBlocker(server.blocker || false);
}, [server.id]);
const handleSave = () => {
onUpdate({
name,
desc,
tools: tools === '' ? null : Number(tools),
apps: apps === '' ? null : Number(apps),
blockerText,
notes,
blocker,
});
};
// Auto-save on changes
useEffect(() => {
const timer = setTimeout(handleSave, 400);
return () => clearTimeout(timer);
}, [name, desc, tools, apps, blockerText, notes, blocker]);
const phase = getPhaseForStage(server.stage);
const progress = Math.round((server.stage / 25) * 100);
const badge = getTypeBadge(server.type);
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-panel" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div style={{ flex: 1 }}>
<input
className="modal-name-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Server name..."
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
{badge && (
<span className="card-type-badge" style={{ background: badge.bg, color: badge.color, position: 'static' }}>
{badge.label}
</span>
)}
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
Stage {server.stage}/25 · {progress}%
</span>
</div>
<div style={{ marginTop: 10, height: 4, background: 'var(--bg-tertiary)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ height: '100%', width: progress + '%', background: phase?.color || '#3B82F6', borderRadius: 2, transition: 'width 0.4s ease' }} />
</div>
</div>
<button className="modal-close" onClick={onClose}><IconClose /></button>
</div>
<div className="modal-body">
<div className="modal-field">
<label className="modal-label">Description</label>
<textarea className="modal-textarea" value={desc} onChange={e => setDesc(e.target.value)} placeholder="What does this server do?" rows={2} />
</div>
<div className="modal-row">
<div className="modal-field">
<label className="modal-label">Tool Count</label>
<input className="modal-input" type="number" value={tools} onChange={e => setTools(e.target.value)} placeholder="—" />
</div>
<div className="modal-field">
<label className="modal-label">App Count</label>
<input className="modal-input" type="number" value={apps} onChange={e => setApps(e.target.value)} placeholder="—" />
</div>
</div>
<div className="modal-field">
<label className="modal-label">Current Stage</label>
<select
className="modal-select"
value={server.stage}
onChange={e => onMove(Number(e.target.value))}
>
{STAGES.map(s => (
<option key={s.id} value={s.id}>{s.id}. {s.name}</option>
))}
</select>
</div>
<div className="modal-field">
<label className="modal-label">Blocker</label>
<label className="blocker-toggle">
<input type="checkbox" checked={blocker} onChange={e => setBlocker(e.target.checked)} />
<span className="blocker-switch" />
<span className="blocker-text">{blocker ? 'Blocked' : 'No blockers'}</span>
</label>
{blocker && (
<textarea
className="modal-textarea"
style={{ marginTop: 8 }}
value={blockerText}
onChange={e => setBlockerText(e.target.value)}
placeholder="Describe the blocker..."
rows={2}
/>
)}
</div>
<div className="modal-field">
<label className="modal-label">Notes</label>
<textarea className="modal-textarea" value={notes} onChange={e => setNotes(e.target.value)} placeholder="Internal notes..." rows={3} />
</div>
<div className="modal-field">
<label className="modal-label">Stage History</label>
<ul className="stage-history">
{[...(server.stageHistory || [])].reverse().map((entry, i) => {
const s = STAGES.find(st => st.id === entry.stage);
return (
<li key={i}>
<span className="sh-stage">{entry.stage}. {s?.name || '?'}</span>
<span className="sh-date">{fmtDate(entry.date)}</span>
</li>
);
})}
</ul>
</div>
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 8 }}>
Last updated: {fmtDate(server.lastUpdated || server.stageHistory?.[server.stageHistory.length-1]?.date)}
</div>
</div>
<div className="modal-footer">
<button className="btn-delete" onClick={onDelete}>Delete Server</button>
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', alignSelf: 'center' }}>Auto-saved</span>
</div>
</div>
</div>
);
}
// ──────────────────────────────────────────
// ADD MODAL
// ──────────────────────────────────────────
function AddModal({ onAdd, onClose }) {
const [name, setName] = useState('');
const [stage, setStage] = useState(1);
const [tools, setTools] = useState('');
const [apps, setApps] = useState('');
const [desc, setDesc] = useState('');
const nameRef = useRef(null);
useEffect(() => { nameRef.current?.focus(); }, []);
const handleSubmit = (e) => {
e.preventDefault();
if (!name.trim()) return;
onAdd({ name: name.trim(), stage, tools: tools ? Number(tools) : null, apps: apps ? Number(apps) : null, desc });
};
return (
<div className="add-modal-overlay" onClick={onClose}>
<div className="add-modal" onClick={e => e.stopPropagation()}>
<h2>Add New MCP Server</h2>
<form onSubmit={handleSubmit}>
<div className="modal-field">
<label className="modal-label">Server Name</label>
<input ref={nameRef} className="modal-input" value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Stripe MCP" required />
</div>
<div className="modal-field">
<label className="modal-label">Starting Stage</label>
<select className="modal-select" value={stage} onChange={e => setStage(Number(e.target.value))}>
{STAGES.map(s => <option key={s.id} value={s.id}>{s.id}. {s.name}</option>)}
</select>
</div>
<div className="modal-row">
<div className="modal-field">
<label className="modal-label">Tools</label>
<input className="modal-input" type="number" value={tools} onChange={e => setTools(e.target.value)} placeholder="—" />
</div>
<div className="modal-field">
<label className="modal-label">Apps</label>
<input className="modal-input" type="number" value={apps} onChange={e => setApps(e.target.value)} placeholder="—" />
</div>
</div>
<div className="modal-field">
<label className="modal-label">Description</label>
<input className="modal-input" value={desc} onChange={e => setDesc(e.target.value)} placeholder="Brief description..." />
</div>
<div className="add-modal-actions">
<button type="button" className="btn-cancel" onClick={onClose}>Cancel <span className="kbd">Esc</span></button>
<button type="submit" className="btn-add">Add Server</button>
</div>
</form>
</div>
</div>
);
}
// ──────────────────────────────────────────
// RENDER
// ──────────────────────────────────────────
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>