1351 lines
50 KiB
HTML
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>
|