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.
Files changed (39) hide show
  1. package/README.ja.md +1 -1
  2. package/README.md +1 -1
  3. package/dist/board/boardRenderer.d.ts +18 -0
  4. package/dist/board/boardRenderer.d.ts.map +1 -0
  5. package/dist/board/boardRenderer.js +273 -0
  6. package/dist/board/boardRenderer.js.map +1 -0
  7. package/dist/board/boardRoutes.d.ts +23 -0
  8. package/dist/board/boardRoutes.d.ts.map +1 -0
  9. package/dist/board/boardRoutes.js +273 -0
  10. package/dist/board/boardRoutes.js.map +1 -0
  11. package/dist/board/boardScript.d.ts +2 -0
  12. package/dist/board/boardScript.d.ts.map +1 -0
  13. package/dist/board/boardScript.js +1202 -0
  14. package/dist/board/boardScript.js.map +1 -0
  15. package/dist/board/boardStyles.d.ts +2 -0
  16. package/dist/board/boardStyles.d.ts.map +1 -0
  17. package/dist/board/boardStyles.js +171 -0
  18. package/dist/board/boardStyles.js.map +1 -0
  19. package/dist/board/client/board.js +1165 -0
  20. package/dist/board/server.d.ts.map +1 -1
  21. package/dist/board/server.js +7 -1712
  22. package/dist/board/server.js.map +1 -1
  23. package/dist/db/adapters/sqlite-adapter.js +2 -0
  24. package/dist/db/adapters/sqlite-adapter.js.map +1 -1
  25. package/dist/db/connection.js +2 -2
  26. package/dist/db/connection.js.map +1 -1
  27. package/dist/services/CommentService.js +1 -0
  28. package/dist/services/CommentService.js.map +1 -1
  29. package/dist/services/MetadataService.js +1 -0
  30. package/dist/services/MetadataService.js.map +1 -1
  31. package/dist/services/TagService.js +1 -0
  32. package/dist/services/TagService.js.map +1 -1
  33. package/dist/services/TaskBlockService.js +2 -0
  34. package/dist/services/TaskBlockService.js.map +1 -1
  35. package/dist/services/TaskService.js +1 -0
  36. package/dist/services/TaskService.js.map +1 -1
  37. package/dist/services/TaskTagService.js +3 -0
  38. package/dist/services/TaskTagService.js.map +1 -1
  39. 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">&times;</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 = '&times;';
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) + ' &middot; 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 + ')">&#9998;</button>';
668
+ html += '<button class="comment-action-btn danger" title="Delete" onclick="deleteComment(' + comment.id + ',' + taskId + ')">&#128465;</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 = '&times;';
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