Skip to content

Commit 01bb65d

Browse files
Fenrurclaude
andcommitted
feat: add built-in Kanban board to web UI (PR moazbuilds#42)
- Kanban board with Todo/In Progress/Done columns - Persistent storage via /api/kanban endpoints - Task creation modal, drag-and-drop ready - Integrated as third tab alongside Dashboard and Chat Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2aee82d commit 01bb65d

File tree

5 files changed

+504
-2
lines changed

5 files changed

+504
-2
lines changed

src/ui/page/script.ts

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1190,4 +1190,131 @@ export const pageScript = String.raw` const $ = (id) => document.getElementBy
11901190
var elapsedEl = chatMessages.querySelector(".chat-msg-elapsed");
11911191
if (elapsedEl) elapsedEl.textContent = fmtElapsed(Date.now() - chatStartedAt);
11921192
}
1193-
}, 1000);`;
1193+
}, 1000);
1194+
1195+
// ── Kanban ───────────────────────────────────────────────────────────────────
1196+
var tabKanbanBtn = $("tab-kanban-btn");
1197+
var kanbanPanel = $("kanban-panel");
1198+
1199+
if (tabKanbanBtn) tabKanbanBtn.addEventListener("click", function() {
1200+
if (dashboardPanel) dashboardPanel.hidden = true;
1201+
if (chatPanel) chatPanel.hidden = true;
1202+
if (kanbanPanel) kanbanPanel.hidden = false;
1203+
if (tabDashboardBtn) tabDashboardBtn.classList.remove("tab-btn-active");
1204+
if (tabChatBtn) tabChatBtn.classList.remove("tab-btn-active");
1205+
tabKanbanBtn.classList.add("tab-btn-active");
1206+
});
1207+
1208+
var kanbanData = { columns: { todo: [], in_progress: [], done: [] } };
1209+
1210+
function timeAgoKanban(isoString) {
1211+
if (!isoString) return "";
1212+
var diff = Date.now() - new Date(isoString).getTime();
1213+
var s = Math.floor(diff / 1000);
1214+
if (s < 60) return s + "s ago";
1215+
var m = Math.floor(s / 60);
1216+
if (m < 60) return m + "m ago";
1217+
var h = Math.floor(m / 60);
1218+
if (h < 24) return h + "h ago";
1219+
return Math.floor(h / 24) + "d ago";
1220+
}
1221+
1222+
function escKanban(str) {
1223+
if (!str) return "";
1224+
return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1225+
}
1226+
1227+
function renderKanbanCard(card, isDone) {
1228+
return '<div class="kanban-card" data-id="' + escKanban(card.id) + '">' +
1229+
'<div class="kanban-card-title">' + escKanban(card.title) + "</div>" +
1230+
(card.description ? '<div class="kanban-card-desc">' + escKanban(card.description) + "</div>" : "") +
1231+
'<div class="kanban-card-meta">' +
1232+
(card.started_at ? '<span>' + timeAgoKanban(isDone ? card.completed_at : card.started_at) + "</span>" : "") +
1233+
"</div>" +
1234+
"</div>";
1235+
}
1236+
1237+
function renderKanban() {
1238+
var todo = kanbanData.columns.todo || [];
1239+
var ip = kanbanData.columns.in_progress || [];
1240+
var done = kanbanData.columns.done || [];
1241+
1242+
var countTodo = $("kanban-count-todo");
1243+
var countIp = $("kanban-count-inprogress");
1244+
var countDone = $("kanban-count-done");
1245+
var cardsTodo = $("kanban-cards-todo");
1246+
var cardsIp = $("kanban-cards-inprogress");
1247+
var cardsDone = $("kanban-cards-done");
1248+
1249+
if (countTodo) countTodo.textContent = todo.length;
1250+
if (countIp) countIp.textContent = ip.length;
1251+
if (countDone) countDone.textContent = done.length;
1252+
1253+
if (cardsTodo) cardsTodo.innerHTML = todo.length
1254+
? todo.map(function(c) { return renderKanbanCard(c, false); }).join("")
1255+
: '<div class="kanban-empty">No tasks queued</div>';
1256+
if (cardsIp) cardsIp.innerHTML = ip.length
1257+
? ip.map(function(c) { return renderKanbanCard(c, false); }).join("")
1258+
: '<div class="kanban-empty">No active tasks</div>';
1259+
if (cardsDone) cardsDone.innerHTML = done.length
1260+
? done.map(function(c) { return renderKanbanCard(c, true); }).join("")
1261+
: '<div class="kanban-empty">Nothing completed yet</div>';
1262+
}
1263+
1264+
async function loadKanban() {
1265+
try {
1266+
var res = await fetch("/api/kanban");
1267+
if (res.ok) { kanbanData = await res.json(); renderKanban(); }
1268+
} catch (_) {}
1269+
}
1270+
1271+
async function saveKanban() {
1272+
try {
1273+
await fetch("/api/kanban", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(kanbanData) });
1274+
} catch (_) {}
1275+
}
1276+
1277+
function clearKanbanDone() {
1278+
kanbanData.columns.done = [];
1279+
renderKanban();
1280+
saveKanban();
1281+
}
1282+
1283+
var kanbanModal = $("kanban-modal-overlay");
1284+
var kanbanAddBtn = $("kanban-add-btn");
1285+
var kanbanCancelBtn = $("kanban-cancel-btn");
1286+
var kanbanSaveBtn = $("kanban-save-btn");
1287+
var kanbanModalClose = $("kanban-modal-close");
1288+
1289+
function openKanbanModal() { if (kanbanModal) kanbanModal.hidden = false; }
1290+
function closeKanbanModal() {
1291+
if (kanbanModal) kanbanModal.hidden = true;
1292+
var t = $("kanban-input-title"); var d = $("kanban-input-desc");
1293+
if (t) t.value = ""; if (d) d.value = "";
1294+
}
1295+
1296+
function addKanbanTask() {
1297+
var titleEl = $("kanban-input-title");
1298+
var descEl = $("kanban-input-desc");
1299+
var title = titleEl ? titleEl.value.trim() : "";
1300+
if (!title) { if (titleEl) titleEl.focus(); return; }
1301+
var card = { id: "task-" + Date.now(), title: title, description: descEl ? descEl.value.trim() : "", started_at: new Date().toISOString() };
1302+
if (!Array.isArray(kanbanData.columns.todo)) kanbanData.columns.todo = [];
1303+
kanbanData.columns.todo.unshift(card);
1304+
closeKanbanModal();
1305+
renderKanban();
1306+
saveKanban();
1307+
}
1308+
1309+
if (kanbanAddBtn) kanbanAddBtn.addEventListener("click", openKanbanModal);
1310+
if (kanbanCancelBtn) kanbanCancelBtn.addEventListener("click", closeKanbanModal);
1311+
if (kanbanModalClose) kanbanModalClose.addEventListener("click", closeKanbanModal);
1312+
if (kanbanSaveBtn) kanbanSaveBtn.addEventListener("click", addKanbanTask);
1313+
if ($("kanban-input-title")) {
1314+
$("kanban-input-title").addEventListener("keydown", function(e) {
1315+
if (e.key === "Enter") { e.preventDefault(); addKanbanTask(); }
1316+
});
1317+
}
1318+
1319+
loadKanban();
1320+
setInterval(loadKanban, 10000);`;

src/ui/page/styles.ts

Lines changed: 223 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1395,4 +1395,226 @@ export const pageStyles = String.raw` :root {
13951395
.pill:nth-last-child(2) {
13961396
border-bottom: 0;
13971397
}
1398-
}`;
1398+
}
1399+
1400+
/* ── Tab nav ── */
1401+
[hidden] { display: none !important; }
1402+
.tab-nav {
1403+
display: flex;
1404+
gap: 4px;
1405+
padding: 12px 24px 0;
1406+
border-bottom: 1px solid var(--glass-border, rgba(255,255,255,0.08));
1407+
margin-bottom: 0;
1408+
}
1409+
.tab-btn {
1410+
background: none;
1411+
border: none;
1412+
border-bottom: 2px solid transparent;
1413+
color: var(--fg-3, #888);
1414+
cursor: pointer;
1415+
font-family: inherit;
1416+
font-size: 13px;
1417+
font-weight: 500;
1418+
letter-spacing: 0.03em;
1419+
padding: 6px 14px 10px;
1420+
transition: color 0.15s, border-color 0.15s;
1421+
}
1422+
.tab-btn:hover { color: var(--fg-1, #eee); }
1423+
.tab-btn-active {
1424+
border-bottom-color: var(--accent, #4a9eff);
1425+
color: var(--fg-1, #eee);
1426+
}
1427+
1428+
/* ── Kanban board ── */
1429+
.kanban-board {
1430+
display: flex;
1431+
gap: 0;
1432+
height: calc(100vh - 200px);
1433+
overflow: hidden;
1434+
}
1435+
.kanban-col {
1436+
flex: 1;
1437+
display: flex;
1438+
flex-direction: column;
1439+
border-right: 1px solid rgba(255,255,255,0.06);
1440+
overflow: hidden;
1441+
}
1442+
.kanban-col:last-child { border-right: none; }
1443+
.kanban-col-header {
1444+
display: flex;
1445+
align-items: center;
1446+
justify-content: space-between;
1447+
padding: 14px 18px 10px;
1448+
flex-shrink: 0;
1449+
}
1450+
.kanban-col-title-group {
1451+
display: flex;
1452+
align-items: center;
1453+
gap: 8px;
1454+
}
1455+
.kanban-col-indicator {
1456+
width: 7px;
1457+
height: 7px;
1458+
border-radius: 50%;
1459+
}
1460+
.kanban-indicator-todo { background: #a855f7; }
1461+
.kanban-indicator-inprogress { background: #f59e0b; box-shadow: 0 0 6px #f59e0b; }
1462+
.kanban-indicator-done { background: #22c55e; }
1463+
.kanban-col-title {
1464+
font-size: 12px;
1465+
font-weight: 600;
1466+
letter-spacing: 0.05em;
1467+
text-transform: uppercase;
1468+
color: rgba(255,255,255,0.5);
1469+
}
1470+
.kanban-col-count {
1471+
font-size: 11px;
1472+
font-weight: 600;
1473+
background: rgba(255,255,255,0.08);
1474+
border-radius: 10px;
1475+
padding: 1px 7px;
1476+
color: rgba(255,255,255,0.4);
1477+
}
1478+
.kanban-clear-btn {
1479+
background: none;
1480+
border: 1px solid rgba(255,255,255,0.1);
1481+
border-radius: 5px;
1482+
color: rgba(255,255,255,0.35);
1483+
cursor: pointer;
1484+
font-size: 11px;
1485+
padding: 2px 8px;
1486+
}
1487+
.kanban-clear-btn:hover { color: rgba(255,255,255,0.7); border-color: rgba(255,255,255,0.25); }
1488+
.kanban-cards {
1489+
flex: 1;
1490+
overflow-y: auto;
1491+
padding: 8px 14px 16px;
1492+
display: flex;
1493+
flex-direction: column;
1494+
gap: 8px;
1495+
}
1496+
.kanban-card {
1497+
background: rgba(255,255,255,0.04);
1498+
border: 1px solid rgba(255,255,255,0.08);
1499+
border-radius: 8px;
1500+
padding: 10px 12px;
1501+
cursor: default;
1502+
transition: background 0.15s;
1503+
}
1504+
.kanban-card:hover { background: rgba(255,255,255,0.07); }
1505+
.kanban-card-title {
1506+
font-size: 13px;
1507+
font-weight: 500;
1508+
color: rgba(255,255,255,0.85);
1509+
margin-bottom: 3px;
1510+
}
1511+
.kanban-card-desc {
1512+
font-size: 12px;
1513+
color: rgba(255,255,255,0.4);
1514+
margin-bottom: 5px;
1515+
line-height: 1.4;
1516+
}
1517+
.kanban-card-meta {
1518+
font-size: 11px;
1519+
color: rgba(255,255,255,0.3);
1520+
font-family: "JetBrains Mono", monospace;
1521+
}
1522+
.kanban-empty {
1523+
color: rgba(255,255,255,0.2);
1524+
font-size: 12px;
1525+
text-align: center;
1526+
padding: 24px 0;
1527+
}
1528+
.kanban-toolbar {
1529+
padding: 10px 18px;
1530+
border-top: 1px solid rgba(255,255,255,0.06);
1531+
}
1532+
.kanban-add-btn {
1533+
background: rgba(74,158,255,0.1);
1534+
border: 1px solid rgba(74,158,255,0.25);
1535+
border-radius: 7px;
1536+
color: rgba(74,158,255,0.9);
1537+
cursor: pointer;
1538+
font-size: 13px;
1539+
font-weight: 500;
1540+
padding: 7px 16px;
1541+
transition: background 0.15s;
1542+
}
1543+
.kanban-add-btn:hover { background: rgba(74,158,255,0.18); }
1544+
/* Kanban modal */
1545+
.kanban-modal-overlay {
1546+
position: fixed;
1547+
inset: 0;
1548+
background: rgba(0,0,0,0.6);
1549+
display: flex;
1550+
align-items: center;
1551+
justify-content: center;
1552+
z-index: 200;
1553+
}
1554+
.kanban-modal {
1555+
background: #1a1a1f;
1556+
border: 1px solid rgba(255,255,255,0.1);
1557+
border-radius: 12px;
1558+
min-width: 360px;
1559+
max-width: 480px;
1560+
width: 100%;
1561+
}
1562+
.kanban-modal-header {
1563+
display: flex;
1564+
align-items: center;
1565+
justify-content: space-between;
1566+
padding: 16px 20px 12px;
1567+
font-weight: 600;
1568+
font-size: 14px;
1569+
border-bottom: 1px solid rgba(255,255,255,0.07);
1570+
}
1571+
.kanban-modal-close {
1572+
background: none;
1573+
border: none;
1574+
color: rgba(255,255,255,0.4);
1575+
cursor: pointer;
1576+
font-size: 20px;
1577+
line-height: 1;
1578+
}
1579+
.kanban-modal-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 10px; }
1580+
.kanban-input {
1581+
background: rgba(255,255,255,0.05);
1582+
border: 1px solid rgba(255,255,255,0.1);
1583+
border-radius: 7px;
1584+
color: rgba(255,255,255,0.85);
1585+
font-family: inherit;
1586+
font-size: 13px;
1587+
padding: 9px 12px;
1588+
width: 100%;
1589+
resize: none;
1590+
}
1591+
.kanban-input:focus { outline: none; border-color: rgba(74,158,255,0.4); }
1592+
.kanban-textarea { min-height: 70px; }
1593+
.kanban-modal-footer {
1594+
display: flex;
1595+
gap: 8px;
1596+
justify-content: flex-end;
1597+
padding: 12px 20px 16px;
1598+
border-top: 1px solid rgba(255,255,255,0.07);
1599+
}
1600+
.kanban-btn-primary {
1601+
background: rgba(74,158,255,0.15);
1602+
border: 1px solid rgba(74,158,255,0.3);
1603+
border-radius: 7px;
1604+
color: rgba(74,158,255,0.95);
1605+
cursor: pointer;
1606+
font-size: 13px;
1607+
font-weight: 500;
1608+
padding: 7px 16px;
1609+
}
1610+
.kanban-btn-primary:hover { background: rgba(74,158,255,0.25); }
1611+
.kanban-btn-secondary {
1612+
background: none;
1613+
border: 1px solid rgba(255,255,255,0.1);
1614+
border-radius: 7px;
1615+
color: rgba(255,255,255,0.4);
1616+
cursor: pointer;
1617+
font-size: 13px;
1618+
padding: 7px 16px;
1619+
}
1620+
.kanban-btn-secondary:hover { color: rgba(255,255,255,0.7); }`;

0 commit comments

Comments
 (0)