agkan 2.11.0 → 2.12.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/README.ja.md +1 -1
- package/README.md +1 -1
- package/dist/board/boardRenderer.d.ts +18 -0
- package/dist/board/boardRenderer.d.ts.map +1 -0
- package/dist/board/boardRenderer.js +273 -0
- package/dist/board/boardRenderer.js.map +1 -0
- package/dist/board/boardRoutes.d.ts +23 -0
- package/dist/board/boardRoutes.d.ts.map +1 -0
- package/dist/board/boardRoutes.js +273 -0
- package/dist/board/boardRoutes.js.map +1 -0
- package/dist/board/boardScript.d.ts +2 -0
- package/dist/board/boardScript.d.ts.map +1 -0
- package/dist/board/boardScript.js +1202 -0
- package/dist/board/boardScript.js.map +1 -0
- package/dist/board/boardStyles.d.ts +2 -0
- package/dist/board/boardStyles.d.ts.map +1 -0
- package/dist/board/boardStyles.js +171 -0
- package/dist/board/boardStyles.js.map +1 -0
- package/dist/board/client/board.js +1165 -0
- package/dist/board/server.d.ts.map +1 -1
- package/dist/board/server.js +7 -1712
- package/dist/board/server.js.map +1 -1
- package/dist/db/adapters/sqlite-adapter.js +2 -0
- package/dist/db/adapters/sqlite-adapter.js.map +1 -1
- package/dist/db/connection.js +2 -2
- package/dist/db/connection.js.map +1 -1
- package/dist/services/CommentService.js +1 -0
- package/dist/services/CommentService.js.map +1 -1
- package/dist/services/MetadataService.js +1 -0
- package/dist/services/MetadataService.js.map +1 -1
- package/dist/services/TagService.js +1 -0
- package/dist/services/TagService.js.map +1 -1
- package/dist/services/TaskBlockService.js +2 -0
- package/dist/services/TaskBlockService.js.map +1 -1
- package/dist/services/TaskService.js +1 -0
- package/dist/services/TaskService.js.map +1 -1
- package/dist/services/TaskTagService.js +3 -0
- package/dist/services/TaskTagService.js.map +1 -1
- package/package.json +9 -5
|
@@ -0,0 +1,1202 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BOARD_SCRIPT = void 0;
|
|
4
|
+
exports.BOARD_SCRIPT = `
|
|
5
|
+
let draggedCard = null;
|
|
6
|
+
let sourceBody = null;
|
|
7
|
+
|
|
8
|
+
document.querySelectorAll('.card').forEach(card => {
|
|
9
|
+
card.addEventListener('dragstart', e => {
|
|
10
|
+
draggedCard = card;
|
|
11
|
+
sourceBody = card.parentElement;
|
|
12
|
+
card.classList.add('dragging');
|
|
13
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
14
|
+
});
|
|
15
|
+
card.addEventListener('dragend', () => {
|
|
16
|
+
card.classList.remove('dragging');
|
|
17
|
+
draggedCard = null;
|
|
18
|
+
sourceBody = null;
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
document.querySelectorAll('.column').forEach(col => {
|
|
23
|
+
col.addEventListener('dragover', e => {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
col.classList.add('drag-over');
|
|
26
|
+
});
|
|
27
|
+
col.addEventListener('dragleave', () => col.classList.remove('drag-over'));
|
|
28
|
+
col.addEventListener('drop', e => handleDrop(e, col.dataset.status, col));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Auto-scroll during drag within column bodies
|
|
32
|
+
let autoScrollRAF = null;
|
|
33
|
+
let autoScrollBody = null;
|
|
34
|
+
let autoScrollDir = 0;
|
|
35
|
+
const AUTO_SCROLL_ZONE = 60;
|
|
36
|
+
const AUTO_SCROLL_SPEED = 8;
|
|
37
|
+
|
|
38
|
+
function stopAutoScroll() {
|
|
39
|
+
if (autoScrollRAF !== null) {
|
|
40
|
+
cancelAnimationFrame(autoScrollRAF);
|
|
41
|
+
autoScrollRAF = null;
|
|
42
|
+
}
|
|
43
|
+
autoScrollBody = null;
|
|
44
|
+
autoScrollDir = 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function startAutoScroll() {
|
|
48
|
+
if (autoScrollRAF !== null) return;
|
|
49
|
+
function step() {
|
|
50
|
+
if (autoScrollBody && autoScrollDir !== 0) {
|
|
51
|
+
autoScrollBody.scrollTop += autoScrollDir * AUTO_SCROLL_SPEED;
|
|
52
|
+
autoScrollRAF = requestAnimationFrame(step);
|
|
53
|
+
} else {
|
|
54
|
+
autoScrollRAF = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
autoScrollRAF = requestAnimationFrame(step);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function attachAutoScrollToBody(body) {
|
|
61
|
+
body.addEventListener('dragover', e => {
|
|
62
|
+
const rect = body.getBoundingClientRect();
|
|
63
|
+
const y = e.clientY - rect.top;
|
|
64
|
+
if (y < AUTO_SCROLL_ZONE) {
|
|
65
|
+
autoScrollBody = body;
|
|
66
|
+
autoScrollDir = -1;
|
|
67
|
+
startAutoScroll();
|
|
68
|
+
} else if (y > rect.height - AUTO_SCROLL_ZONE) {
|
|
69
|
+
autoScrollBody = body;
|
|
70
|
+
autoScrollDir = 1;
|
|
71
|
+
startAutoScroll();
|
|
72
|
+
} else {
|
|
73
|
+
stopAutoScroll();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
body.addEventListener('dragleave', stopAutoScroll);
|
|
77
|
+
body.addEventListener('drop', stopAutoScroll);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
document.querySelectorAll('.column-body').forEach(attachAutoScrollToBody);
|
|
81
|
+
|
|
82
|
+
document.addEventListener('dragend', stopAutoScroll);
|
|
83
|
+
|
|
84
|
+
async function handleDrop(e, newStatus, colEl) {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
colEl.classList.remove('drag-over');
|
|
87
|
+
if (!draggedCard) return;
|
|
88
|
+
const taskId = draggedCard.dataset.id;
|
|
89
|
+
const oldStatus = draggedCard.dataset.status;
|
|
90
|
+
if (oldStatus === newStatus) return;
|
|
91
|
+
|
|
92
|
+
const targetBody = document.getElementById('col-' + newStatus);
|
|
93
|
+
const prevBody = sourceBody;
|
|
94
|
+
targetBody.appendChild(draggedCard);
|
|
95
|
+
draggedCard.dataset.status = newStatus;
|
|
96
|
+
updateCount(oldStatus);
|
|
97
|
+
updateCount(newStatus);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetch('/api/tasks/' + taskId, {
|
|
101
|
+
method: 'PATCH',
|
|
102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
|
+
body: JSON.stringify({ status: newStatus })
|
|
104
|
+
});
|
|
105
|
+
if (!res.ok) throw new Error('Server error');
|
|
106
|
+
} catch {
|
|
107
|
+
prevBody.appendChild(draggedCard);
|
|
108
|
+
draggedCard.dataset.status = oldStatus;
|
|
109
|
+
updateCount(oldStatus);
|
|
110
|
+
updateCount(newStatus);
|
|
111
|
+
showToast();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function updateCount(status) {
|
|
116
|
+
const col = document.querySelector('.column[data-status="' + status + '"]');
|
|
117
|
+
if (!col) return;
|
|
118
|
+
col.querySelector('.column-count').textContent = col.querySelector('.column-body').children.length;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function showToast(msg) {
|
|
122
|
+
const toast = document.getElementById('toast');
|
|
123
|
+
if (msg) toast.textContent = msg;
|
|
124
|
+
toast.classList.add('show');
|
|
125
|
+
setTimeout(() => toast.classList.remove('show'), 3000);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Add task modal
|
|
129
|
+
const addModal = document.getElementById('add-modal');
|
|
130
|
+
const addTitle = document.getElementById('add-title');
|
|
131
|
+
const addBody = document.getElementById('add-body');
|
|
132
|
+
const addPriority = document.getElementById('add-priority');
|
|
133
|
+
const addStatus = document.getElementById('add-status');
|
|
134
|
+
|
|
135
|
+
document.querySelectorAll('.add-btn').forEach(btn => {
|
|
136
|
+
btn.addEventListener('click', e => {
|
|
137
|
+
e.stopPropagation();
|
|
138
|
+
addStatus.value = btn.dataset.status;
|
|
139
|
+
addTitle.value = '';
|
|
140
|
+
addBody.value = '';
|
|
141
|
+
addPriority.value = '';
|
|
142
|
+
addModal.classList.add('show');
|
|
143
|
+
addTitle.focus();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
document.getElementById('add-cancel').addEventListener('click', () => {
|
|
148
|
+
addModal.classList.remove('show');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
addModal.addEventListener('click', e => {
|
|
152
|
+
if (e.target === addModal) addModal.classList.remove('show');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
addTitle.addEventListener('keydown', e => {
|
|
156
|
+
if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); document.getElementById('add-submit').click(); }
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
document.getElementById('add-submit').addEventListener('click', async () => {
|
|
160
|
+
const title = addTitle.value.trim();
|
|
161
|
+
if (!title) { addTitle.focus(); return; }
|
|
162
|
+
const status = addStatus.value;
|
|
163
|
+
addModal.classList.remove('show');
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetch('/api/tasks', {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: { 'Content-Type': 'application/json' },
|
|
169
|
+
body: JSON.stringify({ title, body: addBody.value.trim() || null, status, priority: addPriority.value || null })
|
|
170
|
+
});
|
|
171
|
+
if (!res.ok) throw new Error('Server error');
|
|
172
|
+
location.reload();
|
|
173
|
+
} catch {
|
|
174
|
+
showToast('Failed to add task');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Context menu
|
|
179
|
+
const ctxMenu = document.getElementById('context-menu');
|
|
180
|
+
let ctxTargetCard = null;
|
|
181
|
+
|
|
182
|
+
document.addEventListener('contextmenu', e => {
|
|
183
|
+
const card = e.target.closest('.card');
|
|
184
|
+
if (!card) { ctxMenu.style.display = 'none'; return; }
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
ctxTargetCard = card;
|
|
187
|
+
ctxMenu.style.left = e.clientX + 'px';
|
|
188
|
+
ctxMenu.style.top = e.clientY + 'px';
|
|
189
|
+
ctxMenu.style.display = 'block';
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
document.addEventListener('click', e => {
|
|
193
|
+
if (!e.target.closest('#context-menu')) {
|
|
194
|
+
ctxMenu.style.display = 'none';
|
|
195
|
+
ctxTargetCard = null;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
document.getElementById('ctx-delete').addEventListener('click', async e => {
|
|
200
|
+
e.stopPropagation();
|
|
201
|
+
ctxMenu.style.display = 'none';
|
|
202
|
+
if (!ctxTargetCard) return;
|
|
203
|
+
const card = ctxTargetCard;
|
|
204
|
+
ctxTargetCard = null;
|
|
205
|
+
const taskId = card.dataset.id;
|
|
206
|
+
const status = card.dataset.status;
|
|
207
|
+
if (!confirm('Delete task #' + taskId + '?')) return;
|
|
208
|
+
|
|
209
|
+
card.remove();
|
|
210
|
+
updateCount(status);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const res = await fetch('/api/tasks/' + taskId, { method: 'DELETE' });
|
|
214
|
+
if (!res.ok) throw new Error('Server error');
|
|
215
|
+
} catch {
|
|
216
|
+
location.reload();
|
|
217
|
+
showToast('Failed to delete task');
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Detail panel - create and insert into board-container
|
|
222
|
+
const boardContainer = document.querySelector('.board-container');
|
|
223
|
+
const detailPanelHtml = '<div class="detail-panel" id="detail-panel"><div class="detail-panel-resize-handle" id="detail-panel-resize-handle"></div><div class="detail-panel-header"><h2 id="detail-panel-title">Task Detail</h2><button class="detail-panel-close" id="detail-panel-close" title="Close">×</button></div><div class="detail-tabs" id="detail-tabs"><button class="detail-tab active" data-tab="details">Details</button><button class="detail-tab" data-tab="comments" id="detail-tab-comments">Comments</button></div><div class="detail-panel-body" id="detail-panel-body"><div class="detail-tab-content active" id="detail-tab-content-details"></div><div class="detail-tab-content" id="detail-tab-content-comments"></div></div><div class="detail-panel-footer" id="detail-panel-footer"><button id="detail-save-btn">Save</button></div></div>';
|
|
224
|
+
boardContainer.insertAdjacentHTML('beforeend', detailPanelHtml);
|
|
225
|
+
|
|
226
|
+
const detailPanel = document.getElementById('detail-panel');
|
|
227
|
+
const detailPanelTitle = document.getElementById('detail-panel-title');
|
|
228
|
+
const detailPanelBody = document.getElementById('detail-panel-body');
|
|
229
|
+
let detailTaskId = null;
|
|
230
|
+
let lastTab = 'details';
|
|
231
|
+
|
|
232
|
+
function closeDetailPanel() {
|
|
233
|
+
detailPanel.classList.remove('open');
|
|
234
|
+
detailPanel.style.width = '';
|
|
235
|
+
detailTaskId = null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
document.getElementById('detail-panel-close').addEventListener('click', closeDetailPanel);
|
|
239
|
+
|
|
240
|
+
// Tab switching
|
|
241
|
+
function switchTab(tabName) {
|
|
242
|
+
lastTab = tabName;
|
|
243
|
+
document.querySelectorAll('.detail-tab').forEach(btn => {
|
|
244
|
+
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
|
245
|
+
});
|
|
246
|
+
document.querySelectorAll('.detail-tab-content').forEach(el => {
|
|
247
|
+
el.classList.toggle('active', el.id === 'detail-tab-content-' + tabName);
|
|
248
|
+
});
|
|
249
|
+
const footer = document.getElementById('detail-panel-footer');
|
|
250
|
+
if (footer) footer.style.display = tabName === 'details' ? '' : 'none';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
document.getElementById('detail-tabs').addEventListener('click', e => {
|
|
254
|
+
const btn = e.target.closest('.detail-tab');
|
|
255
|
+
if (!btn) return;
|
|
256
|
+
switchTab(btn.dataset.tab);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Detail panel resize
|
|
260
|
+
const resizeHandle = document.getElementById('detail-panel-resize-handle');
|
|
261
|
+
const PANEL_MIN_WIDTH = 280;
|
|
262
|
+
const PANEL_MAX_WIDTH = 800;
|
|
263
|
+
const PANEL_DEFAULT_WIDTH = 400;
|
|
264
|
+
|
|
265
|
+
// Initialize panel width from server config (async)
|
|
266
|
+
(async function initPanelWidth() {
|
|
267
|
+
let targetWidth = PANEL_DEFAULT_WIDTH;
|
|
268
|
+
try {
|
|
269
|
+
const res = await fetch('/api/config');
|
|
270
|
+
if (res.ok) {
|
|
271
|
+
const data = await res.json();
|
|
272
|
+
const savedWidth = data && data.board && data.board.detailPaneWidth;
|
|
273
|
+
if (typeof savedWidth === 'number' && savedWidth >= PANEL_MIN_WIDTH && savedWidth <= PANEL_MAX_WIDTH) {
|
|
274
|
+
targetWidth = savedWidth;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
// Ignore errors, use default width
|
|
279
|
+
}
|
|
280
|
+
// Store the width for when panel opens (width is 0 when closed)
|
|
281
|
+
detailPanel.dataset.preferredWidth = String(targetWidth);
|
|
282
|
+
})();
|
|
283
|
+
|
|
284
|
+
resizeHandle.addEventListener('mousedown', function(e) {
|
|
285
|
+
e.preventDefault();
|
|
286
|
+
if (!detailPanel.classList.contains('open')) return;
|
|
287
|
+
const startX = e.clientX;
|
|
288
|
+
const startWidth = detailPanel.offsetWidth;
|
|
289
|
+
resizeHandle.classList.add('dragging');
|
|
290
|
+
document.body.style.userSelect = 'none';
|
|
291
|
+
document.body.style.cursor = 'col-resize';
|
|
292
|
+
detailPanel.style.transition = 'none';
|
|
293
|
+
|
|
294
|
+
function onMouseMove(e) {
|
|
295
|
+
const delta = startX - e.clientX;
|
|
296
|
+
const newWidth = Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, startWidth + delta));
|
|
297
|
+
detailPanel.style.width = newWidth + 'px';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function onMouseUp() {
|
|
301
|
+
resizeHandle.classList.remove('dragging');
|
|
302
|
+
document.body.style.userSelect = '';
|
|
303
|
+
document.body.style.cursor = '';
|
|
304
|
+
detailPanel.style.transition = '';
|
|
305
|
+
const currentWidth = detailPanel.offsetWidth;
|
|
306
|
+
detailPanel.dataset.preferredWidth = String(currentWidth);
|
|
307
|
+
fetch('/api/config', {
|
|
308
|
+
method: 'PUT',
|
|
309
|
+
headers: { 'Content-Type': 'application/json' },
|
|
310
|
+
body: JSON.stringify({ board: { detailPaneWidth: currentWidth } })
|
|
311
|
+
}).catch(function() {
|
|
312
|
+
// Ignore errors when saving panel width
|
|
313
|
+
});
|
|
314
|
+
document.removeEventListener('mousemove', onMouseMove);
|
|
315
|
+
document.removeEventListener('mouseup', onMouseUp);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
document.addEventListener('mousemove', onMouseMove);
|
|
319
|
+
document.addEventListener('mouseup', onMouseUp);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
let allAvailableTags = [];
|
|
323
|
+
|
|
324
|
+
async function loadAllTags() {
|
|
325
|
+
try {
|
|
326
|
+
const res = await fetch('/api/tags');
|
|
327
|
+
if (!res.ok) return;
|
|
328
|
+
const data = await res.json();
|
|
329
|
+
allAvailableTags = data.tags || [];
|
|
330
|
+
} catch {
|
|
331
|
+
// Ignore errors loading tags
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function renderTagsSection(currentTags) {
|
|
336
|
+
const container = document.getElementById('detail-tags-container');
|
|
337
|
+
if (!container) return;
|
|
338
|
+
|
|
339
|
+
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>';
|
|
340
|
+
|
|
341
|
+
const control = document.getElementById('tag-select-control');
|
|
342
|
+
const dropdown = document.getElementById('tag-select-dropdown');
|
|
343
|
+
let focusedOptionIndex = -1;
|
|
344
|
+
let inputValue = '';
|
|
345
|
+
|
|
346
|
+
function getFilteredTags() {
|
|
347
|
+
const currentTagIds = new Set(currentTags.map(t => t.id));
|
|
348
|
+
const available = allAvailableTags.filter(t => !currentTagIds.has(t.id));
|
|
349
|
+
if (!inputValue.trim()) return available;
|
|
350
|
+
const q = inputValue.toLowerCase();
|
|
351
|
+
return available.filter(t => t.name.toLowerCase().includes(q));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const input = document.createElement('input');
|
|
355
|
+
input.className = 'tag-select-input';
|
|
356
|
+
input.type = 'text';
|
|
357
|
+
input.autocomplete = 'off';
|
|
358
|
+
control.appendChild(input);
|
|
359
|
+
|
|
360
|
+
function renderPills() {
|
|
361
|
+
control.querySelectorAll('.tag-pill').forEach(p => p.remove());
|
|
362
|
+
currentTags.forEach(t => {
|
|
363
|
+
const pill = document.createElement('span');
|
|
364
|
+
pill.className = 'tag-pill';
|
|
365
|
+
pill.dataset.tagId = t.id;
|
|
366
|
+
const label = document.createTextNode(t.name);
|
|
367
|
+
const removeBtn = document.createElement('button');
|
|
368
|
+
removeBtn.className = 'tag-pill-remove';
|
|
369
|
+
removeBtn.title = 'Remove tag';
|
|
370
|
+
removeBtn.setAttribute('data-tag-id', t.id);
|
|
371
|
+
removeBtn.innerHTML = '×';
|
|
372
|
+
removeBtn.addEventListener('click', async e => {
|
|
373
|
+
e.stopPropagation();
|
|
374
|
+
try {
|
|
375
|
+
const res = await fetch('/api/tasks/' + detailTaskId + '/tags/' + t.id, { method: 'DELETE' });
|
|
376
|
+
if (!res.ok) throw new Error('Server error');
|
|
377
|
+
const idx = currentTags.findIndex(x => String(x.id) === String(t.id));
|
|
378
|
+
if (idx !== -1) currentTags.splice(idx, 1);
|
|
379
|
+
renderPills();
|
|
380
|
+
renderDropdown();
|
|
381
|
+
} catch {
|
|
382
|
+
showToast('Failed to remove tag');
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
pill.appendChild(label);
|
|
386
|
+
pill.appendChild(removeBtn);
|
|
387
|
+
control.insertBefore(pill, input);
|
|
388
|
+
});
|
|
389
|
+
input.placeholder = currentTags.length === 0 ? 'Add tags...' : '';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function renderDropdown() {
|
|
393
|
+
const filtered = getFilteredTags();
|
|
394
|
+
dropdown.innerHTML = '';
|
|
395
|
+
focusedOptionIndex = -1;
|
|
396
|
+
if (filtered.length === 0) {
|
|
397
|
+
const noOpt = document.createElement('div');
|
|
398
|
+
noOpt.className = 'tag-select-no-options';
|
|
399
|
+
noOpt.textContent = inputValue ? 'No matching tags' : 'No tags available';
|
|
400
|
+
dropdown.appendChild(noOpt);
|
|
401
|
+
} else {
|
|
402
|
+
filtered.forEach((t, i) => {
|
|
403
|
+
const opt = document.createElement('div');
|
|
404
|
+
opt.className = 'tag-select-option';
|
|
405
|
+
opt.dataset.tagId = t.id;
|
|
406
|
+
opt.textContent = t.name;
|
|
407
|
+
opt.addEventListener('mouseover', () => setFocusedOption(i));
|
|
408
|
+
opt.addEventListener('mousedown', async e => {
|
|
409
|
+
e.preventDefault();
|
|
410
|
+
await addTag(t.id);
|
|
411
|
+
});
|
|
412
|
+
dropdown.appendChild(opt);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function setFocusedOption(index) {
|
|
418
|
+
const opts = dropdown.querySelectorAll('.tag-select-option');
|
|
419
|
+
opts.forEach((o, i) => o.classList.toggle('focused', i === index));
|
|
420
|
+
focusedOptionIndex = index;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function openDropdown() {
|
|
424
|
+
renderDropdown();
|
|
425
|
+
dropdown.classList.add('open');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function closeDropdown() {
|
|
429
|
+
dropdown.classList.remove('open');
|
|
430
|
+
focusedOptionIndex = -1;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function addTag(tagId) {
|
|
434
|
+
try {
|
|
435
|
+
const res = await fetch('/api/tasks/' + detailTaskId + '/tags', {
|
|
436
|
+
method: 'POST',
|
|
437
|
+
headers: { 'Content-Type': 'application/json' },
|
|
438
|
+
body: JSON.stringify({ tagId: Number(tagId) })
|
|
439
|
+
});
|
|
440
|
+
if (!res.ok) throw new Error('Server error');
|
|
441
|
+
const tag = allAvailableTags.find(t => String(t.id) === String(tagId));
|
|
442
|
+
if (tag) currentTags.push(tag);
|
|
443
|
+
input.value = '';
|
|
444
|
+
inputValue = '';
|
|
445
|
+
renderPills();
|
|
446
|
+
renderDropdown();
|
|
447
|
+
} catch {
|
|
448
|
+
showToast('Failed to add tag');
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
control.addEventListener('click', () => input.focus());
|
|
453
|
+
|
|
454
|
+
input.addEventListener('focus', () => openDropdown());
|
|
455
|
+
|
|
456
|
+
input.addEventListener('blur', () => setTimeout(() => closeDropdown(), 150));
|
|
457
|
+
|
|
458
|
+
input.addEventListener('input', () => {
|
|
459
|
+
inputValue = input.value;
|
|
460
|
+
renderDropdown();
|
|
461
|
+
if (!dropdown.classList.contains('open')) openDropdown();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
input.addEventListener('keydown', async e => {
|
|
465
|
+
const filtered = getFilteredTags();
|
|
466
|
+
const opts = dropdown.querySelectorAll('.tag-select-option');
|
|
467
|
+
if (e.key === 'ArrowDown') {
|
|
468
|
+
e.preventDefault();
|
|
469
|
+
setFocusedOption(Math.min(focusedOptionIndex + 1, opts.length - 1));
|
|
470
|
+
} else if (e.key === 'ArrowUp') {
|
|
471
|
+
e.preventDefault();
|
|
472
|
+
setFocusedOption(Math.max(focusedOptionIndex - 1, 0));
|
|
473
|
+
} else if (e.key === 'Enter') {
|
|
474
|
+
e.preventDefault();
|
|
475
|
+
if (focusedOptionIndex >= 0 && filtered[focusedOptionIndex]) {
|
|
476
|
+
await addTag(filtered[focusedOptionIndex].id);
|
|
477
|
+
}
|
|
478
|
+
} else if (e.key === 'Escape') {
|
|
479
|
+
closeDropdown();
|
|
480
|
+
input.blur();
|
|
481
|
+
} else if (e.key === 'Backspace' && input.value === '' && currentTags.length > 0) {
|
|
482
|
+
e.preventDefault();
|
|
483
|
+
const last = currentTags[currentTags.length - 1];
|
|
484
|
+
try {
|
|
485
|
+
const res = await fetch('/api/tasks/' + detailTaskId + '/tags/' + last.id, { method: 'DELETE' });
|
|
486
|
+
if (!res.ok) throw new Error('Server error');
|
|
487
|
+
currentTags.splice(currentTags.length - 1, 1);
|
|
488
|
+
renderPills();
|
|
489
|
+
renderDropdown();
|
|
490
|
+
} catch {
|
|
491
|
+
showToast('Failed to remove tag');
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
renderPills();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function relativeTime(isoStr) {
|
|
500
|
+
if (!isoStr) return '';
|
|
501
|
+
const diff = Date.now() - new Date(isoStr).getTime();
|
|
502
|
+
const sec = Math.floor(diff / 1000);
|
|
503
|
+
if (sec < 60) return 'just now';
|
|
504
|
+
const min = Math.floor(sec / 60);
|
|
505
|
+
if (min < 60) return min + 'm ago';
|
|
506
|
+
const hr = Math.floor(min / 60);
|
|
507
|
+
if (hr < 24) return hr + 'h ago';
|
|
508
|
+
const day = Math.floor(hr / 24);
|
|
509
|
+
if (day < 30) return day + 'd ago';
|
|
510
|
+
const mo = Math.floor(day / 30);
|
|
511
|
+
if (mo < 12) return mo + 'mo ago';
|
|
512
|
+
return Math.floor(mo / 12) + 'y ago';
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function renderDetailPanel(data) {
|
|
516
|
+
const task = data.task;
|
|
517
|
+
const tags = data.tags || [];
|
|
518
|
+
const metadata = data.metadata || [];
|
|
519
|
+
const blockedBy = data.blockedBy || [];
|
|
520
|
+
const blocking = data.blocking || [];
|
|
521
|
+
const parent = data.parent || null;
|
|
522
|
+
|
|
523
|
+
detailTaskId = task.id;
|
|
524
|
+
detailPanelTitle.textContent = '#' + task.id;
|
|
525
|
+
|
|
526
|
+
let html = '';
|
|
527
|
+
|
|
528
|
+
// Status (editable)
|
|
529
|
+
html += '<div class="detail-field">';
|
|
530
|
+
html += '<div class="detail-field-label">Status</div>';
|
|
531
|
+
html += '<select id="detail-edit-status" class="detail-edit-select">';
|
|
532
|
+
allStatuses.forEach(s => {
|
|
533
|
+
const selected = s === task.status ? ' selected' : '';
|
|
534
|
+
html += '<option value="' + s + '"' + selected + '>' + statusLabels[s] + '</option>';
|
|
535
|
+
});
|
|
536
|
+
html += '</select>';
|
|
537
|
+
html += '</div>';
|
|
538
|
+
|
|
539
|
+
// Priority (editable)
|
|
540
|
+
html += '<div class="detail-field">';
|
|
541
|
+
html += '<div class="detail-field-label">Priority</div>';
|
|
542
|
+
html += '<select id="detail-edit-priority" class="detail-edit-select">';
|
|
543
|
+
html += '<option value="">None</option>';
|
|
544
|
+
allPriorities.forEach(p => {
|
|
545
|
+
const selected = task.priority === p ? ' selected' : '';
|
|
546
|
+
html += '<option value="' + p + '"' + selected + '>' + p.charAt(0).toUpperCase() + p.slice(1) + '</option>';
|
|
547
|
+
});
|
|
548
|
+
html += '</select>';
|
|
549
|
+
html += '</div>';
|
|
550
|
+
|
|
551
|
+
// Tags (editable)
|
|
552
|
+
html += '<div class="detail-field">';
|
|
553
|
+
html += '<div class="detail-field-label">Tags</div>';
|
|
554
|
+
html += '<div id="detail-tags-container"></div>';
|
|
555
|
+
html += '</div>';
|
|
556
|
+
|
|
557
|
+
// Relations: parent, blockedBy, blocking
|
|
558
|
+
const hasRelations = parent || blockedBy.length > 0 || blocking.length > 0;
|
|
559
|
+
if (hasRelations) {
|
|
560
|
+
html += '<div class="detail-relations">';
|
|
561
|
+
if (parent) {
|
|
562
|
+
html += '<div class="detail-relation-row">';
|
|
563
|
+
html += '<span class="detail-relation-label">Parent</span>';
|
|
564
|
+
html += '<div class="detail-relation-ids"><span class="detail-relation-id">#' + parent.id + ' ' + escapeHtmlClient(parent.title) + '</span></div>';
|
|
565
|
+
html += '</div>';
|
|
566
|
+
}
|
|
567
|
+
if (blockedBy.length > 0) {
|
|
568
|
+
html += '<div class="detail-relation-row">';
|
|
569
|
+
html += '<span class="detail-relation-label">Blocked by</span>';
|
|
570
|
+
html += '<div class="detail-relation-ids">';
|
|
571
|
+
blockedBy.forEach(t => { html += '<span class="detail-relation-id">#' + t.id + '</span>'; });
|
|
572
|
+
html += '</div></div>';
|
|
573
|
+
}
|
|
574
|
+
if (blocking.length > 0) {
|
|
575
|
+
html += '<div class="detail-relation-row">';
|
|
576
|
+
html += '<span class="detail-relation-label">Blocking</span>';
|
|
577
|
+
html += '<div class="detail-relation-ids">';
|
|
578
|
+
blocking.forEach(t => { html += '<span class="detail-relation-id">#' + t.id + '</span>'; });
|
|
579
|
+
html += '</div></div>';
|
|
580
|
+
}
|
|
581
|
+
html += '</div>';
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Title (editable)
|
|
585
|
+
html += '<div class="detail-field">';
|
|
586
|
+
html += '<div class="detail-field-label">Title</div>';
|
|
587
|
+
html += '<input id="detail-edit-title" class="detail-edit-input" type="text" value="' + escapeHtmlClient(task.title) + '">';
|
|
588
|
+
html += '</div>';
|
|
589
|
+
|
|
590
|
+
// Body (editable)
|
|
591
|
+
html += '<div class="detail-field description-field-wrapper">';
|
|
592
|
+
html += '<div class="detail-field-label">Description</div>';
|
|
593
|
+
html += '<textarea id="detail-edit-body" class="detail-edit-textarea">' + escapeHtmlClient(task.body || '') + '</textarea>';
|
|
594
|
+
html += '</div>';
|
|
595
|
+
|
|
596
|
+
// Metadata table (read-only, non-priority)
|
|
597
|
+
const otherMeta = metadata.filter(m => m.key !== 'priority');
|
|
598
|
+
if (otherMeta.length > 0) {
|
|
599
|
+
html += '<div class="detail-field">';
|
|
600
|
+
html += '<div class="detail-field-label">Metadata</div>';
|
|
601
|
+
html += '<table class="detail-meta-table">';
|
|
602
|
+
otherMeta.forEach(m => {
|
|
603
|
+
html += '<tr><td>' + escapeHtmlClient(m.key) + '</td><td>' + escapeHtmlClient(m.value) + '</td></tr>';
|
|
604
|
+
});
|
|
605
|
+
html += '</table></div>';
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Timestamps compressed to one line
|
|
609
|
+
html += '<div class="detail-timestamp">created ' + relativeTime(task.created_at) + ' · updated ' + relativeTime(task.updated_at) + '</div>';
|
|
610
|
+
|
|
611
|
+
const detailsPane = document.getElementById('detail-tab-content-details');
|
|
612
|
+
if (detailsPane) {
|
|
613
|
+
detailsPane.innerHTML = html;
|
|
614
|
+
detailsPane.style.padding = '20px';
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Render tags section after DOM update
|
|
618
|
+
loadAllTags().then(() => renderTagsSection([...tags]));
|
|
619
|
+
|
|
620
|
+
// Load comments into the comments tab
|
|
621
|
+
loadComments(task.id);
|
|
622
|
+
|
|
623
|
+
// Restore last tab
|
|
624
|
+
switchTab(lastTab);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function escapeHtmlClient(str) {
|
|
628
|
+
if (!str) return '';
|
|
629
|
+
const div = document.createElement('div');
|
|
630
|
+
div.textContent = String(str);
|
|
631
|
+
return div.innerHTML;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function loadComments(taskId) {
|
|
635
|
+
const tabBtn = document.getElementById('detail-tab-comments');
|
|
636
|
+
const pane = document.getElementById('detail-tab-content-comments');
|
|
637
|
+
if (!pane) return;
|
|
638
|
+
try {
|
|
639
|
+
const res = await fetch('/api/tasks/' + taskId + '/comments');
|
|
640
|
+
if (!res.ok) throw new Error('Server error');
|
|
641
|
+
const data = await res.json();
|
|
642
|
+
const comments = data.comments || [];
|
|
643
|
+
if (tabBtn) tabBtn.textContent = 'Comments (' + comments.length + ')';
|
|
644
|
+
renderComments(taskId, comments);
|
|
645
|
+
} catch {
|
|
646
|
+
if (pane) pane.innerHTML = '<div style="padding:20px;font-size:12px;color:#94a3b8;">Failed to load comments</div>';
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function renderComments(taskId, comments) {
|
|
651
|
+
const pane = document.getElementById('detail-tab-content-comments');
|
|
652
|
+
if (!pane) return;
|
|
653
|
+
pane.style.padding = '16px 20px';
|
|
654
|
+
|
|
655
|
+
let html = '';
|
|
656
|
+
|
|
657
|
+
comments.forEach(function(comment) {
|
|
658
|
+
const authorText = comment.author ? escapeHtmlClient(comment.author) : 'Anonymous';
|
|
659
|
+
const dateRel = relativeTime(comment.created_at);
|
|
660
|
+
const dateAbs = escapeHtmlClient(comment.created_at);
|
|
661
|
+
const contentText = escapeHtmlClient(comment.content);
|
|
662
|
+
html += '<div class="comment-item" data-comment-id="' + comment.id + '">';
|
|
663
|
+
html += '<div class="comment-meta">';
|
|
664
|
+
html += '<span class="comment-author">' + authorText + '</span>';
|
|
665
|
+
html += '<span class="comment-date" title="' + dateAbs + '">' + dateRel + '</span>';
|
|
666
|
+
html += '<span class="comment-actions">';
|
|
667
|
+
html += '<button class="comment-action-btn" title="Edit" onclick="startCommentEdit(' + comment.id + ')">✎</button>';
|
|
668
|
+
html += '<button class="comment-action-btn danger" title="Delete" onclick="deleteComment(' + comment.id + ',' + taskId + ')">🗑</button>';
|
|
669
|
+
html += '</span>';
|
|
670
|
+
html += '</div>';
|
|
671
|
+
html += '<div class="comment-content" id="comment-content-' + comment.id + '">' + contentText + '</div>';
|
|
672
|
+
html += '<div id="comment-edit-' + comment.id + '" style="display:none;">';
|
|
673
|
+
html += '<textarea class="comment-edit-area" id="comment-edit-area-' + comment.id + '">' + contentText + '</textarea>';
|
|
674
|
+
html += '<div class="comment-edit-actions">';
|
|
675
|
+
html += '<button class="comment-btn" onclick="saveCommentEdit(' + comment.id + ',' + taskId + ')">Save</button>';
|
|
676
|
+
html += '<button class="comment-btn" onclick="cancelCommentEdit(' + comment.id + ')">Cancel</button>';
|
|
677
|
+
html += '</div></div>';
|
|
678
|
+
html += '</div>';
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
html += '<button class="add-comment-trigger" id="add-comment-trigger" onclick="openAddCommentForm()">+ Add comment...</button>';
|
|
682
|
+
html += '<div class="add-comment-form" id="add-comment-form">';
|
|
683
|
+
html += '<textarea class="add-comment-textarea" id="add-comment-text" placeholder="Write a comment..."></textarea>';
|
|
684
|
+
html += '<div>';
|
|
685
|
+
html += '<button class="add-comment-submit" onclick="submitComment(' + taskId + ')">Add Comment</button>';
|
|
686
|
+
html += '<button class="add-comment-cancel" onclick="closeAddCommentForm()">Cancel</button>';
|
|
687
|
+
html += '</div></div>';
|
|
688
|
+
|
|
689
|
+
pane.innerHTML = html;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function openAddCommentForm() {
|
|
693
|
+
const trigger = document.getElementById('add-comment-trigger');
|
|
694
|
+
const form = document.getElementById('add-comment-form');
|
|
695
|
+
if (trigger) trigger.style.display = 'none';
|
|
696
|
+
if (form) { form.classList.add('open'); form.querySelector('textarea').focus(); }
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function closeAddCommentForm() {
|
|
700
|
+
const trigger = document.getElementById('add-comment-trigger');
|
|
701
|
+
const form = document.getElementById('add-comment-form');
|
|
702
|
+
if (trigger) trigger.style.display = '';
|
|
703
|
+
if (form) { form.classList.remove('open'); form.querySelector('textarea').value = ''; }
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function startCommentEdit(commentId) {
|
|
707
|
+
const contentEl = document.getElementById('comment-content-' + commentId);
|
|
708
|
+
const editWrapper = document.getElementById('comment-edit-' + commentId);
|
|
709
|
+
if (contentEl) contentEl.style.display = 'none';
|
|
710
|
+
if (editWrapper) editWrapper.style.display = 'block';
|
|
711
|
+
const area = document.getElementById('comment-edit-area-' + commentId);
|
|
712
|
+
if (area) area.focus();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function cancelCommentEdit(commentId) {
|
|
716
|
+
const contentEl = document.getElementById('comment-content-' + commentId);
|
|
717
|
+
const editWrapper = document.getElementById('comment-edit-' + commentId);
|
|
718
|
+
if (contentEl) contentEl.style.display = '';
|
|
719
|
+
if (editWrapper) editWrapper.style.display = 'none';
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function saveCommentEdit(commentId, taskId) {
|
|
723
|
+
const area = document.getElementById('comment-edit-area-' + commentId);
|
|
724
|
+
if (!area) return;
|
|
725
|
+
const content = area.value.trim();
|
|
726
|
+
if (!content) { area.focus(); return; }
|
|
727
|
+
try {
|
|
728
|
+
const res = await fetch('/api/comments/' + commentId, {
|
|
729
|
+
method: 'PATCH',
|
|
730
|
+
headers: { 'Content-Type': 'application/json' },
|
|
731
|
+
body: JSON.stringify({ content })
|
|
732
|
+
});
|
|
733
|
+
if (!res.ok) throw new Error('Server error');
|
|
734
|
+
await loadComments(taskId);
|
|
735
|
+
} catch {
|
|
736
|
+
showToast('Failed to update comment');
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function deleteComment(commentId, taskId) {
|
|
741
|
+
if (!confirm('Delete this comment?')) return;
|
|
742
|
+
try {
|
|
743
|
+
const res = await fetch('/api/comments/' + commentId, { method: 'DELETE' });
|
|
744
|
+
if (!res.ok) throw new Error('Server error');
|
|
745
|
+
await loadComments(taskId);
|
|
746
|
+
} catch {
|
|
747
|
+
showToast('Failed to delete comment');
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function submitComment(taskId) {
|
|
752
|
+
const textarea = document.getElementById('add-comment-text');
|
|
753
|
+
if (!textarea) return;
|
|
754
|
+
const content = textarea.value.trim();
|
|
755
|
+
if (!content) { textarea.focus(); return; }
|
|
756
|
+
try {
|
|
757
|
+
const res = await fetch('/api/tasks/' + taskId + '/comments', {
|
|
758
|
+
method: 'POST',
|
|
759
|
+
headers: { 'Content-Type': 'application/json' },
|
|
760
|
+
body: JSON.stringify({ content })
|
|
761
|
+
});
|
|
762
|
+
if (!res.ok) throw new Error('Server error');
|
|
763
|
+
await loadComments(taskId);
|
|
764
|
+
} catch {
|
|
765
|
+
showToast('Failed to add comment');
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
document.getElementById('detail-save-btn').addEventListener('click', async () => {
|
|
770
|
+
if (detailTaskId === null) return;
|
|
771
|
+
const titleInput = document.getElementById('detail-edit-title');
|
|
772
|
+
const title = titleInput ? titleInput.value.trim() : '';
|
|
773
|
+
if (!title) { if (titleInput) titleInput.focus(); return; }
|
|
774
|
+
const bodyEl = document.getElementById('detail-edit-body');
|
|
775
|
+
const statusEl = document.getElementById('detail-edit-status');
|
|
776
|
+
const priorityEl = document.getElementById('detail-edit-priority');
|
|
777
|
+
|
|
778
|
+
try {
|
|
779
|
+
const res = await fetch('/api/tasks/' + detailTaskId, {
|
|
780
|
+
method: 'PATCH',
|
|
781
|
+
headers: { 'Content-Type': 'application/json' },
|
|
782
|
+
body: JSON.stringify({
|
|
783
|
+
title,
|
|
784
|
+
body: bodyEl ? (bodyEl.value.trim() || null) : null,
|
|
785
|
+
status: statusEl ? statusEl.value : undefined,
|
|
786
|
+
priority: priorityEl ? (priorityEl.value || null) : null
|
|
787
|
+
})
|
|
788
|
+
});
|
|
789
|
+
if (!res.ok) throw new Error('Server error');
|
|
790
|
+
// Fetch updated task data and refresh detail panel instead of reloading
|
|
791
|
+
const getRes = await fetch('/api/tasks/' + detailTaskId);
|
|
792
|
+
if (!getRes.ok) throw new Error('Failed to fetch updated task');
|
|
793
|
+
const data = await getRes.json();
|
|
794
|
+
renderDetailPanel(data);
|
|
795
|
+
showToast('Task saved successfully');
|
|
796
|
+
// Update lastUpdatedAt so polling doesn't treat our own save as an external update
|
|
797
|
+
try {
|
|
798
|
+
const tsRes = await fetch('/api/board/updated-at');
|
|
799
|
+
if (tsRes.ok) {
|
|
800
|
+
const tsData = await tsRes.json();
|
|
801
|
+
lastUpdatedAt = tsData.updatedAt;
|
|
802
|
+
}
|
|
803
|
+
} catch {
|
|
804
|
+
// Ignore errors when syncing timestamp
|
|
805
|
+
}
|
|
806
|
+
// Refresh board cards in the background
|
|
807
|
+
refreshBoardCards();
|
|
808
|
+
} catch {
|
|
809
|
+
showToast('Failed to update task');
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
document.querySelectorAll('.card').forEach(card => {
|
|
814
|
+
card.addEventListener('click', async e => {
|
|
815
|
+
if (e.defaultPrevented) return;
|
|
816
|
+
const taskId = card.dataset.id;
|
|
817
|
+
try {
|
|
818
|
+
const res = await fetch('/api/tasks/' + taskId);
|
|
819
|
+
if (!res.ok) throw new Error('Server error');
|
|
820
|
+
const data = await res.json();
|
|
821
|
+
renderDetailPanel(data);
|
|
822
|
+
if (!detailPanel.classList.contains('open')) {
|
|
823
|
+
const preferredWidth = detailPanel.dataset.preferredWidth || PANEL_DEFAULT_WIDTH;
|
|
824
|
+
detailPanel.style.width = preferredWidth + 'px';
|
|
825
|
+
detailPanel.classList.add('open');
|
|
826
|
+
}
|
|
827
|
+
} catch {
|
|
828
|
+
showToast('Failed to load task details');
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
// Filter state (defined before refreshBoardCards so it can use them)
|
|
834
|
+
let activeFilters = { tagIds: [], priorities: [], assignee: '' };
|
|
835
|
+
|
|
836
|
+
function buildFilterParams() {
|
|
837
|
+
const params = new URLSearchParams();
|
|
838
|
+
if (activeFilters.priorities.length > 0) {
|
|
839
|
+
params.set('priority', activeFilters.priorities.join(','));
|
|
840
|
+
}
|
|
841
|
+
if (activeFilters.tagIds.length > 0) {
|
|
842
|
+
params.set('tags', activeFilters.tagIds.join(','));
|
|
843
|
+
}
|
|
844
|
+
if (activeFilters.assignee) {
|
|
845
|
+
params.set('assignee', activeFilters.assignee);
|
|
846
|
+
}
|
|
847
|
+
return params;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Board polling: reload when updated_at changes (skip during drag)
|
|
851
|
+
let lastUpdatedAt = null;
|
|
852
|
+
async function refreshBoardCards() {
|
|
853
|
+
const filterParams = buildFilterParams();
|
|
854
|
+
const url = '/api/board/cards' + (filterParams.toString() ? '?' + filterParams.toString() : '');
|
|
855
|
+
try {
|
|
856
|
+
const res = await fetch(url);
|
|
857
|
+
if (!res.ok) return;
|
|
858
|
+
const data = await res.json();
|
|
859
|
+
const columns = data.columns;
|
|
860
|
+
columns.forEach(col => {
|
|
861
|
+
const body = document.getElementById('col-' + col.status);
|
|
862
|
+
if (!body) return;
|
|
863
|
+
body.innerHTML = col.html;
|
|
864
|
+
const colEl = body.closest('.column');
|
|
865
|
+
if (colEl) colEl.querySelector('.column-count').textContent = col.count;
|
|
866
|
+
// Re-attach drag event listeners to new cards
|
|
867
|
+
body.querySelectorAll('.card').forEach(card => {
|
|
868
|
+
card.addEventListener('dragstart', e => {
|
|
869
|
+
draggedCard = card;
|
|
870
|
+
sourceBody = card.parentElement;
|
|
871
|
+
card.classList.add('dragging');
|
|
872
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
873
|
+
});
|
|
874
|
+
card.addEventListener('dragend', () => {
|
|
875
|
+
card.classList.remove('dragging');
|
|
876
|
+
draggedCard = null;
|
|
877
|
+
sourceBody = null;
|
|
878
|
+
});
|
|
879
|
+
card.addEventListener('click', async e => {
|
|
880
|
+
if (e.defaultPrevented) return;
|
|
881
|
+
const taskId = card.dataset.id;
|
|
882
|
+
try {
|
|
883
|
+
const res = await fetch('/api/tasks/' + taskId);
|
|
884
|
+
if (!res.ok) throw new Error('Server error');
|
|
885
|
+
const data = await res.json();
|
|
886
|
+
renderDetailPanel(data);
|
|
887
|
+
if (!detailPanel.classList.contains('open')) {
|
|
888
|
+
const preferredWidth = detailPanel.dataset.preferredWidth || PANEL_DEFAULT_WIDTH;
|
|
889
|
+
detailPanel.style.width = preferredWidth + 'px';
|
|
890
|
+
detailPanel.classList.add('open');
|
|
891
|
+
}
|
|
892
|
+
} catch {
|
|
893
|
+
showToast('Failed to load task details');
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
// If detail panel is open, refresh its content if the task was updated
|
|
899
|
+
if (detailTaskId !== null) {
|
|
900
|
+
const editableFields = ['detail-edit-title', 'detail-edit-body', 'detail-edit-status', 'detail-edit-priority'];
|
|
901
|
+
const isEditing = editableFields.some(id => document.activeElement && document.activeElement.id === id);
|
|
902
|
+
if (isEditing) {
|
|
903
|
+
const warning = document.getElementById('detail-panel-update-warning');
|
|
904
|
+
if (!warning) {
|
|
905
|
+
const warningEl = document.createElement('div');
|
|
906
|
+
warningEl.id = 'detail-panel-update-warning';
|
|
907
|
+
warningEl.style.cssText = 'display: flex; align-items: center; gap: 8px; color: red; font-size: 0.85em; padding: 4px 8px; background: #fff0f0; border: 1px solid #ffcccc; border-radius: 4px; margin-bottom: 8px;';
|
|
908
|
+
const msgSpan = document.createElement('span');
|
|
909
|
+
msgSpan.style.cssText = 'flex: 1;';
|
|
910
|
+
msgSpan.textContent = 'This task has been updated in the database. Save or discard your changes to see the latest version.';
|
|
911
|
+
const reloadBtn = document.createElement('button');
|
|
912
|
+
reloadBtn.title = 'Reload latest data';
|
|
913
|
+
reloadBtn.textContent = '↺';
|
|
914
|
+
reloadBtn.style.cssText = 'background: none; border: none; cursor: pointer; font-size: 1.1em; color: red; padding: 0 2px; line-height: 1; flex-shrink: 0;';
|
|
915
|
+
reloadBtn.addEventListener('click', async () => {
|
|
916
|
+
try {
|
|
917
|
+
const taskRes = await fetch('/api/tasks/' + detailTaskId);
|
|
918
|
+
if (taskRes.ok) {
|
|
919
|
+
const taskData = await taskRes.json();
|
|
920
|
+
renderDetailPanel(taskData);
|
|
921
|
+
}
|
|
922
|
+
} catch {
|
|
923
|
+
// Ignore network errors
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
warningEl.appendChild(msgSpan);
|
|
927
|
+
warningEl.appendChild(reloadBtn);
|
|
928
|
+
detailPanelBody.insertBefore(warningEl, detailPanelBody.firstChild);
|
|
929
|
+
}
|
|
930
|
+
} else {
|
|
931
|
+
try {
|
|
932
|
+
const taskRes = await fetch('/api/tasks/' + detailTaskId);
|
|
933
|
+
if (taskRes.ok) {
|
|
934
|
+
const taskData = await taskRes.json();
|
|
935
|
+
renderDetailPanel(taskData);
|
|
936
|
+
}
|
|
937
|
+
} catch {
|
|
938
|
+
// Ignore network errors during detail panel refresh
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
} catch {
|
|
943
|
+
// Ignore network errors during card refresh
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
async function pollBoardUpdates() {
|
|
947
|
+
if (draggedCard !== null) return;
|
|
948
|
+
try {
|
|
949
|
+
const res = await fetch('/api/board/updated-at');
|
|
950
|
+
if (!res.ok) return;
|
|
951
|
+
const data = await res.json();
|
|
952
|
+
const ts = data.updatedAt;
|
|
953
|
+
if (lastUpdatedAt === null) {
|
|
954
|
+
lastUpdatedAt = ts;
|
|
955
|
+
} else if (ts !== lastUpdatedAt) {
|
|
956
|
+
lastUpdatedAt = ts;
|
|
957
|
+
if (detailPanel.classList.contains('open')) {
|
|
958
|
+
await refreshBoardCards();
|
|
959
|
+
} else {
|
|
960
|
+
location.reload();
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
} catch {
|
|
964
|
+
// Ignore network errors during polling
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
setInterval(pollBoardUpdates, 5000);
|
|
968
|
+
pollBoardUpdates();
|
|
969
|
+
|
|
970
|
+
function isFiltersActive() {
|
|
971
|
+
return activeFilters.priorities.length > 0 || activeFilters.tagIds.length > 0 || activeFilters.assignee !== '';
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function applyFilters() {
|
|
975
|
+
const clearBtn = document.getElementById('filter-clear');
|
|
976
|
+
if (clearBtn) {
|
|
977
|
+
if (isFiltersActive()) {
|
|
978
|
+
clearBtn.classList.add('visible');
|
|
979
|
+
} else {
|
|
980
|
+
clearBtn.classList.remove('visible');
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
refreshBoardCards();
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function renderFilterTagPills() {
|
|
987
|
+
const container = document.getElementById('filter-tags-control');
|
|
988
|
+
if (!container) return;
|
|
989
|
+
// Remove existing pills
|
|
990
|
+
container.querySelectorAll('.filter-tag-pill').forEach(p => p.remove());
|
|
991
|
+
// Add pills for active tag filters
|
|
992
|
+
activeFilters.tagIds.forEach(tagId => {
|
|
993
|
+
const tag = allAvailableTags.find(t => t.id === tagId);
|
|
994
|
+
if (!tag) return;
|
|
995
|
+
const pill = document.createElement('span');
|
|
996
|
+
pill.className = 'filter-tag-pill';
|
|
997
|
+
const label = document.createTextNode(tag.name);
|
|
998
|
+
const removeBtn = document.createElement('button');
|
|
999
|
+
removeBtn.className = 'filter-tag-pill-remove';
|
|
1000
|
+
removeBtn.title = 'Remove tag filter';
|
|
1001
|
+
removeBtn.innerHTML = '×';
|
|
1002
|
+
removeBtn.addEventListener('click', () => {
|
|
1003
|
+
const idx = activeFilters.tagIds.indexOf(tagId);
|
|
1004
|
+
if (idx !== -1) activeFilters.tagIds.splice(idx, 1);
|
|
1005
|
+
renderFilterTagPills();
|
|
1006
|
+
applyFilters();
|
|
1007
|
+
});
|
|
1008
|
+
pill.appendChild(label);
|
|
1009
|
+
pill.appendChild(removeBtn);
|
|
1010
|
+
container.insertBefore(pill, container.querySelector('.filter-tag-dropdown-wrapper'));
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function initFilterBar() {
|
|
1015
|
+
// Priority toggle buttons
|
|
1016
|
+
document.querySelectorAll('.filter-priority-btn').forEach(btn => {
|
|
1017
|
+
btn.addEventListener('click', () => {
|
|
1018
|
+
const priority = btn.dataset.priority;
|
|
1019
|
+
const idx = activeFilters.priorities.indexOf(priority);
|
|
1020
|
+
if (idx === -1) {
|
|
1021
|
+
activeFilters.priorities.push(priority);
|
|
1022
|
+
btn.classList.add('active');
|
|
1023
|
+
} else {
|
|
1024
|
+
activeFilters.priorities.splice(idx, 1);
|
|
1025
|
+
btn.classList.remove('active');
|
|
1026
|
+
}
|
|
1027
|
+
applyFilters();
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
// Assignee input with debounce
|
|
1032
|
+
const assigneeInput = document.getElementById('filter-assignee');
|
|
1033
|
+
let assigneeTimer = null;
|
|
1034
|
+
if (assigneeInput) {
|
|
1035
|
+
assigneeInput.addEventListener('input', () => {
|
|
1036
|
+
clearTimeout(assigneeTimer);
|
|
1037
|
+
assigneeTimer = setTimeout(() => {
|
|
1038
|
+
activeFilters.assignee = assigneeInput.value.trim();
|
|
1039
|
+
applyFilters();
|
|
1040
|
+
}, 300);
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Clear button
|
|
1045
|
+
const clearBtn = document.getElementById('filter-clear');
|
|
1046
|
+
if (clearBtn) {
|
|
1047
|
+
clearBtn.addEventListener('click', () => {
|
|
1048
|
+
activeFilters.tagIds = [];
|
|
1049
|
+
activeFilters.priorities = [];
|
|
1050
|
+
activeFilters.assignee = '';
|
|
1051
|
+
document.querySelectorAll('.filter-priority-btn').forEach(btn => btn.classList.remove('active'));
|
|
1052
|
+
if (assigneeInput) assigneeInput.value = '';
|
|
1053
|
+
renderFilterTagPills();
|
|
1054
|
+
applyFilters();
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Tag filter dropdown
|
|
1059
|
+
const tagsControl = document.getElementById('filter-tags-control');
|
|
1060
|
+
if (tagsControl) {
|
|
1061
|
+
const dropdownWrapper = document.createElement('div');
|
|
1062
|
+
dropdownWrapper.className = 'filter-tag-dropdown-wrapper';
|
|
1063
|
+
|
|
1064
|
+
const addBtn = document.createElement('button');
|
|
1065
|
+
addBtn.className = 'filter-tag-add-btn';
|
|
1066
|
+
addBtn.textContent = '+ Tag';
|
|
1067
|
+
|
|
1068
|
+
const dropdown = document.createElement('div');
|
|
1069
|
+
dropdown.className = 'filter-tag-dropdown';
|
|
1070
|
+
|
|
1071
|
+
dropdownWrapper.appendChild(addBtn);
|
|
1072
|
+
dropdownWrapper.appendChild(dropdown);
|
|
1073
|
+
tagsControl.appendChild(dropdownWrapper);
|
|
1074
|
+
|
|
1075
|
+
function renderTagDropdown() {
|
|
1076
|
+
dropdown.innerHTML = '';
|
|
1077
|
+
const available = allAvailableTags.filter(t => !activeFilters.tagIds.includes(t.id));
|
|
1078
|
+
if (available.length === 0) {
|
|
1079
|
+
const empty = document.createElement('div');
|
|
1080
|
+
empty.className = 'filter-tag-dropdown-empty';
|
|
1081
|
+
empty.textContent = 'No tags available';
|
|
1082
|
+
dropdown.appendChild(empty);
|
|
1083
|
+
} else {
|
|
1084
|
+
available.forEach(tag => {
|
|
1085
|
+
const opt = document.createElement('div');
|
|
1086
|
+
opt.className = 'filter-tag-dropdown-option';
|
|
1087
|
+
opt.textContent = tag.name;
|
|
1088
|
+
opt.addEventListener('mousedown', (e) => {
|
|
1089
|
+
e.preventDefault();
|
|
1090
|
+
activeFilters.tagIds.push(tag.id);
|
|
1091
|
+
dropdown.classList.remove('open');
|
|
1092
|
+
renderFilterTagPills();
|
|
1093
|
+
applyFilters();
|
|
1094
|
+
});
|
|
1095
|
+
dropdown.appendChild(opt);
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
addBtn.addEventListener('click', () => {
|
|
1101
|
+
if (dropdown.classList.contains('open')) {
|
|
1102
|
+
dropdown.classList.remove('open');
|
|
1103
|
+
} else {
|
|
1104
|
+
renderTagDropdown();
|
|
1105
|
+
const rect = addBtn.getBoundingClientRect();
|
|
1106
|
+
dropdown.style.top = (rect.bottom + 2) + 'px';
|
|
1107
|
+
dropdown.style.left = rect.left + 'px';
|
|
1108
|
+
dropdown.classList.add('open');
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
document.addEventListener('click', (e) => {
|
|
1113
|
+
if (!dropdownWrapper.contains(e.target)) {
|
|
1114
|
+
dropdown.classList.remove('open');
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Initialize filter bar after tags are loaded
|
|
1121
|
+
loadAllTags().then(() => {
|
|
1122
|
+
initFilterBar();
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
// Burger menu
|
|
1126
|
+
const burgerBtn = document.getElementById('burger-menu-btn');
|
|
1127
|
+
const burgerDropdown = document.getElementById('burger-menu-dropdown');
|
|
1128
|
+
|
|
1129
|
+
burgerBtn.addEventListener('click', (e) => {
|
|
1130
|
+
e.stopPropagation();
|
|
1131
|
+
burgerDropdown.classList.toggle('open');
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
document.addEventListener('click', (e) => {
|
|
1135
|
+
if (!burgerDropdown.contains(e.target) && e.target !== burgerBtn) {
|
|
1136
|
+
burgerDropdown.classList.remove('open');
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
// Purge tasks
|
|
1141
|
+
const purgeModal = document.getElementById('purge-confirm-modal');
|
|
1142
|
+
const purgeConfirmBtn = document.getElementById('purge-confirm-btn');
|
|
1143
|
+
const purgeCancelBtn = document.getElementById('purge-cancel-btn');
|
|
1144
|
+
const purgeResultEl = document.getElementById('purge-result');
|
|
1145
|
+
|
|
1146
|
+
document.getElementById('burger-purge-tasks').addEventListener('click', () => {
|
|
1147
|
+
burgerDropdown.classList.remove('open');
|
|
1148
|
+
purgeResultEl.textContent = '';
|
|
1149
|
+
purgeModal.classList.add('show');
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
purgeCancelBtn.addEventListener('click', () => {
|
|
1153
|
+
purgeModal.classList.remove('show');
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
purgeConfirmBtn.addEventListener('click', async () => {
|
|
1157
|
+
purgeConfirmBtn.disabled = true;
|
|
1158
|
+
purgeConfirmBtn.textContent = 'Purging...';
|
|
1159
|
+
try {
|
|
1160
|
+
const res = await fetch('/api/tasks/purge', {
|
|
1161
|
+
method: 'POST',
|
|
1162
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1163
|
+
body: JSON.stringify({})
|
|
1164
|
+
});
|
|
1165
|
+
const data = await res.json();
|
|
1166
|
+
if (res.ok) {
|
|
1167
|
+
purgeResultEl.textContent = 'Purged ' + data.count + ' task(s).';
|
|
1168
|
+
setTimeout(() => { purgeModal.classList.remove('show'); }, 1500);
|
|
1169
|
+
location.reload();
|
|
1170
|
+
} else {
|
|
1171
|
+
purgeResultEl.textContent = 'Error: ' + (data.error || 'Unknown error');
|
|
1172
|
+
}
|
|
1173
|
+
} catch {
|
|
1174
|
+
purgeResultEl.textContent = 'Failed to purge tasks.';
|
|
1175
|
+
} finally {
|
|
1176
|
+
purgeConfirmBtn.disabled = false;
|
|
1177
|
+
purgeConfirmBtn.textContent = 'Purge';
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
// Version info
|
|
1182
|
+
const versionModal = document.getElementById('version-info-modal');
|
|
1183
|
+
const versionCloseBtn = document.getElementById('version-info-close');
|
|
1184
|
+
const versionTextEl = document.getElementById('version-info-text');
|
|
1185
|
+
|
|
1186
|
+
document.getElementById('burger-version-info').addEventListener('click', async () => {
|
|
1187
|
+
burgerDropdown.classList.remove('open');
|
|
1188
|
+
versionTextEl.textContent = 'Loading...';
|
|
1189
|
+
versionModal.classList.add('show');
|
|
1190
|
+
try {
|
|
1191
|
+
const res = await fetch('/api/version');
|
|
1192
|
+
const data = await res.json();
|
|
1193
|
+
versionTextEl.textContent = 'agkan v' + data.version;
|
|
1194
|
+
} catch {
|
|
1195
|
+
versionTextEl.textContent = 'Failed to load version.';
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
versionCloseBtn.addEventListener('click', () => {
|
|
1200
|
+
versionModal.classList.remove('show');
|
|
1201
|
+
});`;
|
|
1202
|
+
//# sourceMappingURL=boardScript.js.map
|