@beastmode-develeap/beastmode 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +232 -45
- package/dist/index.js.map +1 -1
- package/dist/web/board.html +644 -88
- package/package.json +1 -1
package/dist/web/board.html
CHANGED
|
@@ -219,24 +219,18 @@ body {
|
|
|
219
219
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
/* Mobile
|
|
223
|
-
.
|
|
222
|
+
/* Mobile nav overlay — must be BELOW sidebar (z-index 100) */
|
|
223
|
+
.mobile-nav-overlay {
|
|
224
224
|
display: none;
|
|
225
225
|
position: fixed;
|
|
226
226
|
inset: 0;
|
|
227
227
|
background: rgba(0,0,0,0.5);
|
|
228
|
-
z-index:
|
|
228
|
+
z-index: 98;
|
|
229
229
|
}
|
|
230
|
-
.
|
|
230
|
+
.mobile-nav-overlay.open { display: block; }
|
|
231
231
|
|
|
232
|
-
@media (max-width:
|
|
232
|
+
@media (max-width: 900px) {
|
|
233
233
|
.mobile-menu-btn { display: block; }
|
|
234
|
-
.sidebar {
|
|
235
|
-
transform: translateX(-100%);
|
|
236
|
-
}
|
|
237
|
-
.sidebar.open {
|
|
238
|
-
transform: translateX(0);
|
|
239
|
-
}
|
|
240
234
|
.main {
|
|
241
235
|
margin-left: 0;
|
|
242
236
|
padding: 56px 16px 16px;
|
|
@@ -1035,6 +1029,8 @@ input[type="range"]::-webkit-slider-thumb {
|
|
|
1035
1029
|
color: var(--text-secondary);
|
|
1036
1030
|
display: flex;
|
|
1037
1031
|
justify-content: space-between;
|
|
1032
|
+
cursor: pointer;
|
|
1033
|
+
user-select: none;
|
|
1038
1034
|
align-items: center;
|
|
1039
1035
|
text-transform: uppercase;
|
|
1040
1036
|
letter-spacing: 0.3px;
|
|
@@ -1212,6 +1208,159 @@ input[type="range"]::-webkit-slider-thumb {
|
|
|
1212
1208
|
letter-spacing: 0.5px;
|
|
1213
1209
|
}
|
|
1214
1210
|
|
|
1211
|
+
/* Board header row — stats + search + filters + new task */
|
|
1212
|
+
.board-header-row {
|
|
1213
|
+
display: flex;
|
|
1214
|
+
align-items: center;
|
|
1215
|
+
gap: 12px;
|
|
1216
|
+
margin-bottom: 16px;
|
|
1217
|
+
flex-wrap: wrap;
|
|
1218
|
+
}
|
|
1219
|
+
.board-header-row .board-stats-bar {
|
|
1220
|
+
margin-bottom: 0;
|
|
1221
|
+
flex-shrink: 0;
|
|
1222
|
+
}
|
|
1223
|
+
.board-search-wrap {
|
|
1224
|
+
position: relative;
|
|
1225
|
+
flex: 1;
|
|
1226
|
+
min-width: 180px;
|
|
1227
|
+
max-width: 360px;
|
|
1228
|
+
}
|
|
1229
|
+
.board-search-wrap input {
|
|
1230
|
+
width: 100%;
|
|
1231
|
+
padding: 8px 32px 8px 12px;
|
|
1232
|
+
background: var(--bg-input);
|
|
1233
|
+
border: 1px solid var(--border);
|
|
1234
|
+
border-radius: var(--radius-sm);
|
|
1235
|
+
color: var(--text);
|
|
1236
|
+
font-size: 13px;
|
|
1237
|
+
font-family: var(--font-sans);
|
|
1238
|
+
outline: none;
|
|
1239
|
+
transition: border-color 0.15s;
|
|
1240
|
+
}
|
|
1241
|
+
.board-search-wrap input:focus {
|
|
1242
|
+
border-color: var(--accent);
|
|
1243
|
+
}
|
|
1244
|
+
.board-search-wrap input::placeholder {
|
|
1245
|
+
color: var(--text-muted);
|
|
1246
|
+
}
|
|
1247
|
+
.board-search-clear {
|
|
1248
|
+
position: absolute;
|
|
1249
|
+
right: 6px;
|
|
1250
|
+
top: 50%;
|
|
1251
|
+
transform: translateY(-50%);
|
|
1252
|
+
background: none;
|
|
1253
|
+
border: none;
|
|
1254
|
+
color: var(--text-muted);
|
|
1255
|
+
cursor: pointer;
|
|
1256
|
+
font-size: 16px;
|
|
1257
|
+
padding: 2px 4px;
|
|
1258
|
+
line-height: 1;
|
|
1259
|
+
}
|
|
1260
|
+
.board-search-clear:hover { color: var(--text); }
|
|
1261
|
+
|
|
1262
|
+
/* Filter toggle button */
|
|
1263
|
+
.filter-toggle {
|
|
1264
|
+
display: inline-flex;
|
|
1265
|
+
align-items: center;
|
|
1266
|
+
gap: 6px;
|
|
1267
|
+
padding: 0 14px;
|
|
1268
|
+
height: 36px;
|
|
1269
|
+
background: var(--bg-card);
|
|
1270
|
+
border: 1px solid var(--border);
|
|
1271
|
+
border-radius: var(--radius-sm);
|
|
1272
|
+
color: var(--text-secondary);
|
|
1273
|
+
font-size: 13px;
|
|
1274
|
+
font-family: var(--font-sans);
|
|
1275
|
+
cursor: pointer;
|
|
1276
|
+
transition: all 0.15s;
|
|
1277
|
+
white-space: nowrap;
|
|
1278
|
+
}
|
|
1279
|
+
.filter-toggle:hover { border-color: var(--text-muted); color: var(--text); }
|
|
1280
|
+
.filter-toggle.active { border-color: var(--accent); color: var(--accent); background: var(--accent-subtle); }
|
|
1281
|
+
.filter-active-count {
|
|
1282
|
+
display: inline-flex;
|
|
1283
|
+
align-items: center;
|
|
1284
|
+
justify-content: center;
|
|
1285
|
+
min-width: 18px;
|
|
1286
|
+
height: 18px;
|
|
1287
|
+
border-radius: 9px;
|
|
1288
|
+
background: var(--accent);
|
|
1289
|
+
color: #1a1a1a;
|
|
1290
|
+
font-size: 11px;
|
|
1291
|
+
font-weight: 700;
|
|
1292
|
+
padding: 0 5px;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/* Filter panel */
|
|
1296
|
+
.board-filter-panel {
|
|
1297
|
+
display: flex;
|
|
1298
|
+
flex-wrap: wrap;
|
|
1299
|
+
gap: 12px;
|
|
1300
|
+
align-items: flex-end;
|
|
1301
|
+
padding: 14px 16px;
|
|
1302
|
+
margin-bottom: 16px;
|
|
1303
|
+
background: var(--bg-card);
|
|
1304
|
+
border: 1px solid var(--border);
|
|
1305
|
+
border-radius: var(--radius-sm);
|
|
1306
|
+
animation: fadeSlideIn 0.15s ease;
|
|
1307
|
+
}
|
|
1308
|
+
.board-filter-panel .filter-group {
|
|
1309
|
+
display: flex;
|
|
1310
|
+
flex-direction: column;
|
|
1311
|
+
gap: 4px;
|
|
1312
|
+
min-width: 140px;
|
|
1313
|
+
}
|
|
1314
|
+
.board-filter-panel .filter-group label {
|
|
1315
|
+
font-size: 11px;
|
|
1316
|
+
font-weight: 600;
|
|
1317
|
+
color: var(--text-muted);
|
|
1318
|
+
text-transform: uppercase;
|
|
1319
|
+
letter-spacing: 0.3px;
|
|
1320
|
+
}
|
|
1321
|
+
.board-filter-panel .filter-group select {
|
|
1322
|
+
padding: 6px 10px;
|
|
1323
|
+
background: var(--bg-input);
|
|
1324
|
+
border: 1px solid var(--border);
|
|
1325
|
+
border-radius: var(--radius-xs);
|
|
1326
|
+
color: var(--text);
|
|
1327
|
+
font-size: 13px;
|
|
1328
|
+
font-family: var(--font-sans);
|
|
1329
|
+
outline: none;
|
|
1330
|
+
cursor: pointer;
|
|
1331
|
+
}
|
|
1332
|
+
.board-filter-panel .filter-group select:focus { border-color: var(--accent); }
|
|
1333
|
+
.filter-clear-link {
|
|
1334
|
+
font-size: 12px;
|
|
1335
|
+
color: var(--accent);
|
|
1336
|
+
cursor: pointer;
|
|
1337
|
+
background: none;
|
|
1338
|
+
border: none;
|
|
1339
|
+
font-family: var(--font-sans);
|
|
1340
|
+
padding: 6px 0;
|
|
1341
|
+
align-self: flex-end;
|
|
1342
|
+
}
|
|
1343
|
+
.filter-clear-link:hover { text-decoration: underline; }
|
|
1344
|
+
|
|
1345
|
+
/* Column sort indicator */
|
|
1346
|
+
.kanban-column-header .sort-indicator {
|
|
1347
|
+
font-size: 11px;
|
|
1348
|
+
margin-left: 4px;
|
|
1349
|
+
color: var(--accent);
|
|
1350
|
+
opacity: 0.8;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/* Project badge on card */
|
|
1354
|
+
.card-badge.badge-project {
|
|
1355
|
+
background: rgba(96, 165, 250, 0.12);
|
|
1356
|
+
color: #60a5fa;
|
|
1357
|
+
font-size: 10px;
|
|
1358
|
+
max-width: 90px;
|
|
1359
|
+
overflow: hidden;
|
|
1360
|
+
text-overflow: ellipsis;
|
|
1361
|
+
white-space: nowrap;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1215
1364
|
/* ================================================================
|
|
1216
1365
|
EXTENSIONS / ITEMS LIST
|
|
1217
1366
|
================================================================ */
|
|
@@ -1342,6 +1491,7 @@ input[type="range"]::-webkit-slider-thumb {
|
|
|
1342
1491
|
}
|
|
1343
1492
|
|
|
1344
1493
|
.badge-success { background: var(--success-subtle); color: var(--success); }
|
|
1494
|
+
.badge-prod-verified { background: #064e3b; color: #34d399; font-weight: 700; border: 1px solid #34d399; }
|
|
1345
1495
|
.badge-warning { background: var(--warning-subtle); color: var(--warning); }
|
|
1346
1496
|
.badge-danger { background: var(--danger-subtle); color: var(--danger); }
|
|
1347
1497
|
.badge-info { background: var(--info-subtle); color: var(--info); }
|
|
@@ -1742,17 +1892,35 @@ input[type="range"]::-webkit-slider-thumb {
|
|
|
1742
1892
|
}
|
|
1743
1893
|
|
|
1744
1894
|
@media (max-width: 900px) {
|
|
1745
|
-
.sidebar {
|
|
1746
|
-
.
|
|
1747
|
-
.
|
|
1748
|
-
.kanban
|
|
1895
|
+
.sidebar { position: fixed; top: 0; left: 0; bottom: 0; z-index: 99; transform: translateX(-100%); transition: transform 0.2s ease; }
|
|
1896
|
+
.sidebar.open { transform: translateX(0); }
|
|
1897
|
+
.main { margin-left: 0; padding: 20px 12px; overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
|
1898
|
+
.kanban { flex-direction: column; overflow-x: hidden; }
|
|
1899
|
+
.kanban-column { max-width: 100%; min-width: 100%; margin-bottom: 8px; }
|
|
1900
|
+
.kanban-column .kanban-items { max-height: none; }
|
|
1749
1901
|
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
|
1750
1902
|
.quick-info-row { flex-direction: column; gap: 12px; }
|
|
1751
1903
|
.form-row { flex-direction: column; }
|
|
1904
|
+
.kanban-scroll-bar { display: none; }
|
|
1905
|
+
/* Detail sidebar: full-width on tablets */
|
|
1906
|
+
.detail-sidebar { width: 100vw !important; max-width: 100vw; }
|
|
1907
|
+
.detail-resize-handle { display: none; }
|
|
1752
1908
|
}
|
|
1753
1909
|
|
|
1754
1910
|
@media (max-width: 600px) {
|
|
1755
1911
|
.stat-grid { grid-template-columns: 1fr; }
|
|
1912
|
+
.main { padding: 16px 8px; }
|
|
1913
|
+
.page-header h2 { font-size: 18px; }
|
|
1914
|
+
.page-header-actions { flex-wrap: wrap; gap: 6px; }
|
|
1915
|
+
.page-header-actions button, .page-header-actions select { font-size: 12px; padding: 5px 8px; }
|
|
1916
|
+
/* Kanban cards: more compact on small phones */
|
|
1917
|
+
.card { padding: 10px; }
|
|
1918
|
+
.card-title { font-size: 13px; }
|
|
1919
|
+
.kanban-column-header { padding: 8px 10px; font-size: 12px; }
|
|
1920
|
+
/* Detail sidebar: full-screen overlay on phones */
|
|
1921
|
+
.detail-sidebar { padding: 16px; }
|
|
1922
|
+
.detail-header h3 { font-size: 14px; }
|
|
1923
|
+
.update-body { font-size: 12px; }
|
|
1756
1924
|
}
|
|
1757
1925
|
|
|
1758
1926
|
/* ================================================================
|
|
@@ -1781,12 +1949,19 @@ input[type="range"]::-webkit-slider-thumb {
|
|
|
1781
1949
|
}
|
|
1782
1950
|
.detail-sidebar.open { transform: translateX(0); }
|
|
1783
1951
|
.detail-resize-handle {
|
|
1784
|
-
position: absolute; top: 0; left: -
|
|
1952
|
+
position: absolute; top: 0; left: -6px; bottom: 0; width: 12px;
|
|
1785
1953
|
cursor: col-resize; z-index: 102;
|
|
1786
1954
|
}
|
|
1955
|
+
.detail-resize-handle::after {
|
|
1956
|
+
content: ''; position: absolute; top: 50%; left: 4px; width: 4px; height: 32px;
|
|
1957
|
+
transform: translateY(-50%); border-radius: 2px;
|
|
1958
|
+
background: var(--border); transition: background 0.15s;
|
|
1959
|
+
}
|
|
1960
|
+
.detail-resize-handle:hover::after, .detail-resize-handle.active::after {
|
|
1961
|
+
background: var(--accent);
|
|
1962
|
+
}
|
|
1787
1963
|
.detail-resize-handle:hover, .detail-resize-handle.active {
|
|
1788
1964
|
background: var(--accent-subtle);
|
|
1789
|
-
border-left: 2px solid var(--accent);
|
|
1790
1965
|
}
|
|
1791
1966
|
.detail-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
|
|
1792
1967
|
.detail-header h3 { font-size: 16px; font-weight: 600; line-height: 1.4; flex: 1; margin-right: 12px; }
|
|
@@ -1806,12 +1981,23 @@ input[type="range"]::-webkit-slider-thumb {
|
|
|
1806
1981
|
.detail-updates h4 { font-size: 13px; font-weight: 600; margin-bottom: 12px; }
|
|
1807
1982
|
.update-item { padding: 12px; background: var(--bg-input); border-radius: var(--radius-sm); margin-bottom: 8px; }
|
|
1808
1983
|
.update-author { font-size: 11px; font-weight: 600; color: var(--accent); margin-bottom: 4px; }
|
|
1809
|
-
.update-body { font-size: 13px; line-height: 1.5; color: var(--text); word-break: break-word;
|
|
1984
|
+
.update-body { font-size: 13px; line-height: 1.5; color: var(--text); word-break: break-word; }
|
|
1985
|
+
.update-body.collapsed { max-height: 200px; overflow-y: hidden; position: relative; }
|
|
1986
|
+
.update-body.collapsed::after { content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 40px; background: linear-gradient(transparent, var(--bg-card)); pointer-events: none; }
|
|
1987
|
+
.update-toggle { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 12px; padding: 4px 0; font-weight: 500; }
|
|
1988
|
+
.update-toggle:hover { text-decoration: underline; }
|
|
1810
1989
|
.update-body h1, .update-body h2, .update-body h3, .update-body h4, .update-body h5, .update-body h6 { font-size: 14px; font-weight: 600; margin: 8px 0 4px; color: var(--text); }
|
|
1811
1990
|
.update-body p { margin: 4px 0; }
|
|
1812
1991
|
.update-body ul, .update-body ol { padding-left: 18px; margin: 4px 0; }
|
|
1813
1992
|
.update-body a { color: var(--accent); text-decoration: underline; }
|
|
1814
1993
|
.update-body img { max-width: 100%; border-radius: var(--radius-sm); margin: 4px 0; }
|
|
1994
|
+
.update-body pre { white-space: pre-wrap; word-break: break-word; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px; font-size: 12px; line-height: 1.6; max-height: 400px; overflow-y: auto; }
|
|
1995
|
+
.update-body table { width: 100%; border-collapse: collapse; font-size: 12px; margin: 8px 0; }
|
|
1996
|
+
.update-body th, .update-body td { border: 1px solid var(--border); padding: 6px 8px; text-align: left; }
|
|
1997
|
+
.update-body th { background: var(--bg-input); font-weight: 600; }
|
|
1998
|
+
.update-body details { margin: 4px 0; }
|
|
1999
|
+
.update-body details summary { cursor: pointer; font-weight: 500; padding: 4px 0; }
|
|
2000
|
+
.update-body details summary:hover { color: var(--accent); }
|
|
1815
2001
|
.update-time { font-size: 11px; color: var(--text-muted); margin-top: 4px; font-family: var(--font-mono); }
|
|
1816
2002
|
.loading-text { font-size: 13px; color: var(--text-muted); }
|
|
1817
2003
|
.empty-text { font-size: 13px; color: var(--text-muted); }
|
|
@@ -1858,11 +2044,16 @@ input[type="range"]::-webkit-slider-thumb {
|
|
|
1858
2044
|
|
|
1859
2045
|
<script>
|
|
1860
2046
|
// Global error handler to show errors instead of black screen
|
|
2047
|
+
function _escHtml(s) {
|
|
2048
|
+
var d = document.createElement('div');
|
|
2049
|
+
d.appendChild(document.createTextNode(s || ''));
|
|
2050
|
+
return d.innerHTML;
|
|
2051
|
+
}
|
|
1861
2052
|
window.onerror = function(msg, url, line, col, err) {
|
|
1862
|
-
document.getElementById('app').innerHTML = '<div style="padding:40px;font-family:monospace;"><div style="background:rgba(248,113,113,0.12);border:1px solid rgba(248,113,113,0.3);border-radius:12px;padding:20px;color:#f87171;"><strong>Error:</strong> ' + msg + '<br/>Line: ' + line + '<pre style="margin-top:12px;font-size:12px;opacity:0.7;">' + (err ? err.stack : '') + '</pre></div></div>';
|
|
2053
|
+
document.getElementById('app').innerHTML = '<div style="padding:40px;font-family:monospace;"><div style="background:rgba(248,113,113,0.12);border:1px solid rgba(248,113,113,0.3);border-radius:12px;padding:20px;color:#f87171;"><strong>Error:</strong> ' + _escHtml(String(msg)) + '<br/>Line: ' + _escHtml(String(line)) + '<pre style="margin-top:12px;font-size:12px;opacity:0.7;">' + _escHtml(err ? err.stack : '') + '</pre></div></div>';
|
|
1863
2054
|
};
|
|
1864
2055
|
window.addEventListener('unhandledrejection', function(e) {
|
|
1865
|
-
document.getElementById('app').innerHTML = '<div style="padding:40px;font-family:monospace;"><div style="background:rgba(248,113,113,0.12);border:1px solid rgba(248,113,113,0.3);border-radius:12px;padding:20px;color:#f87171;"><strong>Unhandled error:</strong> ' + (e.reason ? (e.reason.message || e.reason) : 'Unknown') + '<pre style="margin-top:12px;font-size:12px;opacity:0.7;">' + (e.reason && e.reason.stack ? e.reason.stack : '') + '</pre></div></div>';
|
|
2056
|
+
document.getElementById('app').innerHTML = '<div style="padding:40px;font-family:monospace;"><div style="background:rgba(248,113,113,0.12);border:1px solid rgba(248,113,113,0.3);border-radius:12px;padding:20px;color:#f87171;"><strong>Unhandled error:</strong> ' + _escHtml(e.reason ? (e.reason.message || String(e.reason)) : 'Unknown') + '<pre style="margin-top:12px;font-size:12px;opacity:0.7;">' + _escHtml(e.reason && e.reason.stack ? e.reason.stack : '') + '</pre></div></div>';
|
|
1866
2057
|
});
|
|
1867
2058
|
</script>
|
|
1868
2059
|
|
|
@@ -1971,6 +2162,7 @@ function Icon({ name, size = 18, className = '' }) {
|
|
|
1971
2162
|
chevron: html`<path d="M5 3l5 5-5 5"/>`,
|
|
1972
2163
|
lightbulb: html`<path d="M8 1a5 5 0 00-3 9c.5.6.8 1.2 1 1.8V13h4v-1.2c.2-.6.5-1.2 1-1.8A5 5 0 008 1z"/><line x1="6" y1="13.5" x2="10" y2="13.5"/><line x1="6.5" y1="15" x2="9.5" y2="15"/>`,
|
|
1973
2164
|
help: html`<circle cx="8" cy="8" r="6.5"/><path d="M6 6.5a2 2 0 013.7 1c0 1.5-1.7 1.5-1.7 3"/><circle cx="8" cy="12" r="0.5" fill="currentColor" stroke="none"/>`,
|
|
2165
|
+
filter: html`<polygon points="1.5,2 14.5,2 9.5,8.5 9.5,13 6.5,14.5 6.5,8.5"/>`,
|
|
1974
2166
|
};
|
|
1975
2167
|
|
|
1976
2168
|
return html`
|
|
@@ -2453,18 +2645,60 @@ function priorityBadgeClass(priority) {
|
|
|
2453
2645
|
|
|
2454
2646
|
// ── HTML Content Renderer (for Monday.com update bodies) ──
|
|
2455
2647
|
|
|
2648
|
+
function _sanitizeHtml(raw) {
|
|
2649
|
+
const tmp = document.createElement('div');
|
|
2650
|
+
tmp.innerHTML = raw || '';
|
|
2651
|
+
tmp.querySelectorAll('script,style,iframe,object,embed,form,input').forEach(el => el.remove());
|
|
2652
|
+
// Strip event-handler attributes (onerror, onclick, onload, etc.)
|
|
2653
|
+
tmp.querySelectorAll('*').forEach(el => {
|
|
2654
|
+
[...el.attributes].forEach(attr => {
|
|
2655
|
+
if (attr.name.startsWith('on') || attr.value.trim().toLowerCase().startsWith('javascript:')) el.removeAttribute(attr.name);
|
|
2656
|
+
});
|
|
2657
|
+
});
|
|
2658
|
+
return tmp.innerHTML;
|
|
2659
|
+
}
|
|
2456
2660
|
function HtmlContent({ html: content, className }) {
|
|
2457
2661
|
const ref = useRef(null);
|
|
2458
2662
|
useEffect(() => {
|
|
2459
2663
|
if (ref.current) {
|
|
2460
|
-
ref.current.innerHTML = content
|
|
2461
|
-
// Sanitize: remove dangerous elements
|
|
2462
|
-
ref.current.querySelectorAll('script,style,iframe,object,embed,form,input').forEach(el => el.remove());
|
|
2664
|
+
ref.current.innerHTML = _sanitizeHtml(content);
|
|
2463
2665
|
}
|
|
2464
2666
|
}, [content]);
|
|
2465
2667
|
return html`<div ref=${ref} class=${className || ''}></div>`;
|
|
2466
2668
|
}
|
|
2467
2669
|
|
|
2670
|
+
// ── Collapsible update body ──
|
|
2671
|
+
|
|
2672
|
+
function CollapsibleUpdate({ html: content }) {
|
|
2673
|
+
const ref = useRef(null);
|
|
2674
|
+
const [collapsed, setCollapsed] = useState(true);
|
|
2675
|
+
const [needsCollapse, setNeedsCollapse] = useState(false);
|
|
2676
|
+
|
|
2677
|
+
useEffect(() => {
|
|
2678
|
+
if (ref.current) {
|
|
2679
|
+
ref.current.innerHTML = _sanitizeHtml(content);
|
|
2680
|
+
// Check if content exceeds collapse threshold
|
|
2681
|
+
requestAnimationFrame(() => {
|
|
2682
|
+
if (ref.current && ref.current.scrollHeight > 220) {
|
|
2683
|
+
setNeedsCollapse(true);
|
|
2684
|
+
} else {
|
|
2685
|
+
setNeedsCollapse(false);
|
|
2686
|
+
setCollapsed(false);
|
|
2687
|
+
}
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
}, [content]);
|
|
2691
|
+
|
|
2692
|
+
return html`
|
|
2693
|
+
<div ref=${ref} class=${'update-body' + (needsCollapse && collapsed ? ' collapsed' : '')}></div>
|
|
2694
|
+
${needsCollapse ? html`
|
|
2695
|
+
<button class="update-toggle" onClick=${() => setCollapsed(!collapsed)}>
|
|
2696
|
+
${collapsed ? 'Show more ▾' : 'Show less ▴'}
|
|
2697
|
+
</button>
|
|
2698
|
+
` : null}
|
|
2699
|
+
`;
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2468
2702
|
// ── Update Item with threaded replies ──
|
|
2469
2703
|
|
|
2470
2704
|
function UpdateItemWithReplies({ update }) {
|
|
@@ -2503,7 +2737,7 @@ function UpdateItemWithReplies({ update }) {
|
|
|
2503
2737
|
<div class="update-author" style="font-size:11px;font-weight:600;color:var(--accent);margin-bottom:4px;">
|
|
2504
2738
|
${update.creator_name || (update.creator && update.creator.name) || 'system'}
|
|
2505
2739
|
</div>
|
|
2506
|
-
<${
|
|
2740
|
+
<${CollapsibleUpdate} html=${update.body || update.text_body || ''} />
|
|
2507
2741
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px;">
|
|
2508
2742
|
<div class="update-time" style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono);">
|
|
2509
2743
|
${timeAgo(update.created_at)}
|
|
@@ -2548,16 +2782,113 @@ function UpdateItemWithReplies({ update }) {
|
|
|
2548
2782
|
|
|
2549
2783
|
// ── Updates Header (sort toggle) ──
|
|
2550
2784
|
|
|
2551
|
-
function
|
|
2785
|
+
function CommentBox({ itemId, onPosted, compact = false }) {
|
|
2786
|
+
const [text, setText] = useState('');
|
|
2787
|
+
const [posting, setPosting] = useState(false);
|
|
2788
|
+
|
|
2789
|
+
const post = async () => {
|
|
2790
|
+
if (!text.trim() || posting) return;
|
|
2791
|
+
setPosting(true);
|
|
2792
|
+
try {
|
|
2793
|
+
await api('POST', '/api/board/items/' + itemId + '/updates', { body: text.trim(), creator_name: 'UI User' });
|
|
2794
|
+
setText('');
|
|
2795
|
+
if (onPosted) onPosted();
|
|
2796
|
+
} catch (e) { console.error('Comment failed:', e); }
|
|
2797
|
+
setPosting(false);
|
|
2798
|
+
};
|
|
2799
|
+
|
|
2800
|
+
const handleKeyDown = (e) => {
|
|
2801
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) post();
|
|
2802
|
+
};
|
|
2803
|
+
|
|
2552
2804
|
return html`
|
|
2553
|
-
<div style
|
|
2554
|
-
<
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2805
|
+
<div style=${'padding:' + (compact ? '8px 0' : '12px 0') + ';'}>
|
|
2806
|
+
<textarea value=${text} onInput=${(e) => setText(e.target.value)} onKeyDown=${handleKeyDown}
|
|
2807
|
+
style=${'width:100%;height:' + (compact ? '44px' : '60px') + ';padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:13px;font-family:var(--font-sans);resize:vertical;'}
|
|
2808
|
+
placeholder="Write a comment... (Ctrl+Enter to post)"></textarea>
|
|
2809
|
+
<div style="display:flex;justify-content:flex-end;margin-top:6px;">
|
|
2810
|
+
<button onClick=${post} disabled=${posting || !text.trim()}
|
|
2811
|
+
style="padding:4px 14px;border:none;border-radius:var(--radius-xs);background:var(--accent);color:#0f1219;font-size:12px;font-weight:600;cursor:pointer;opacity:${posting || !text.trim() ? '0.5' : '1'};">
|
|
2812
|
+
${posting ? 'Posting...' : 'Post'}
|
|
2559
2813
|
</button>
|
|
2560
|
-
|
|
2814
|
+
</div>
|
|
2815
|
+
</div>
|
|
2816
|
+
`;
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
function UpdatesHeader({ count, sortNewest, onToggle, itemId, onPosted }) {
|
|
2820
|
+
useEffect(() => {
|
|
2821
|
+
const c = document.getElementById('updates-header-wrap');
|
|
2822
|
+
if (!c || !itemId || c.querySelector('.top-comment-wrap')) return;
|
|
2823
|
+
const wrap = document.createElement('div');
|
|
2824
|
+
wrap.className = 'top-comment-wrap';
|
|
2825
|
+
wrap.style.cssText = 'margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid var(--border);';
|
|
2826
|
+
const ta = document.createElement('textarea');
|
|
2827
|
+
ta.style.cssText = 'width:100%;height:44px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:13px;font-family:var(--font-sans);resize:vertical;';
|
|
2828
|
+
ta.placeholder = 'Write a comment...';
|
|
2829
|
+
const row = document.createElement('div');
|
|
2830
|
+
row.style.cssText = 'display:flex;justify-content:flex-end;margin-top:6px;';
|
|
2831
|
+
const btn = document.createElement('button');
|
|
2832
|
+
btn.textContent = 'Post';
|
|
2833
|
+
btn.style.cssText = 'padding:4px 14px;border:none;border-radius:4px;background:var(--accent);color:#0f1219;font-size:12px;font-weight:600;cursor:pointer;';
|
|
2834
|
+
btn.onclick = async () => {
|
|
2835
|
+
const body = ta.value.trim();
|
|
2836
|
+
if (!body) return;
|
|
2837
|
+
btn.disabled = true; btn.textContent = 'Posting...';
|
|
2838
|
+
try {
|
|
2839
|
+
await api('POST', '/api/board/items/' + itemId + '/updates', { body, creator_name: 'UI User' });
|
|
2840
|
+
ta.value = '';
|
|
2841
|
+
if (onPosted) onPosted();
|
|
2842
|
+
} catch (e) { console.error(e); }
|
|
2843
|
+
btn.disabled = false; btn.textContent = 'Post';
|
|
2844
|
+
};
|
|
2845
|
+
row.appendChild(btn);
|
|
2846
|
+
wrap.appendChild(ta);
|
|
2847
|
+
wrap.appendChild(row);
|
|
2848
|
+
c.insertBefore(wrap, c.firstChild);
|
|
2849
|
+
}, [itemId]);
|
|
2850
|
+
return html`
|
|
2851
|
+
<div id="updates-header-wrap" style="margin-bottom:8px;">
|
|
2852
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid var(--border);padding-bottom:8px;">
|
|
2853
|
+
<h4 style="margin:0;font-size:13px;font-weight:600;">Updates${count > 0 ? ' (' + count + ')' : ''}</h4>
|
|
2854
|
+
${count > 1 ? html`
|
|
2855
|
+
<button onClick=${onToggle}
|
|
2856
|
+
style="font-size:11px;padding:2px 8px;border:1px solid var(--border);border-radius:6px;cursor:pointer;background:var(--bg-input);color:var(--text-secondary);font-family:var(--font-sans);">
|
|
2857
|
+
${sortNewest ? 'Newest first \u25BC' : 'Oldest first \u25B2'}
|
|
2858
|
+
</button>
|
|
2859
|
+
` : null}
|
|
2860
|
+
</div>
|
|
2861
|
+
</div>
|
|
2862
|
+
`;
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
// ── Top Comment Box for task detail ──
|
|
2866
|
+
|
|
2867
|
+
function TopCommentBox({ itemId, onPosted }) {
|
|
2868
|
+
const textRef = useRef(null);
|
|
2869
|
+
const [posting, setPosting] = useState(false);
|
|
2870
|
+
const post = async () => {
|
|
2871
|
+
const ta = textRef.current;
|
|
2872
|
+
const body = ta ? ta.value.trim() : '';
|
|
2873
|
+
if (!body || posting) return;
|
|
2874
|
+
setPosting(true);
|
|
2875
|
+
try {
|
|
2876
|
+
await api('POST', '/api/board/items/' + itemId + '/updates', { body: body, creator_name: 'UI User' });
|
|
2877
|
+
if (ta) ta.value = '';
|
|
2878
|
+
if (onPosted) onPosted();
|
|
2879
|
+
} catch (e) { console.error('Comment failed:', e); }
|
|
2880
|
+
setPosting(false);
|
|
2881
|
+
};
|
|
2882
|
+
return html`
|
|
2883
|
+
<div style="padding:8px 0;border-bottom:1px solid var(--border);margin-bottom:8px;">
|
|
2884
|
+
<textarea ref=${textRef}
|
|
2885
|
+
style="width:100%;height:44px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:13px;font-family:var(--font-sans);resize:vertical;"
|
|
2886
|
+
placeholder="Write a comment..."></textarea>
|
|
2887
|
+
<div style="display:flex;justify-content:flex-end;margin-top:6px;">
|
|
2888
|
+
<button onClick=${post} disabled=${posting}
|
|
2889
|
+
style="padding:4px 14px;border:none;border-radius:var(--radius-xs);background:var(--accent);color:#0f1219;font-size:12px;font-weight:600;cursor:pointer;">
|
|
2890
|
+
${posting ? 'Posting...' : 'Post'}</button>
|
|
2891
|
+
</div>
|
|
2561
2892
|
</div>
|
|
2562
2893
|
`;
|
|
2563
2894
|
}
|
|
@@ -2569,6 +2900,17 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
|
|
|
2569
2900
|
const [loadingUpdates, setLoadingUpdates] = useState(true);
|
|
2570
2901
|
const [sortNewest, setSortNewest] = useState(true);
|
|
2571
2902
|
const sidebarRef = useRef(null);
|
|
2903
|
+
const topCommentRef = useRef(null);
|
|
2904
|
+
|
|
2905
|
+
const refreshUpdates = useCallback(() => {
|
|
2906
|
+
if (!item) return;
|
|
2907
|
+
api('GET', '/api/board/items/' + item.id + '/updates')
|
|
2908
|
+
.then(data => {
|
|
2909
|
+
const list = Array.isArray(data) ? data : (data.updates || data.data || []);
|
|
2910
|
+
setUpdates(list);
|
|
2911
|
+
})
|
|
2912
|
+
.catch(() => setUpdates([]));
|
|
2913
|
+
}, [item && item.id]);
|
|
2572
2914
|
|
|
2573
2915
|
useEffect(() => {
|
|
2574
2916
|
if (!item) return;
|
|
@@ -2580,6 +2922,44 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
|
|
|
2580
2922
|
})
|
|
2581
2923
|
.catch(() => setUpdates([]))
|
|
2582
2924
|
.finally(() => setLoadingUpdates(false));
|
|
2925
|
+
const interval = setInterval(refreshUpdates, 10000);
|
|
2926
|
+
return () => clearInterval(interval);
|
|
2927
|
+
}, [item && item.id]);
|
|
2928
|
+
|
|
2929
|
+
useEffect(() => {
|
|
2930
|
+
const area = document.getElementById('top-comment-area');
|
|
2931
|
+
if (!area || !item) return;
|
|
2932
|
+
area.innerHTML = '';
|
|
2933
|
+
const wrapper = document.createElement('div');
|
|
2934
|
+
wrapper.style.cssText = 'padding-bottom:10px;margin-bottom:10px;border-bottom:1px solid var(--border);';
|
|
2935
|
+
const ta = document.createElement('textarea');
|
|
2936
|
+
ta.style.cssText = 'width:100%;height:44px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:13px;font-family:var(--font-sans);resize:vertical;';
|
|
2937
|
+
ta.placeholder = 'Write a comment...';
|
|
2938
|
+
const btnRow = document.createElement('div');
|
|
2939
|
+
btnRow.style.cssText = 'display:flex;justify-content:flex-end;margin-top:6px;';
|
|
2940
|
+
const btn = document.createElement('button');
|
|
2941
|
+
btn.textContent = 'Post';
|
|
2942
|
+
btn.style.cssText = 'padding:4px 14px;border:none;border-radius:4px;background:var(--accent);color:#0f1219;font-size:12px;font-weight:600;cursor:pointer;';
|
|
2943
|
+
btn.onclick = async () => {
|
|
2944
|
+
const body = ta.value.trim();
|
|
2945
|
+
if (!body) return;
|
|
2946
|
+
btn.disabled = true;
|
|
2947
|
+
btn.textContent = 'Posting...';
|
|
2948
|
+
try {
|
|
2949
|
+
await api('POST', '/api/board/items/' + item.id + '/updates', { body: body, creator_name: 'UI User' });
|
|
2950
|
+
ta.value = '';
|
|
2951
|
+
refreshUpdates();
|
|
2952
|
+
} catch (e) { console.error('Comment failed:', e); }
|
|
2953
|
+
btn.disabled = false;
|
|
2954
|
+
btn.textContent = 'Post';
|
|
2955
|
+
};
|
|
2956
|
+
const onKeyDown = (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) btn.click(); };
|
|
2957
|
+
ta.addEventListener('keydown', onKeyDown);
|
|
2958
|
+
btnRow.appendChild(btn);
|
|
2959
|
+
wrapper.appendChild(ta);
|
|
2960
|
+
wrapper.appendChild(btnRow);
|
|
2961
|
+
area.appendChild(wrapper);
|
|
2962
|
+
return () => { ta.removeEventListener('keydown', onKeyDown); };
|
|
2583
2963
|
}, [item && item.id]);
|
|
2584
2964
|
|
|
2585
2965
|
useEffect(() => {
|
|
@@ -2672,7 +3052,7 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
|
|
|
2672
3052
|
<div class="detail-value">${item.parent_epic ? '#' + item.parent_epic : '\u2014'}</div>
|
|
2673
3053
|
</div>
|
|
2674
3054
|
<div class="detail-updates">
|
|
2675
|
-
<${UpdatesHeader} count=${updates.length} sortNewest=${sortNewest} onToggle=${() => setSortNewest(!sortNewest)} />
|
|
3055
|
+
<${UpdatesHeader} count=${updates.length} sortNewest=${sortNewest} onToggle=${() => setSortNewest(!sortNewest)} itemId=${item.id} onPosted=${refreshUpdates} />
|
|
2676
3056
|
${loadingUpdates ? html`<div class="loading-text">Loading updates...</div>` :
|
|
2677
3057
|
sortedUpdates.length === 0 ? html`<div class="empty-text">No updates yet</div>` :
|
|
2678
3058
|
sortedUpdates.map((u, i) => html`
|
|
@@ -2680,39 +3060,8 @@ function ItemDetailSidebar({ item, onClose, onStatusChange }) {
|
|
|
2680
3060
|
`)
|
|
2681
3061
|
}
|
|
2682
3062
|
</div>
|
|
2683
|
-
<div style="border-top:1px solid var(--border);padding:
|
|
2684
|
-
|
|
2685
|
-
style="width:100%;height:60px;padding:10px;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:13px;font-family:var(--font-sans);resize:vertical;"
|
|
2686
|
-
placeholder="Write a comment..."></textarea>
|
|
2687
|
-
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px;">
|
|
2688
|
-
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--text-muted);">
|
|
2689
|
-
<input type="file" id="detail-reply-file" style="display:none;" />
|
|
2690
|
-
<span style="padding:4px 10px;border:1px solid var(--border);border-radius:var(--radius-xs);background:var(--bg-input);">Attach File</span>
|
|
2691
|
-
</label>
|
|
2692
|
-
<button style="padding:4px 14px;border:none;border-radius:var(--radius-xs);background:var(--accent);color:#0f1219;font-size:13px;font-weight:600;cursor:pointer;"
|
|
2693
|
-
onClick=${async () => {
|
|
2694
|
-
const ta = document.getElementById('detail-reply-input');
|
|
2695
|
-
const fileInput = document.getElementById('detail-reply-file');
|
|
2696
|
-
const body = ta ? ta.value.trim() : '';
|
|
2697
|
-
if (!body && !(fileInput && fileInput.files && fileInput.files.length)) return;
|
|
2698
|
-
try {
|
|
2699
|
-
if (body) {
|
|
2700
|
-
await api('POST', '/api/board/items/' + item.id + '/updates', { body, creator_name: 'UI User' });
|
|
2701
|
-
}
|
|
2702
|
-
if (fileInput && fileInput.files && fileInput.files.length) {
|
|
2703
|
-
const fd = new FormData();
|
|
2704
|
-
fd.append('file', fileInput.files[0]);
|
|
2705
|
-
await fetch('/api/board/items/' + item.id + '/attachments', { method: 'POST', body: fd });
|
|
2706
|
-
fileInput.value = '';
|
|
2707
|
-
}
|
|
2708
|
-
if (ta) ta.value = '';
|
|
2709
|
-
// Reload updates
|
|
2710
|
-
const data = await api('GET', '/api/board/items/' + item.id + '/updates');
|
|
2711
|
-
const list = Array.isArray(data) ? data : (data.updates || data.data || []);
|
|
2712
|
-
setUpdates(list);
|
|
2713
|
-
} catch (e) { console.error('Reply failed:', e); }
|
|
2714
|
-
}}>Post</button>
|
|
2715
|
-
</div>
|
|
3063
|
+
<div style="border-top:1px solid var(--border);padding:0 24px 16px;">
|
|
3064
|
+
<${CommentBox} itemId=${item.id} onPosted=${refreshUpdates} />
|
|
2716
3065
|
</div>
|
|
2717
3066
|
</aside>
|
|
2718
3067
|
`;
|
|
@@ -2845,6 +3194,10 @@ function BoardPage({ selectedProject }) {
|
|
|
2845
3194
|
const [dragId, setDragId] = useState(null);
|
|
2846
3195
|
const [selectedItem, setSelectedItem] = useState(null);
|
|
2847
3196
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
|
3197
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
3198
|
+
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
3199
|
+
const [filters, setFilters] = useState({ priority: '', taskType: '', project: '', dateRange: '', parentEpic: '' });
|
|
3200
|
+
const [columnSorts, setColumnSorts] = useState({});
|
|
2848
3201
|
|
|
2849
3202
|
const fetchItems = useCallback(() => {
|
|
2850
3203
|
setLoading(true);
|
|
@@ -2856,7 +3209,17 @@ function BoardPage({ selectedProject }) {
|
|
|
2856
3209
|
|
|
2857
3210
|
// Re-fetch whenever the selected project changes — each project
|
|
2858
3211
|
// has its own SQLite board on the server.
|
|
2859
|
-
|
|
3212
|
+
// Also poll every 10s for live updates from the daemon.
|
|
3213
|
+
useEffect(() => {
|
|
3214
|
+
fetchItems();
|
|
3215
|
+
const interval = setInterval(() => {
|
|
3216
|
+
// Silent refresh — don't show loading spinner on polls
|
|
3217
|
+
api('GET', '/api/board/items')
|
|
3218
|
+
.then(data => setItems(data.items || []))
|
|
3219
|
+
.catch(() => {});
|
|
3220
|
+
}, 10000);
|
|
3221
|
+
return () => clearInterval(interval);
|
|
3222
|
+
}, [selectedProject]);
|
|
2860
3223
|
|
|
2861
3224
|
// Sync scroll thumb position with kanban scroll + drag support
|
|
2862
3225
|
useEffect(() => {
|
|
@@ -2947,7 +3310,7 @@ function BoardPage({ selectedProject }) {
|
|
|
2947
3310
|
document.removeEventListener('mousemove', onKanbanMouseMove);
|
|
2948
3311
|
document.removeEventListener('mouseup', onKanbanMouseUp);
|
|
2949
3312
|
};
|
|
2950
|
-
});
|
|
3313
|
+
}, []);
|
|
2951
3314
|
|
|
2952
3315
|
const deleteItem = async (id) => {
|
|
2953
3316
|
try {
|
|
@@ -2988,6 +3351,69 @@ function BoardPage({ selectedProject }) {
|
|
|
2988
3351
|
} catch (err) { setError(err.message); }
|
|
2989
3352
|
};
|
|
2990
3353
|
|
|
3354
|
+
// ── Filtering logic ──
|
|
3355
|
+
const filteredItems = items.filter(item => {
|
|
3356
|
+
// Search: case-insensitive substring, minimum 2 chars
|
|
3357
|
+
if (searchTerm.length >= 2) {
|
|
3358
|
+
const term = searchTerm.toLowerCase();
|
|
3359
|
+
const name = (item.name || item.title || '').toLowerCase();
|
|
3360
|
+
if (!name.includes(term)) return false;
|
|
3361
|
+
}
|
|
3362
|
+
// Priority filter
|
|
3363
|
+
if (filters.priority && (item.priority || '').toLowerCase() !== filters.priority.toLowerCase()) return false;
|
|
3364
|
+
// Task type filter
|
|
3365
|
+
if (filters.taskType && (item.task_type || '') !== filters.taskType) return false;
|
|
3366
|
+
// Project filter (only when viewing all projects)
|
|
3367
|
+
if (filters.project && (item.project_id || '') !== filters.project) return false;
|
|
3368
|
+
// Parent epic filter
|
|
3369
|
+
if (filters.parentEpic && String(item.parent_epic || '') !== filters.parentEpic) return false;
|
|
3370
|
+
// Date range filter
|
|
3371
|
+
if (filters.dateRange) {
|
|
3372
|
+
const d = new Date(item.created_at || item.updated_at);
|
|
3373
|
+
const now = new Date();
|
|
3374
|
+
if (filters.dateRange === 'today') {
|
|
3375
|
+
if (d.toDateString() !== now.toDateString()) return false;
|
|
3376
|
+
} else if (filters.dateRange === 'week') {
|
|
3377
|
+
const weekAgo = new Date(now); weekAgo.setDate(weekAgo.getDate() - 7);
|
|
3378
|
+
if (d < weekAgo) return false;
|
|
3379
|
+
} else if (filters.dateRange === 'month') {
|
|
3380
|
+
const monthAgo = new Date(now); monthAgo.setMonth(monthAgo.getMonth() - 1);
|
|
3381
|
+
if (d < monthAgo) return false;
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
return true;
|
|
3385
|
+
});
|
|
3386
|
+
|
|
3387
|
+
// Active filter count
|
|
3388
|
+
const activeFilterCount = [filters.priority, filters.taskType, filters.project, filters.dateRange, filters.parentEpic].filter(Boolean).length;
|
|
3389
|
+
|
|
3390
|
+
// ── Column sorting ──
|
|
3391
|
+
const PRIORITY_ORDER = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3, '': 4 };
|
|
3392
|
+
const sortColumnItems = (colItems, mode) => {
|
|
3393
|
+
if (!mode) return colItems; // default: keep API order (newest first)
|
|
3394
|
+
const sorted = [...colItems];
|
|
3395
|
+
if (mode === 'priority') {
|
|
3396
|
+
sorted.sort((a, b) => (PRIORITY_ORDER[a.priority || ''] ?? 4) - (PRIORITY_ORDER[b.priority || ''] ?? 4));
|
|
3397
|
+
} else if (mode === 'name') {
|
|
3398
|
+
sorted.sort((a, b) => (a.name || a.title || '').localeCompare(b.name || b.title || ''));
|
|
3399
|
+
}
|
|
3400
|
+
return sorted;
|
|
3401
|
+
};
|
|
3402
|
+
|
|
3403
|
+
const cycleSort = (colId) => {
|
|
3404
|
+
setColumnSorts(prev => {
|
|
3405
|
+
const current = prev[colId] || '';
|
|
3406
|
+
const next = current === '' ? 'priority' : current === 'priority' ? 'name' : '';
|
|
3407
|
+
const copy = { ...prev };
|
|
3408
|
+
if (next) copy[colId] = next; else delete copy[colId];
|
|
3409
|
+
return copy;
|
|
3410
|
+
});
|
|
3411
|
+
};
|
|
3412
|
+
|
|
3413
|
+
// Unique project IDs and parent epics for filter dropdowns
|
|
3414
|
+
const uniqueProjects = [...new Set(items.map(i => i.project_id).filter(Boolean))];
|
|
3415
|
+
const uniqueEpics = [...new Set(items.map(i => i.parent_epic).filter(Boolean))].map(String);
|
|
3416
|
+
|
|
2991
3417
|
if (loading) return html`<${SkeletonLoader} type="cards" />`;
|
|
2992
3418
|
|
|
2993
3419
|
if (items.length === 0) {
|
|
@@ -3020,8 +3446,9 @@ function BoardPage({ selectedProject }) {
|
|
|
3020
3446
|
`;
|
|
3021
3447
|
}
|
|
3022
3448
|
|
|
3023
|
-
const totalItems =
|
|
3024
|
-
const activeItems =
|
|
3449
|
+
const totalItems = filteredItems.length;
|
|
3450
|
+
const activeItems = filteredItems.filter(i => i.status !== 'Done').length;
|
|
3451
|
+
const isFiltered = searchTerm.length >= 2 || activeFilterCount > 0;
|
|
3025
3452
|
|
|
3026
3453
|
return html`
|
|
3027
3454
|
<div class="page-content">
|
|
@@ -3032,19 +3459,86 @@ function BoardPage({ selectedProject }) {
|
|
|
3032
3459
|
|
|
3033
3460
|
${error && html`<div class="error-msg">${error}</div>`}
|
|
3034
3461
|
|
|
3035
|
-
<div class="board-
|
|
3036
|
-
<
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3462
|
+
<div class="board-header-row">
|
|
3463
|
+
<div class="board-stats-bar">
|
|
3464
|
+
<span class="stat-label">Board</span>
|
|
3465
|
+
<span class="stat-item"><strong>${totalItems}</strong>${isFiltered ? '/' + items.length : ''} total</span>
|
|
3466
|
+
<span class="stat-item"><strong>${activeItems}</strong> active</span>
|
|
3467
|
+
</div>
|
|
3468
|
+
|
|
3469
|
+
<div class="board-search-wrap">
|
|
3470
|
+
<input type="text" placeholder="Search tasks..." value=${searchTerm} onInput=${(e) => setSearchTerm(e.target.value)} />
|
|
3471
|
+
${searchTerm && html`<button class="board-search-clear" onClick=${() => setSearchTerm('')}>\u00d7</button>`}
|
|
3472
|
+
</div>
|
|
3473
|
+
|
|
3474
|
+
<button class=${'filter-toggle' + (filtersOpen || activeFilterCount > 0 ? ' active' : '')} onClick=${() => setFiltersOpen(v => !v)}>
|
|
3475
|
+
<${Icon} name="filter" size=${13} />
|
|
3476
|
+
Filters
|
|
3477
|
+
${activeFilterCount > 0 && html`<span class="filter-active-count">${activeFilterCount}</span>`}
|
|
3478
|
+
</button>
|
|
3040
3479
|
|
|
3041
|
-
<div style="margin-bottom:20px;">
|
|
3042
3480
|
<button class="btn btn-primary" onClick=${() => setShowCreateDialog(true)}>
|
|
3043
3481
|
<${Icon} name="plus" size=${14} />
|
|
3044
3482
|
New Task
|
|
3045
3483
|
</button>
|
|
3046
3484
|
</div>
|
|
3047
3485
|
|
|
3486
|
+
${filtersOpen && html`
|
|
3487
|
+
<div class="board-filter-panel">
|
|
3488
|
+
<div class="filter-group">
|
|
3489
|
+
<label>Priority</label>
|
|
3490
|
+
<select value=${filters.priority} onChange=${(e) => setFilters(f => ({...f, priority: e.target.value}))}>
|
|
3491
|
+
<option value="">All</option>
|
|
3492
|
+
<option value="Critical">Critical</option>
|
|
3493
|
+
<option value="High">High</option>
|
|
3494
|
+
<option value="Medium">Medium</option>
|
|
3495
|
+
<option value="Low">Low</option>
|
|
3496
|
+
</select>
|
|
3497
|
+
</div>
|
|
3498
|
+
<div class="filter-group">
|
|
3499
|
+
<label>Type</label>
|
|
3500
|
+
<select value=${filters.taskType} onChange=${(e) => setFilters(f => ({...f, taskType: e.target.value}))}>
|
|
3501
|
+
<option value="">All</option>
|
|
3502
|
+
<option value="code">code</option>
|
|
3503
|
+
<option value="epic">epic</option>
|
|
3504
|
+
<option value="deep-planning">deep-planning</option>
|
|
3505
|
+
<option value="infra-plan">infra-plan</option>
|
|
3506
|
+
<option value="infrastructure">infrastructure</option>
|
|
3507
|
+
</select>
|
|
3508
|
+
</div>
|
|
3509
|
+
<div class="filter-group">
|
|
3510
|
+
<label>Date</label>
|
|
3511
|
+
<select value=${filters.dateRange} onChange=${(e) => setFilters(f => ({...f, dateRange: e.target.value}))}>
|
|
3512
|
+
<option value="">Any time</option>
|
|
3513
|
+
<option value="today">Today</option>
|
|
3514
|
+
<option value="week">This week</option>
|
|
3515
|
+
<option value="month">This month</option>
|
|
3516
|
+
</select>
|
|
3517
|
+
</div>
|
|
3518
|
+
${selectedProject === 'all' && uniqueProjects.length > 0 && html`
|
|
3519
|
+
<div class="filter-group">
|
|
3520
|
+
<label>Project</label>
|
|
3521
|
+
<select value=${filters.project} onChange=${(e) => setFilters(f => ({...f, project: e.target.value}))}>
|
|
3522
|
+
<option value="">All</option>
|
|
3523
|
+
${uniqueProjects.map(p => html`<option key=${p} value=${p}>${p}</option>`)}
|
|
3524
|
+
</select>
|
|
3525
|
+
</div>
|
|
3526
|
+
`}
|
|
3527
|
+
${uniqueEpics.length > 0 && html`
|
|
3528
|
+
<div class="filter-group">
|
|
3529
|
+
<label>Epic</label>
|
|
3530
|
+
<select value=${filters.parentEpic} onChange=${(e) => setFilters(f => ({...f, parentEpic: e.target.value}))}>
|
|
3531
|
+
<option value="">All</option>
|
|
3532
|
+
${uniqueEpics.map(ep => html`<option key=${ep} value=${ep}>${ep}</option>`)}
|
|
3533
|
+
</select>
|
|
3534
|
+
</div>
|
|
3535
|
+
`}
|
|
3536
|
+
${activeFilterCount > 0 && html`
|
|
3537
|
+
<button class="filter-clear-link" onClick=${() => setFilters({ priority: '', taskType: '', project: '', dateRange: '', parentEpic: '' })}>Clear all</button>
|
|
3538
|
+
`}
|
|
3539
|
+
</div>
|
|
3540
|
+
`}
|
|
3541
|
+
|
|
3048
3542
|
<div class="kanban-wrapper">
|
|
3049
3543
|
<div class="kanban-scroll-bar">
|
|
3050
3544
|
<button class="kanban-scroll-btn" onClick=${() => {
|
|
@@ -3068,19 +3562,21 @@ function BoardPage({ selectedProject }) {
|
|
|
3068
3562
|
</div>
|
|
3069
3563
|
<div class="kanban">
|
|
3070
3564
|
${KANBAN_COLUMNS.map(col => {
|
|
3071
|
-
const
|
|
3565
|
+
const rawColItems = filteredItems.filter(i => {
|
|
3072
3566
|
if (col.id === 'New') return !i.status || i.status === 'New' || i.status === '';
|
|
3073
3567
|
if (i.status === col.id) return true;
|
|
3074
3568
|
if (col.also && col.also.includes(i.status)) return true;
|
|
3075
3569
|
return false;
|
|
3076
3570
|
});
|
|
3571
|
+
const colItems = sortColumnItems(rawColItems, columnSorts[col.id] || '');
|
|
3572
|
+
const sortMode = columnSorts[col.id] || '';
|
|
3077
3573
|
return html`
|
|
3078
3574
|
<div class="kanban-column" key=${col.id}
|
|
3079
3575
|
onDragOver=${onDragOver}
|
|
3080
3576
|
onDragLeave=${onDragLeave}
|
|
3081
3577
|
onDrop=${e => onDrop(e, col.id)}>
|
|
3082
|
-
<div class="kanban-column-header" style=${'--col-color: ' + col.color} title=${col.tooltip}>
|
|
3083
|
-
<span>${col.label}</span>
|
|
3578
|
+
<div class="kanban-column-header" style=${'--col-color: ' + col.color} title=${col.tooltip} onClick=${() => cycleSort(col.id)}>
|
|
3579
|
+
<span>${col.label}${sortMode && html`<span class="sort-indicator">${sortMode === 'priority' ? '\u25BC' : '\u25B2'}</span>`}</span>
|
|
3084
3580
|
<span class=${'kanban-count' + (colItems.length === 0 ? ' kanban-count-zero' : '')}>${colItems.length}</span>
|
|
3085
3581
|
</div>
|
|
3086
3582
|
<div class="kanban-items">
|
|
@@ -3101,6 +3597,7 @@ function BoardPage({ selectedProject }) {
|
|
|
3101
3597
|
${item.parent_epic && html`<span class="card-badge badge-epic">epic:${item.parent_epic}</span>`}
|
|
3102
3598
|
${item.priority && html`<span class=${'card-badge ' + priorityBadgeClass(item.priority)}>${item.priority}</span>`}
|
|
3103
3599
|
${item.task_type && html`<span class="card-badge badge-type">${item.task_type}</span>`}
|
|
3600
|
+
${selectedProject === 'all' && item.project_id && html`<span class="card-badge badge-project">${item.project_id}</span>`}
|
|
3104
3601
|
<span class="card-time">${timeAgo(item.updated_at || item.created_at)}</span>
|
|
3105
3602
|
</div>
|
|
3106
3603
|
</div>
|
|
@@ -3122,11 +3619,12 @@ function BoardPage({ selectedProject }) {
|
|
|
3122
3619
|
// Runs Page
|
|
3123
3620
|
// ================================================================
|
|
3124
3621
|
|
|
3125
|
-
function stageLabel(stage) {
|
|
3622
|
+
function stageLabel(stage, extra) {
|
|
3623
|
+
// If prod verified, show that regardless of checkpoint stage
|
|
3624
|
+
if (extra && extra.prodVerified) return { label: '\u2705 Prod Verified', cls: 'badge-prod-verified' };
|
|
3126
3625
|
if (!stage || typeof stage !== 'string') return { label: 'Unknown', cls: 'badge-muted' };
|
|
3127
3626
|
const s = stage.toLowerCase();
|
|
3128
|
-
if (s === 'done') return { label: '
|
|
3129
|
-
if (s === 'ship') return { label: 'Shipped', cls: 'badge-success' };
|
|
3627
|
+
if (s === 'done' || s === 'ship') return { label: 'Shipped', cls: 'badge-success' };
|
|
3130
3628
|
if (s.includes('build') || (s.includes('verify') && !s.includes('prod'))) return { label: 'Building', cls: 'badge-warning' };
|
|
3131
3629
|
if (s.includes('awaiting') || s.includes('waiting') || s.includes('approval')) return { label: 'Awaiting Approval', cls: 'badge-info' };
|
|
3132
3630
|
if (s.includes('spec') || s.includes('phase1')) return { label: 'Spec Phase', cls: 'badge-info' };
|
|
@@ -3241,7 +3739,21 @@ function RunsPage({ selectedProject }) {
|
|
|
3241
3739
|
.finally(() => setLoading(false));
|
|
3242
3740
|
};
|
|
3243
3741
|
|
|
3244
|
-
useEffect(() => {
|
|
3742
|
+
useEffect(() => {
|
|
3743
|
+
fetchRuns();
|
|
3744
|
+
const interval = setInterval(() => {
|
|
3745
|
+
api('GET', '/api/runs')
|
|
3746
|
+
.then(data => {
|
|
3747
|
+
const list = data.runs || [];
|
|
3748
|
+
setRuns(list);
|
|
3749
|
+
const pins = {};
|
|
3750
|
+
for (const r of list) pins[r.id] = !!r.pinned;
|
|
3751
|
+
setPinnedIds(pins);
|
|
3752
|
+
})
|
|
3753
|
+
.catch(() => {});
|
|
3754
|
+
}, 15000);
|
|
3755
|
+
return () => clearInterval(interval);
|
|
3756
|
+
}, []);
|
|
3245
3757
|
|
|
3246
3758
|
const togglePin = async (e, runId) => {
|
|
3247
3759
|
e.stopPropagation();
|
|
@@ -3313,7 +3825,7 @@ function RunsPage({ selectedProject }) {
|
|
|
3313
3825
|
const cp = run.checkpoint || {};
|
|
3314
3826
|
const hist = Array.isArray(cp.satisfaction_history) ? cp.satisfaction_history : [];
|
|
3315
3827
|
const satisfaction = hist.length > 0 ? hist[hist.length - 1] : null;
|
|
3316
|
-
const stage = stageLabel(cp.current_stage);
|
|
3828
|
+
const stage = stageLabel(cp.current_stage, { prodVerified: run.prodVerified });
|
|
3317
3829
|
const taskName = run.taskName || run.manifest?.task_description || run.manifest?.item_name || '';
|
|
3318
3830
|
const projectName = run.projectId || run.manifest?.project_id || '';
|
|
3319
3831
|
const shortId = run.id.replace('run-', '').slice(-6);
|
|
@@ -3321,7 +3833,7 @@ function RunsPage({ selectedProject }) {
|
|
|
3321
3833
|
const isPinned = !!pinnedIds[run.id];
|
|
3322
3834
|
|
|
3323
3835
|
return html`
|
|
3324
|
-
<tr key=${run.id} style
|
|
3836
|
+
<tr key=${run.id} style=${'cursor:pointer;' + (run.prodVerified ? 'border-left:3px solid #34d399;' : '')} onClick=${() => toggleDetail(run.id)}>
|
|
3325
3837
|
<td class="mono" title=${run.id} style="display:flex;align-items:center;gap:4px;">
|
|
3326
3838
|
<button
|
|
3327
3839
|
style=${{background:'none',border:'none',cursor:'pointer',padding:'0 2px',fontSize:'13px',lineHeight:'1',color:isPinned ? 'var(--accent)' : 'var(--text-muted)',opacity: isPinned ? 1 : 0.5,flexShrink:0}}
|
|
@@ -4290,11 +4802,42 @@ function SettingsPage() {
|
|
|
4290
4802
|
.finally(() => setLoading(false));
|
|
4291
4803
|
}, []);
|
|
4292
4804
|
|
|
4293
|
-
const saveConfig = async () => {
|
|
4805
|
+
const saveConfig = async (forceWrite = false) => {
|
|
4294
4806
|
try {
|
|
4295
4807
|
setSaving(true);
|
|
4296
4808
|
setError(null);
|
|
4297
|
-
const
|
|
4809
|
+
const body = forceWrite ? { ...config, force: true } : config;
|
|
4810
|
+
const result = await api('PATCH', '/api/config', body);
|
|
4811
|
+
|
|
4812
|
+
// Threshold safety check (feature #7): server may return a warning
|
|
4813
|
+
// payload instead of writing. Prompt the user and retry with force
|
|
4814
|
+
// if they accept.
|
|
4815
|
+
if (result && result.confirm_required && result.stranded_runs) {
|
|
4816
|
+
const lines = result.stranded_runs.map(r =>
|
|
4817
|
+
'• ' + r.run_id + ' (best: ' + (r.best_satisfaction * 100).toFixed(0) + '%)'
|
|
4818
|
+
).join('\n');
|
|
4819
|
+
const oldPct = ((result.old_threshold || 0) * 100).toFixed(0);
|
|
4820
|
+
const newPct = (result.attempted_threshold * 100).toFixed(0);
|
|
4821
|
+
const msg =
|
|
4822
|
+
result.warning + '\n\n' +
|
|
4823
|
+
'Stranded runs:\n' + lines + '\n\n' +
|
|
4824
|
+
'Old threshold: ' + oldPct + '%\n' +
|
|
4825
|
+
'New threshold: ' + newPct + '%\n\n' +
|
|
4826
|
+
'Proceed anyway? (Runs below the new threshold will never converge.)';
|
|
4827
|
+
if (confirm(msg)) {
|
|
4828
|
+
setSaving(false);
|
|
4829
|
+
return saveConfig(true);
|
|
4830
|
+
} else {
|
|
4831
|
+
// User cancelled — refetch config to revert the slider UI
|
|
4832
|
+
setSaving(false);
|
|
4833
|
+
try {
|
|
4834
|
+
const original = await api('GET', '/api/config');
|
|
4835
|
+
setConfig(original);
|
|
4836
|
+
} catch (_) { /* ignore */ }
|
|
4837
|
+
return;
|
|
4838
|
+
}
|
|
4839
|
+
}
|
|
4840
|
+
|
|
4298
4841
|
setConfig(result);
|
|
4299
4842
|
setSuccess('Configuration saved');
|
|
4300
4843
|
setTimeout(() => setSuccess(null), 3000);
|
|
@@ -4388,6 +4931,19 @@ function SettingsPage() {
|
|
|
4388
4931
|
<span class="mono">${((pipeline.satisfaction_threshold || 0.85) * 100).toFixed(0)}%</span>
|
|
4389
4932
|
</div>
|
|
4390
4933
|
</div>
|
|
4934
|
+
<div class="setting-row">
|
|
4935
|
+
<div>
|
|
4936
|
+
<div class="setting-label">Prod Verification Threshold</div>
|
|
4937
|
+
<div class="setting-desc">Min prod satisfaction to auto-accept (0.0 - 1.0)</div>
|
|
4938
|
+
</div>
|
|
4939
|
+
<div class="setting-control">
|
|
4940
|
+
<input type="range" min="0" max="100" step="5"
|
|
4941
|
+
value=${(pipeline.prod_accept_floor ?? 0.65) * 100}
|
|
4942
|
+
onInput=${e => updateField('pipeline.prod_accept_floor', parseInt(e.target.value) / 100)}
|
|
4943
|
+
style="width:120px" />
|
|
4944
|
+
<span class="mono">${((pipeline.prod_accept_floor ?? 0.65) * 100).toFixed(0)}%</span>
|
|
4945
|
+
</div>
|
|
4946
|
+
</div>
|
|
4391
4947
|
<div class="setting-row">
|
|
4392
4948
|
<div>
|
|
4393
4949
|
<div class="setting-label">Max Iterations</div>
|
|
@@ -5638,7 +6194,7 @@ function App() {
|
|
|
5638
6194
|
<button class="mobile-menu-btn" onClick=${() => setMobileMenuOpen(!mobileMenuOpen)} aria-label="Menu">
|
|
5639
6195
|
${mobileMenuOpen ? '\u2715' : '\u2630'}
|
|
5640
6196
|
</button>
|
|
5641
|
-
<div class=${'
|
|
6197
|
+
<div class=${'mobile-nav-overlay' + (mobileMenuOpen ? ' open' : '')} onClick=${() => setMobileMenuOpen(false)} />
|
|
5642
6198
|
<${Sidebar} currentHash=${hash} factoryName=${factoryName} theme=${theme} onToggleTheme=${toggleTheme}
|
|
5643
6199
|
selectedProject=${selectedProject} onProjectChange=${setSelectedProject}
|
|
5644
6200
|
mobileOpen=${mobileMenuOpen} />
|