agkan 2.4.0 → 2.6.0
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/board/server.d.ts +2 -1
- package/dist/board/server.d.ts.map +1 -1
- package/dist/board/server.js +514 -22
- package/dist/board/server.js.map +1 -1
- package/dist/cli/commands/comment/add.d.ts +6 -0
- package/dist/cli/commands/comment/add.d.ts.map +1 -0
- package/dist/cli/commands/comment/add.js +73 -0
- package/dist/cli/commands/comment/add.js.map +1 -0
- package/dist/cli/commands/comment/delete.d.ts +6 -0
- package/dist/cli/commands/comment/delete.d.ts.map +1 -0
- package/dist/cli/commands/comment/delete.js +57 -0
- package/dist/cli/commands/comment/delete.js.map +1 -0
- package/dist/cli/commands/comment/list.d.ts +6 -0
- package/dist/cli/commands/comment/list.d.ts.map +1 -0
- package/dist/cli/commands/comment/list.js +72 -0
- package/dist/cli/commands/comment/list.js.map +1 -0
- package/dist/cli/commands/task/find.d.ts.map +1 -1
- package/dist/cli/commands/task/find.js +31 -15
- package/dist/cli/commands/task/find.js.map +1 -1
- package/dist/cli/commands/task/get.d.ts.map +1 -1
- package/dist/cli/commands/task/get.js +23 -0
- package/dist/cli/commands/task/get.js.map +1 -1
- package/dist/cli/commands/task/list.d.ts.map +1 -1
- package/dist/cli/commands/task/list.js +27 -12
- package/dist/cli/commands/task/list.js.map +1 -1
- package/dist/cli/index.js +8 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +19 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/models/TaskComment.d.ts +21 -0
- package/dist/models/TaskComment.d.ts.map +1 -0
- package/dist/models/TaskComment.js +3 -0
- package/dist/models/TaskComment.js.map +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/services/CommentService.d.ts +48 -0
- package/dist/services/CommentService.d.ts.map +1 -0
- package/dist/services/CommentService.js +120 -0
- package/dist/services/CommentService.js.map +1 -0
- package/dist/services/TaskService.d.ts +2 -1
- package/dist/services/TaskService.d.ts.map +1 -1
- package/dist/services/TaskService.js +10 -2
- package/dist/services/TaskService.js.map +1 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +3 -1
- package/dist/services/index.js.map +1 -1
- package/dist/utils/input-validators.d.ts +7 -0
- package/dist/utils/input-validators.d.ts.map +1 -1
- package/dist/utils/input-validators.js +30 -0
- package/dist/utils/input-validators.js.map +1 -1
- package/package.json +1 -1
package/dist/board/server.js
CHANGED
|
@@ -6,6 +6,7 @@ const hono_1 = require("hono");
|
|
|
6
6
|
const node_server_1 = require("@hono/node-server");
|
|
7
7
|
const TaskService_1 = require("../services/TaskService");
|
|
8
8
|
const TaskTagService_1 = require("../services/TaskTagService");
|
|
9
|
+
const TagService_1 = require("../services/TagService");
|
|
9
10
|
const MetadataService_1 = require("../services/MetadataService");
|
|
10
11
|
const models_1 = require("../models");
|
|
11
12
|
const connection_1 = require("../db/connection");
|
|
@@ -58,7 +59,7 @@ const BOARD_STYLES = `
|
|
|
58
59
|
header { background: #1e293b; color: white; padding: 12px 20px; display: flex; align-items: center; justify-content: space-between; }
|
|
59
60
|
header h1 { font-size: 18px; font-weight: 700; }
|
|
60
61
|
.board-title { font-size: 14px; font-weight: 400; opacity: 0.75; }
|
|
61
|
-
.board-container { display: flex; width: 100%; height: calc(100vh -
|
|
62
|
+
.board-container { display: flex; width: 100%; height: calc(100vh - 92px); gap: 0; }
|
|
62
63
|
.board { display: flex; gap: 12px; padding: 16px; overflow-x: auto; flex: 1; align-items: flex-start; min-width: 0; }
|
|
63
64
|
.board.with-panel { padding-right: 0; }
|
|
64
65
|
.column { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; width: 240px; flex-shrink: 0; display: flex; flex-direction: column; border-top: 3px solid transparent; }
|
|
@@ -103,7 +104,7 @@ const BOARD_STYLES = `
|
|
|
103
104
|
.modal-actions button.primary:hover { background: #2563eb; }
|
|
104
105
|
.toast { position: fixed; bottom: 20px; right: 20px; background: #ef4444; color: white; padding: 10px 16px; border-radius: 6px; font-size: 13px; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
|
|
105
106
|
.toast.show { opacity: 1; }
|
|
106
|
-
.detail-panel { position: relative; width: 0; height: calc(100vh -
|
|
107
|
+
.detail-panel { position: relative; width: 0; height: calc(100vh - 92px); background: white; box-shadow: none; border-left: 0 solid #e2e8f0; display: flex; flex-direction: column; max-width: 800px; overflow: hidden; transition: width 0.25s ease; }
|
|
107
108
|
.detail-panel-resize-handle { position: absolute; top: 0; left: 0; width: 6px; height: 100%; cursor: col-resize; z-index: 10; background: transparent; }
|
|
108
109
|
.detail-panel-resize-handle:hover, .detail-panel-resize-handle.dragging { background: rgba(59,130,246,0.3); }
|
|
109
110
|
.detail-panel.open { width: 400px; min-width: 280px; border-left-width: 1px; }
|
|
@@ -111,8 +112,9 @@ const BOARD_STYLES = `
|
|
|
111
112
|
.detail-panel-header h2 { font-size: 16px; font-weight: 700; color: #1e293b; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
112
113
|
.detail-panel-close { background: none; border: none; font-size: 20px; color: #64748b; cursor: pointer; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 4px; flex-shrink: 0; }
|
|
113
114
|
.detail-panel-close:hover { background: #f1f5f9; color: #1e293b; }
|
|
114
|
-
.detail-panel-body { flex: 1; overflow-y: auto; padding: 20px; min-width: 0; }
|
|
115
|
+
.detail-panel-body { flex: 1; overflow-y: auto; padding: 20px; min-width: 0; display: flex; flex-direction: column; }
|
|
115
116
|
.detail-field { margin-bottom: 16px; word-wrap: break-word; }
|
|
117
|
+
.description-field-wrapper { flex: 1; display: flex; flex-direction: column; min-height: 0; margin-bottom: 0; }
|
|
116
118
|
.detail-field-label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: #94a3b8; margin-bottom: 4px; letter-spacing: 0.05em; }
|
|
117
119
|
.detail-field-value { font-size: 13px; color: #1e293b; line-height: 1.5; }
|
|
118
120
|
.detail-field-value.empty { color: #94a3b8; font-style: italic; }
|
|
@@ -129,8 +131,47 @@ const BOARD_STYLES = `
|
|
|
129
131
|
.detail-edit-input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
130
132
|
.detail-edit-textarea { width: 100%; border: 1px solid #e2e8f0; border-radius: 6px; padding: 7px 10px; font-size: 13px; font-family: inherit; resize: vertical; min-height: 240px; background: white; color: #1e293b; }
|
|
131
133
|
.detail-edit-textarea:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
134
|
+
.description-field-wrapper .detail-edit-textarea { flex: 1; resize: none; min-height: 0; }
|
|
132
135
|
.detail-edit-select { width: 100%; border: 1px solid #e2e8f0; border-radius: 6px; padding: 7px 10px; font-size: 13px; font-family: inherit; background: white; color: #1e293b; }
|
|
133
|
-
.detail-edit-select:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
136
|
+
.detail-edit-select:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
137
|
+
.tag-select-wrapper { position: relative; }
|
|
138
|
+
.tag-select-control { border: 1px solid #e2e8f0; border-radius: 6px; padding: 4px 8px; min-height: 36px; cursor: text; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; background: white; }
|
|
139
|
+
.tag-select-control:focus-within { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
140
|
+
.tag-pill { background: #e0f2fe; color: #0369a1; font-size: 11px; font-weight: 600; padding: 2px 4px 2px 8px; border-radius: 10px; display: inline-flex; align-items: center; gap: 3px; }
|
|
141
|
+
.tag-pill-remove { background: none; border: none; color: #0369a1; cursor: pointer; font-size: 13px; line-height: 1; padding: 0 2px; display: inline-flex; align-items: center; border-radius: 50%; }
|
|
142
|
+
.tag-pill-remove:hover { color: #dc2626; background: rgba(220,38,38,0.1); }
|
|
143
|
+
.tag-select-input { border: none; outline: none; font-size: 12px; font-family: inherit; min-width: 80px; flex: 1; background: transparent; padding: 2px 0; color: #1e293b; }
|
|
144
|
+
.tag-select-input::placeholder { color: #94a3b8; }
|
|
145
|
+
.tag-select-dropdown { position: absolute; top: calc(100% + 2px); left: 0; right: 0; background: white; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 100; max-height: 180px; overflow-y: auto; display: none; }
|
|
146
|
+
.tag-select-dropdown.open { display: block; }
|
|
147
|
+
.tag-select-option { padding: 6px 10px; font-size: 12px; cursor: pointer; color: #1e293b; }
|
|
148
|
+
.tag-select-option:hover, .tag-select-option.focused { background: #eff6ff; color: #0369a1; }
|
|
149
|
+
.tag-select-no-options { padding: 6px 10px; font-size: 12px; color: #94a3b8; font-style: italic; }
|
|
150
|
+
.filter-bar { display: flex; align-items: center; gap: 16px; height: 44px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; padding: 0 16px; flex-shrink: 0; overflow-x: auto; }
|
|
151
|
+
.filter-group { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
|
152
|
+
.filter-label { font-size: 10px; font-weight: 700; text-transform: uppercase; color: #94a3b8; letter-spacing: 0.05em; white-space: nowrap; }
|
|
153
|
+
.filter-priority-btn { border: 1px solid #e2e8f0; background: white; border-radius: 4px; padding: 2px 8px; font-size: 11px; font-weight: 600; cursor: pointer; text-transform: uppercase; color: #64748b; }
|
|
154
|
+
.filter-priority-btn:hover { background: #f1f5f9; }
|
|
155
|
+
.filter-priority-btn.active[data-priority="critical"] { background: #fee2e2; color: #dc2626; border-color: #fca5a5; }
|
|
156
|
+
.filter-priority-btn.active[data-priority="high"] { background: #fee2e2; color: #dc2626; border-color: #fca5a5; }
|
|
157
|
+
.filter-priority-btn.active[data-priority="medium"] { background: #fef9c3; color: #ca8a04; border-color: #fde047; }
|
|
158
|
+
.filter-priority-btn.active[data-priority="low"] { background: #dcfce7; color: #16a34a; border-color: #86efac; }
|
|
159
|
+
.filter-assignee-input { border: 1px solid #e2e8f0; border-radius: 4px; padding: 3px 8px; font-size: 12px; font-family: inherit; background: white; color: #1e293b; width: 120px; }
|
|
160
|
+
.filter-assignee-input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
161
|
+
.filter-tag-pill { background: #e0f2fe; color: #0369a1; font-size: 11px; font-weight: 600; padding: 2px 4px 2px 8px; border-radius: 10px; display: inline-flex; align-items: center; gap: 3px; flex-shrink: 0; }
|
|
162
|
+
.filter-tag-pill-remove { background: none; border: none; color: #0369a1; cursor: pointer; font-size: 13px; line-height: 1; padding: 0 2px; display: inline-flex; align-items: center; border-radius: 50%; }
|
|
163
|
+
.filter-tag-pill-remove:hover { color: #dc2626; background: rgba(220,38,38,0.1); }
|
|
164
|
+
.filter-tag-dropdown-wrapper { flex-shrink: 0; }
|
|
165
|
+
.filter-tag-add-btn { border: 1px dashed #cbd5e1; background: white; border-radius: 4px; padding: 2px 8px; font-size: 11px; color: #64748b; cursor: pointer; white-space: nowrap; }
|
|
166
|
+
.filter-tag-add-btn:hover { background: #f1f5f9; border-color: #94a3b8; }
|
|
167
|
+
.filter-tag-dropdown { position: fixed; background: white; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 200; max-height: 180px; overflow-y: auto; display: none; min-width: 140px; }
|
|
168
|
+
.filter-tag-dropdown.open { display: block; }
|
|
169
|
+
.filter-tag-dropdown-option { padding: 6px 10px; font-size: 12px; cursor: pointer; color: #1e293b; white-space: nowrap; }
|
|
170
|
+
.filter-tag-dropdown-option:hover { background: #eff6ff; color: #0369a1; }
|
|
171
|
+
.filter-tag-dropdown-empty { padding: 6px 10px; font-size: 12px; color: #94a3b8; font-style: italic; }
|
|
172
|
+
.filter-clear-btn { border: 1px solid #e2e8f0; background: white; border-radius: 4px; padding: 2px 10px; font-size: 11px; font-weight: 600; cursor: pointer; color: #64748b; display: none; flex-shrink: 0; margin-left: auto; }
|
|
173
|
+
.filter-clear-btn:hover { background: #fee2e2; border-color: #fca5a5; color: #dc2626; }
|
|
174
|
+
.filter-clear-btn.visible { display: block; }`;
|
|
134
175
|
const BOARD_SCRIPT = `
|
|
135
176
|
let draggedCard = null;
|
|
136
177
|
let sourceBody = null;
|
|
@@ -364,6 +405,183 @@ const BOARD_SCRIPT = `
|
|
|
364
405
|
const statusLabels = ${JSON.stringify(STATUS_LABELS)};
|
|
365
406
|
const allPriorities = ${JSON.stringify(models_1.PRIORITIES)};
|
|
366
407
|
|
|
408
|
+
let allAvailableTags = [];
|
|
409
|
+
|
|
410
|
+
async function loadAllTags() {
|
|
411
|
+
try {
|
|
412
|
+
const res = await fetch('/api/tags');
|
|
413
|
+
if (!res.ok) return;
|
|
414
|
+
const data = await res.json();
|
|
415
|
+
allAvailableTags = data.tags || [];
|
|
416
|
+
} catch {
|
|
417
|
+
// Ignore errors loading tags
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function renderTagsSection(currentTags) {
|
|
422
|
+
const container = document.getElementById('detail-tags-container');
|
|
423
|
+
if (!container) return;
|
|
424
|
+
|
|
425
|
+
container.innerHTML = '<div class="tag-select-wrapper"><div class="tag-select-control" id="tag-select-control"></div><div class="tag-select-dropdown" id="tag-select-dropdown"></div></div>';
|
|
426
|
+
|
|
427
|
+
const control = document.getElementById('tag-select-control');
|
|
428
|
+
const dropdown = document.getElementById('tag-select-dropdown');
|
|
429
|
+
let focusedOptionIndex = -1;
|
|
430
|
+
let inputValue = '';
|
|
431
|
+
|
|
432
|
+
function getFilteredTags() {
|
|
433
|
+
const currentTagIds = new Set(currentTags.map(t => t.id));
|
|
434
|
+
const available = allAvailableTags.filter(t => !currentTagIds.has(t.id));
|
|
435
|
+
if (!inputValue.trim()) return available;
|
|
436
|
+
const q = inputValue.toLowerCase();
|
|
437
|
+
return available.filter(t => t.name.toLowerCase().includes(q));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const input = document.createElement('input');
|
|
441
|
+
input.className = 'tag-select-input';
|
|
442
|
+
input.type = 'text';
|
|
443
|
+
input.autocomplete = 'off';
|
|
444
|
+
control.appendChild(input);
|
|
445
|
+
|
|
446
|
+
function renderPills() {
|
|
447
|
+
control.querySelectorAll('.tag-pill').forEach(p => p.remove());
|
|
448
|
+
currentTags.forEach(t => {
|
|
449
|
+
const pill = document.createElement('span');
|
|
450
|
+
pill.className = 'tag-pill';
|
|
451
|
+
pill.dataset.tagId = t.id;
|
|
452
|
+
const label = document.createTextNode(t.name);
|
|
453
|
+
const removeBtn = document.createElement('button');
|
|
454
|
+
removeBtn.className = 'tag-pill-remove';
|
|
455
|
+
removeBtn.title = 'Remove tag';
|
|
456
|
+
removeBtn.setAttribute('data-tag-id', t.id);
|
|
457
|
+
removeBtn.innerHTML = '×';
|
|
458
|
+
removeBtn.addEventListener('click', async e => {
|
|
459
|
+
e.stopPropagation();
|
|
460
|
+
try {
|
|
461
|
+
const res = await fetch('/api/tasks/' + detailTaskId + '/tags/' + t.id, { method: 'DELETE' });
|
|
462
|
+
if (!res.ok) throw new Error('Server error');
|
|
463
|
+
const idx = currentTags.findIndex(x => String(x.id) === String(t.id));
|
|
464
|
+
if (idx !== -1) currentTags.splice(idx, 1);
|
|
465
|
+
renderPills();
|
|
466
|
+
renderDropdown();
|
|
467
|
+
} catch {
|
|
468
|
+
showToast('Failed to remove tag');
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
pill.appendChild(label);
|
|
472
|
+
pill.appendChild(removeBtn);
|
|
473
|
+
control.insertBefore(pill, input);
|
|
474
|
+
});
|
|
475
|
+
input.placeholder = currentTags.length === 0 ? 'Add tags...' : '';
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function renderDropdown() {
|
|
479
|
+
const filtered = getFilteredTags();
|
|
480
|
+
dropdown.innerHTML = '';
|
|
481
|
+
focusedOptionIndex = -1;
|
|
482
|
+
if (filtered.length === 0) {
|
|
483
|
+
const noOpt = document.createElement('div');
|
|
484
|
+
noOpt.className = 'tag-select-no-options';
|
|
485
|
+
noOpt.textContent = inputValue ? 'No matching tags' : 'No tags available';
|
|
486
|
+
dropdown.appendChild(noOpt);
|
|
487
|
+
} else {
|
|
488
|
+
filtered.forEach((t, i) => {
|
|
489
|
+
const opt = document.createElement('div');
|
|
490
|
+
opt.className = 'tag-select-option';
|
|
491
|
+
opt.dataset.tagId = t.id;
|
|
492
|
+
opt.textContent = t.name;
|
|
493
|
+
opt.addEventListener('mouseover', () => setFocusedOption(i));
|
|
494
|
+
opt.addEventListener('mousedown', async e => {
|
|
495
|
+
e.preventDefault();
|
|
496
|
+
await addTag(t.id);
|
|
497
|
+
});
|
|
498
|
+
dropdown.appendChild(opt);
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function setFocusedOption(index) {
|
|
504
|
+
const opts = dropdown.querySelectorAll('.tag-select-option');
|
|
505
|
+
opts.forEach((o, i) => o.classList.toggle('focused', i === index));
|
|
506
|
+
focusedOptionIndex = index;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function openDropdown() {
|
|
510
|
+
renderDropdown();
|
|
511
|
+
dropdown.classList.add('open');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function closeDropdown() {
|
|
515
|
+
dropdown.classList.remove('open');
|
|
516
|
+
focusedOptionIndex = -1;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function addTag(tagId) {
|
|
520
|
+
try {
|
|
521
|
+
const res = await fetch('/api/tasks/' + detailTaskId + '/tags', {
|
|
522
|
+
method: 'POST',
|
|
523
|
+
headers: { 'Content-Type': 'application/json' },
|
|
524
|
+
body: JSON.stringify({ tagId: Number(tagId) })
|
|
525
|
+
});
|
|
526
|
+
if (!res.ok) throw new Error('Server error');
|
|
527
|
+
const tag = allAvailableTags.find(t => String(t.id) === String(tagId));
|
|
528
|
+
if (tag) currentTags.push(tag);
|
|
529
|
+
input.value = '';
|
|
530
|
+
inputValue = '';
|
|
531
|
+
renderPills();
|
|
532
|
+
renderDropdown();
|
|
533
|
+
} catch {
|
|
534
|
+
showToast('Failed to add tag');
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
control.addEventListener('click', () => input.focus());
|
|
539
|
+
|
|
540
|
+
input.addEventListener('focus', () => openDropdown());
|
|
541
|
+
|
|
542
|
+
input.addEventListener('blur', () => setTimeout(() => closeDropdown(), 150));
|
|
543
|
+
|
|
544
|
+
input.addEventListener('input', () => {
|
|
545
|
+
inputValue = input.value;
|
|
546
|
+
renderDropdown();
|
|
547
|
+
if (!dropdown.classList.contains('open')) openDropdown();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
input.addEventListener('keydown', async e => {
|
|
551
|
+
const filtered = getFilteredTags();
|
|
552
|
+
const opts = dropdown.querySelectorAll('.tag-select-option');
|
|
553
|
+
if (e.key === 'ArrowDown') {
|
|
554
|
+
e.preventDefault();
|
|
555
|
+
setFocusedOption(Math.min(focusedOptionIndex + 1, opts.length - 1));
|
|
556
|
+
} else if (e.key === 'ArrowUp') {
|
|
557
|
+
e.preventDefault();
|
|
558
|
+
setFocusedOption(Math.max(focusedOptionIndex - 1, 0));
|
|
559
|
+
} else if (e.key === 'Enter') {
|
|
560
|
+
e.preventDefault();
|
|
561
|
+
if (focusedOptionIndex >= 0 && filtered[focusedOptionIndex]) {
|
|
562
|
+
await addTag(filtered[focusedOptionIndex].id);
|
|
563
|
+
}
|
|
564
|
+
} else if (e.key === 'Escape') {
|
|
565
|
+
closeDropdown();
|
|
566
|
+
input.blur();
|
|
567
|
+
} else if (e.key === 'Backspace' && input.value === '' && currentTags.length > 0) {
|
|
568
|
+
e.preventDefault();
|
|
569
|
+
const last = currentTags[currentTags.length - 1];
|
|
570
|
+
try {
|
|
571
|
+
const res = await fetch('/api/tasks/' + detailTaskId + '/tags/' + last.id, { method: 'DELETE' });
|
|
572
|
+
if (!res.ok) throw new Error('Server error');
|
|
573
|
+
currentTags.splice(currentTags.length - 1, 1);
|
|
574
|
+
renderPills();
|
|
575
|
+
renderDropdown();
|
|
576
|
+
} catch {
|
|
577
|
+
showToast('Failed to remove tag');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
renderPills();
|
|
583
|
+
}
|
|
584
|
+
|
|
367
585
|
function renderDetailPanel(data) {
|
|
368
586
|
const task = data.task;
|
|
369
587
|
const tags = data.tags || [];
|
|
@@ -403,21 +621,12 @@ const BOARD_SCRIPT = `
|
|
|
403
621
|
html += '</select>';
|
|
404
622
|
html += '</div>';
|
|
405
623
|
|
|
406
|
-
//
|
|
624
|
+
// Tags (editable)
|
|
407
625
|
html += '<div class="detail-field">';
|
|
408
|
-
html += '<div class="detail-field-label">
|
|
409
|
-
html += '<
|
|
626
|
+
html += '<div class="detail-field-label">Tags</div>';
|
|
627
|
+
html += '<div id="detail-tags-container"></div>';
|
|
410
628
|
html += '</div>';
|
|
411
629
|
|
|
412
|
-
// Tags (read-only)
|
|
413
|
-
if (tags.length > 0) {
|
|
414
|
-
html += '<div class="detail-field">';
|
|
415
|
-
html += '<div class="detail-field-label">Tags</div>';
|
|
416
|
-
html += '<div class="detail-field-value detail-tags">';
|
|
417
|
-
tags.forEach(t => { html += '<span class="tag">' + escapeHtmlClient(t.name) + '</span>'; });
|
|
418
|
-
html += '</div></div>';
|
|
419
|
-
}
|
|
420
|
-
|
|
421
630
|
// Metadata table (read-only, non-priority)
|
|
422
631
|
const otherMeta = metadata.filter(m => m.key !== 'priority');
|
|
423
632
|
if (otherMeta.length > 0) {
|
|
@@ -440,7 +649,16 @@ const BOARD_SCRIPT = `
|
|
|
440
649
|
html += '<div class="detail-field-value">' + escapeHtmlClient(task.updated_at) + '</div>';
|
|
441
650
|
html += '</div>';
|
|
442
651
|
|
|
652
|
+
// Body (editable)
|
|
653
|
+
html += '<div class="detail-field description-field-wrapper">';
|
|
654
|
+
html += '<div class="detail-field-label">Description</div>';
|
|
655
|
+
html += '<textarea id="detail-edit-body" class="detail-edit-textarea">' + escapeHtmlClient(task.body || '') + '</textarea>';
|
|
656
|
+
html += '</div>';
|
|
657
|
+
|
|
443
658
|
detailPanelBody.innerHTML = html;
|
|
659
|
+
|
|
660
|
+
// Render tags section after DOM update
|
|
661
|
+
loadAllTags().then(() => renderTagsSection([...tags]));
|
|
444
662
|
}
|
|
445
663
|
|
|
446
664
|
function escapeHtmlClient(str) {
|
|
@@ -497,11 +715,30 @@ const BOARD_SCRIPT = `
|
|
|
497
715
|
});
|
|
498
716
|
});
|
|
499
717
|
|
|
718
|
+
// Filter state (defined before refreshBoardCards so it can use them)
|
|
719
|
+
let activeFilters = { tagIds: [], priorities: [], assignee: '' };
|
|
720
|
+
|
|
721
|
+
function buildFilterParams() {
|
|
722
|
+
const params = new URLSearchParams();
|
|
723
|
+
if (activeFilters.priorities.length > 0) {
|
|
724
|
+
params.set('priority', activeFilters.priorities.join(','));
|
|
725
|
+
}
|
|
726
|
+
if (activeFilters.tagIds.length > 0) {
|
|
727
|
+
params.set('tags', activeFilters.tagIds.join(','));
|
|
728
|
+
}
|
|
729
|
+
if (activeFilters.assignee) {
|
|
730
|
+
params.set('assignee', activeFilters.assignee);
|
|
731
|
+
}
|
|
732
|
+
return params;
|
|
733
|
+
}
|
|
734
|
+
|
|
500
735
|
// Board polling: reload when updated_at changes (skip during drag)
|
|
501
736
|
let lastUpdatedAt = null;
|
|
502
737
|
async function refreshBoardCards() {
|
|
738
|
+
const filterParams = buildFilterParams();
|
|
739
|
+
const url = '/api/board/cards' + (filterParams.toString() ? '?' + filterParams.toString() : '');
|
|
503
740
|
try {
|
|
504
|
-
const res = await fetch(
|
|
741
|
+
const res = await fetch(url);
|
|
505
742
|
if (!res.ok) return;
|
|
506
743
|
const data = await res.json();
|
|
507
744
|
const columns = data.columns;
|
|
@@ -543,6 +780,31 @@ const BOARD_SCRIPT = `
|
|
|
543
780
|
});
|
|
544
781
|
});
|
|
545
782
|
});
|
|
783
|
+
// If detail panel is open, refresh its content if the task was updated
|
|
784
|
+
if (detailTaskId !== null) {
|
|
785
|
+
const editableFields = ['detail-edit-title', 'detail-edit-body', 'detail-edit-status', 'detail-edit-priority'];
|
|
786
|
+
const isEditing = editableFields.some(id => document.activeElement && document.activeElement.id === id);
|
|
787
|
+
if (isEditing) {
|
|
788
|
+
const warning = document.getElementById('detail-panel-update-warning');
|
|
789
|
+
if (!warning) {
|
|
790
|
+
const warningEl = document.createElement('div');
|
|
791
|
+
warningEl.id = 'detail-panel-update-warning';
|
|
792
|
+
warningEl.style.cssText = 'color: red; font-size: 0.85em; padding: 4px 8px; background: #fff0f0; border: 1px solid #ffcccc; border-radius: 4px; margin-bottom: 8px;';
|
|
793
|
+
warningEl.textContent = 'This task has been updated in the database. Save or discard your changes to see the latest version.';
|
|
794
|
+
detailPanelBody.insertBefore(warningEl, detailPanelBody.firstChild);
|
|
795
|
+
}
|
|
796
|
+
} else {
|
|
797
|
+
try {
|
|
798
|
+
const taskRes = await fetch('/api/tasks/' + detailTaskId);
|
|
799
|
+
if (taskRes.ok) {
|
|
800
|
+
const taskData = await taskRes.json();
|
|
801
|
+
renderDetailPanel(taskData);
|
|
802
|
+
}
|
|
803
|
+
} catch {
|
|
804
|
+
// Ignore network errors during detail panel refresh
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
546
808
|
} catch {
|
|
547
809
|
// Ignore network errors during card refresh
|
|
548
810
|
}
|
|
@@ -568,8 +830,163 @@ const BOARD_SCRIPT = `
|
|
|
568
830
|
// Ignore network errors during polling
|
|
569
831
|
}
|
|
570
832
|
}
|
|
571
|
-
setInterval(pollBoardUpdates,
|
|
572
|
-
pollBoardUpdates()
|
|
833
|
+
setInterval(pollBoardUpdates, 5000);
|
|
834
|
+
pollBoardUpdates();
|
|
835
|
+
|
|
836
|
+
function isFiltersActive() {
|
|
837
|
+
return activeFilters.priorities.length > 0 || activeFilters.tagIds.length > 0 || activeFilters.assignee !== '';
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function applyFilters() {
|
|
841
|
+
const clearBtn = document.getElementById('filter-clear');
|
|
842
|
+
if (clearBtn) {
|
|
843
|
+
if (isFiltersActive()) {
|
|
844
|
+
clearBtn.classList.add('visible');
|
|
845
|
+
} else {
|
|
846
|
+
clearBtn.classList.remove('visible');
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
refreshBoardCards();
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function renderFilterTagPills() {
|
|
853
|
+
const container = document.getElementById('filter-tags-control');
|
|
854
|
+
if (!container) return;
|
|
855
|
+
// Remove existing pills
|
|
856
|
+
container.querySelectorAll('.filter-tag-pill').forEach(p => p.remove());
|
|
857
|
+
// Add pills for active tag filters
|
|
858
|
+
activeFilters.tagIds.forEach(tagId => {
|
|
859
|
+
const tag = allAvailableTags.find(t => t.id === tagId);
|
|
860
|
+
if (!tag) return;
|
|
861
|
+
const pill = document.createElement('span');
|
|
862
|
+
pill.className = 'filter-tag-pill';
|
|
863
|
+
const label = document.createTextNode(tag.name);
|
|
864
|
+
const removeBtn = document.createElement('button');
|
|
865
|
+
removeBtn.className = 'filter-tag-pill-remove';
|
|
866
|
+
removeBtn.title = 'Remove tag filter';
|
|
867
|
+
removeBtn.innerHTML = '×';
|
|
868
|
+
removeBtn.addEventListener('click', () => {
|
|
869
|
+
const idx = activeFilters.tagIds.indexOf(tagId);
|
|
870
|
+
if (idx !== -1) activeFilters.tagIds.splice(idx, 1);
|
|
871
|
+
renderFilterTagPills();
|
|
872
|
+
applyFilters();
|
|
873
|
+
});
|
|
874
|
+
pill.appendChild(label);
|
|
875
|
+
pill.appendChild(removeBtn);
|
|
876
|
+
container.insertBefore(pill, container.querySelector('.filter-tag-dropdown-wrapper'));
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function initFilterBar() {
|
|
881
|
+
// Priority toggle buttons
|
|
882
|
+
document.querySelectorAll('.filter-priority-btn').forEach(btn => {
|
|
883
|
+
btn.addEventListener('click', () => {
|
|
884
|
+
const priority = btn.dataset.priority;
|
|
885
|
+
const idx = activeFilters.priorities.indexOf(priority);
|
|
886
|
+
if (idx === -1) {
|
|
887
|
+
activeFilters.priorities.push(priority);
|
|
888
|
+
btn.classList.add('active');
|
|
889
|
+
} else {
|
|
890
|
+
activeFilters.priorities.splice(idx, 1);
|
|
891
|
+
btn.classList.remove('active');
|
|
892
|
+
}
|
|
893
|
+
applyFilters();
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// Assignee input with debounce
|
|
898
|
+
const assigneeInput = document.getElementById('filter-assignee');
|
|
899
|
+
let assigneeTimer = null;
|
|
900
|
+
if (assigneeInput) {
|
|
901
|
+
assigneeInput.addEventListener('input', () => {
|
|
902
|
+
clearTimeout(assigneeTimer);
|
|
903
|
+
assigneeTimer = setTimeout(() => {
|
|
904
|
+
activeFilters.assignee = assigneeInput.value.trim();
|
|
905
|
+
applyFilters();
|
|
906
|
+
}, 300);
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Clear button
|
|
911
|
+
const clearBtn = document.getElementById('filter-clear');
|
|
912
|
+
if (clearBtn) {
|
|
913
|
+
clearBtn.addEventListener('click', () => {
|
|
914
|
+
activeFilters.tagIds = [];
|
|
915
|
+
activeFilters.priorities = [];
|
|
916
|
+
activeFilters.assignee = '';
|
|
917
|
+
document.querySelectorAll('.filter-priority-btn').forEach(btn => btn.classList.remove('active'));
|
|
918
|
+
if (assigneeInput) assigneeInput.value = '';
|
|
919
|
+
renderFilterTagPills();
|
|
920
|
+
applyFilters();
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Tag filter dropdown
|
|
925
|
+
const tagsControl = document.getElementById('filter-tags-control');
|
|
926
|
+
if (tagsControl) {
|
|
927
|
+
const dropdownWrapper = document.createElement('div');
|
|
928
|
+
dropdownWrapper.className = 'filter-tag-dropdown-wrapper';
|
|
929
|
+
|
|
930
|
+
const addBtn = document.createElement('button');
|
|
931
|
+
addBtn.className = 'filter-tag-add-btn';
|
|
932
|
+
addBtn.textContent = '+ Tag';
|
|
933
|
+
|
|
934
|
+
const dropdown = document.createElement('div');
|
|
935
|
+
dropdown.className = 'filter-tag-dropdown';
|
|
936
|
+
|
|
937
|
+
dropdownWrapper.appendChild(addBtn);
|
|
938
|
+
dropdownWrapper.appendChild(dropdown);
|
|
939
|
+
tagsControl.appendChild(dropdownWrapper);
|
|
940
|
+
|
|
941
|
+
function renderTagDropdown() {
|
|
942
|
+
dropdown.innerHTML = '';
|
|
943
|
+
const available = allAvailableTags.filter(t => !activeFilters.tagIds.includes(t.id));
|
|
944
|
+
if (available.length === 0) {
|
|
945
|
+
const empty = document.createElement('div');
|
|
946
|
+
empty.className = 'filter-tag-dropdown-empty';
|
|
947
|
+
empty.textContent = 'No tags available';
|
|
948
|
+
dropdown.appendChild(empty);
|
|
949
|
+
} else {
|
|
950
|
+
available.forEach(tag => {
|
|
951
|
+
const opt = document.createElement('div');
|
|
952
|
+
opt.className = 'filter-tag-dropdown-option';
|
|
953
|
+
opt.textContent = tag.name;
|
|
954
|
+
opt.addEventListener('mousedown', (e) => {
|
|
955
|
+
e.preventDefault();
|
|
956
|
+
activeFilters.tagIds.push(tag.id);
|
|
957
|
+
dropdown.classList.remove('open');
|
|
958
|
+
renderFilterTagPills();
|
|
959
|
+
applyFilters();
|
|
960
|
+
});
|
|
961
|
+
dropdown.appendChild(opt);
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
addBtn.addEventListener('click', () => {
|
|
967
|
+
if (dropdown.classList.contains('open')) {
|
|
968
|
+
dropdown.classList.remove('open');
|
|
969
|
+
} else {
|
|
970
|
+
renderTagDropdown();
|
|
971
|
+
const rect = addBtn.getBoundingClientRect();
|
|
972
|
+
dropdown.style.top = (rect.bottom + 2) + 'px';
|
|
973
|
+
dropdown.style.left = rect.left + 'px';
|
|
974
|
+
dropdown.classList.add('open');
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
document.addEventListener('click', (e) => {
|
|
979
|
+
if (!dropdownWrapper.contains(e.target)) {
|
|
980
|
+
dropdown.classList.remove('open');
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Initialize filter bar after tags are loaded
|
|
987
|
+
loadAllTags().then(() => {
|
|
988
|
+
initFilterBar();
|
|
989
|
+
});`;
|
|
573
990
|
function renderColumn(status, tasks, tagMap) {
|
|
574
991
|
const color = STATUS_COLORS[status];
|
|
575
992
|
const label = STATUS_LABELS[status];
|
|
@@ -629,6 +1046,24 @@ function renderBoard(tasksByStatus, tagMap, boardTitle) {
|
|
|
629
1046
|
</head>
|
|
630
1047
|
<body>
|
|
631
1048
|
<header><h1>agkan board</h1>${titleHtml}</header>
|
|
1049
|
+
<div class="filter-bar" id="filter-bar">
|
|
1050
|
+
<div class="filter-group">
|
|
1051
|
+
<span class="filter-label">Priority</span>
|
|
1052
|
+
<button class="filter-priority-btn" data-priority="critical">critical</button>
|
|
1053
|
+
<button class="filter-priority-btn" data-priority="high">high</button>
|
|
1054
|
+
<button class="filter-priority-btn" data-priority="medium">medium</button>
|
|
1055
|
+
<button class="filter-priority-btn" data-priority="low">low</button>
|
|
1056
|
+
</div>
|
|
1057
|
+
<div class="filter-group">
|
|
1058
|
+
<span class="filter-label">Tags</span>
|
|
1059
|
+
<div id="filter-tags-control" style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;"></div>
|
|
1060
|
+
</div>
|
|
1061
|
+
<div class="filter-group">
|
|
1062
|
+
<span class="filter-label">Assignee</span>
|
|
1063
|
+
<input type="text" id="filter-assignee" class="filter-assignee-input" placeholder="Filter by assignee">
|
|
1064
|
+
</div>
|
|
1065
|
+
<button class="filter-clear-btn" id="filter-clear">Clear filters</button>
|
|
1066
|
+
</div>
|
|
632
1067
|
<div class="board-container">
|
|
633
1068
|
<div class="board">${columns}</div>${BOARD_BODY_STATIC}
|
|
634
1069
|
</div>
|
|
@@ -694,7 +1129,7 @@ function getBoardUpdatedAt(database) {
|
|
|
694
1129
|
return null;
|
|
695
1130
|
return `${baseRow.max_updated_at}|${tagsRow.max_created_at}|${tagsRow.count}`;
|
|
696
1131
|
}
|
|
697
|
-
function registerTaskApiRoutes(app, { ts, tts, ms }) {
|
|
1132
|
+
function registerTaskApiRoutes(app, { ts, tts, tags, ms }) {
|
|
698
1133
|
app.get('/api/tasks', (c) => c.json({ tasks: ts.listTasks({}, 'id', 'asc') }));
|
|
699
1134
|
app.post('/api/tasks', async (c) => {
|
|
700
1135
|
const body = await c.req.json();
|
|
@@ -735,6 +1170,34 @@ function registerTaskApiRoutes(app, { ts, tts, ms }) {
|
|
|
735
1170
|
ts.deleteTask(id);
|
|
736
1171
|
return c.json({ success: true });
|
|
737
1172
|
});
|
|
1173
|
+
app.get('/api/tags', (c) => c.json({ tags: tags.listTags() }));
|
|
1174
|
+
app.post('/api/tasks/:id/tags', async (c) => {
|
|
1175
|
+
const id = Number(c.req.param('id'));
|
|
1176
|
+
if (isNaN(id))
|
|
1177
|
+
return c.json({ error: 'Invalid task id' }, 400);
|
|
1178
|
+
const body = await c.req.json();
|
|
1179
|
+
if (body.tagId === undefined || body.tagId === null)
|
|
1180
|
+
return c.json({ error: 'tagId is required' }, 400);
|
|
1181
|
+
const tagId = Number(body.tagId);
|
|
1182
|
+
if (!ts.getTask(id))
|
|
1183
|
+
return c.json({ error: 'Task not found' }, 404);
|
|
1184
|
+
if (!tags.getTag(tagId))
|
|
1185
|
+
return c.json({ error: 'Tag not found' }, 404);
|
|
1186
|
+
tts.addTagToTask({ task_id: id, tag_id: tagId });
|
|
1187
|
+
return c.json({ success: true }, 201);
|
|
1188
|
+
});
|
|
1189
|
+
app.delete('/api/tasks/:id/tags/:tagId', (c) => {
|
|
1190
|
+
const id = Number(c.req.param('id'));
|
|
1191
|
+
if (isNaN(id))
|
|
1192
|
+
return c.json({ error: 'Invalid task id' }, 400);
|
|
1193
|
+
const tagId = Number(c.req.param('tagId'));
|
|
1194
|
+
if (isNaN(tagId))
|
|
1195
|
+
return c.json({ error: 'Invalid tag id' }, 400);
|
|
1196
|
+
const removed = tts.removeTagFromTask(id, tagId);
|
|
1197
|
+
if (!removed)
|
|
1198
|
+
return c.json({ error: 'Tag not attached to task' }, 404);
|
|
1199
|
+
return c.json({ success: true });
|
|
1200
|
+
});
|
|
738
1201
|
}
|
|
739
1202
|
function buildBoardCardsPayload(tasksByStatus, tagMap) {
|
|
740
1203
|
return STATUSES.map((status) => {
|
|
@@ -743,6 +1206,29 @@ function buildBoardCardsPayload(tasksByStatus, tagMap) {
|
|
|
743
1206
|
return { status, html, count: tasks.length };
|
|
744
1207
|
});
|
|
745
1208
|
}
|
|
1209
|
+
function parseBoardCardFilters(query) {
|
|
1210
|
+
const filters = {};
|
|
1211
|
+
if (query.tags) {
|
|
1212
|
+
const tagIds = query.tags
|
|
1213
|
+
.split(',')
|
|
1214
|
+
.map((s) => Number(s.trim()))
|
|
1215
|
+
.filter((n) => !isNaN(n) && n > 0);
|
|
1216
|
+
if (tagIds.length > 0)
|
|
1217
|
+
filters.tagIds = tagIds;
|
|
1218
|
+
}
|
|
1219
|
+
if (query.priority) {
|
|
1220
|
+
const priorities = query.priority
|
|
1221
|
+
.split(',')
|
|
1222
|
+
.map((s) => s.trim())
|
|
1223
|
+
.filter((s) => s.length > 0);
|
|
1224
|
+
if (priorities.length > 0)
|
|
1225
|
+
filters.priority = priorities;
|
|
1226
|
+
}
|
|
1227
|
+
if (query.assignee && query.assignee.trim()) {
|
|
1228
|
+
filters.assignees = query.assignee.trim();
|
|
1229
|
+
}
|
|
1230
|
+
return filters;
|
|
1231
|
+
}
|
|
746
1232
|
function registerBoardRoutes(app, services) {
|
|
747
1233
|
const { ts, tts, database, boardTitle } = services;
|
|
748
1234
|
app.get('/', (c) => {
|
|
@@ -751,17 +1237,23 @@ function registerBoardRoutes(app, services) {
|
|
|
751
1237
|
});
|
|
752
1238
|
app.get('/api/board/updated-at', (c) => c.json({ updatedAt: getBoardUpdatedAt(database) }));
|
|
753
1239
|
app.get('/api/board/cards', (c) => {
|
|
754
|
-
const
|
|
1240
|
+
const filters = parseBoardCardFilters({
|
|
1241
|
+
tags: c.req.query('tags'),
|
|
1242
|
+
priority: c.req.query('priority'),
|
|
1243
|
+
assignee: c.req.query('assignee'),
|
|
1244
|
+
});
|
|
1245
|
+
const tasksByStatus = buildTasksByStatus(ts.listTasks(filters, 'id', 'asc'));
|
|
755
1246
|
const columns = buildBoardCardsPayload(tasksByStatus, tts.getAllTaskTags());
|
|
756
1247
|
return c.json({ columns });
|
|
757
1248
|
});
|
|
758
1249
|
registerTaskApiRoutes(app, services);
|
|
759
1250
|
}
|
|
760
|
-
function createBoardApp(taskService, taskTagService, metadataService, db, boardTitle) {
|
|
1251
|
+
function createBoardApp(taskService, taskTagService, metadataService, db, boardTitle, tagService) {
|
|
761
1252
|
const app = new hono_1.Hono();
|
|
762
1253
|
const services = {
|
|
763
1254
|
ts: taskService ?? new TaskService_1.TaskService(),
|
|
764
1255
|
tts: taskTagService ?? new TaskTagService_1.TaskTagService(),
|
|
1256
|
+
tags: tagService ?? new TagService_1.TagService(),
|
|
765
1257
|
ms: metadataService ?? new MetadataService_1.MetadataService(),
|
|
766
1258
|
database: db ?? (0, connection_1.getDatabase)(),
|
|
767
1259
|
boardTitle,
|