agkan 2.10.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +1160 -0
- package/dist/board/server.d.ts +3 -1
- package/dist/board/server.d.ts.map +1 -1
- package/dist/board/server.js +14 -1301
- package/dist/board/server.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +31 -4
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/task/update.d.ts.map +1 -1
- package/dist/cli/commands/task/update.js +1 -3
- package/dist/cli/commands/task/update.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.d.ts +7 -0
- package/dist/services/CommentService.d.ts.map +1 -1
- package/dist/services/CommentService.js +25 -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 +8 -5
package/dist/board/server.js
CHANGED
|
@@ -12,1323 +12,36 @@ const TaskService_1 = require("../services/TaskService");
|
|
|
12
12
|
const TaskTagService_1 = require("../services/TaskTagService");
|
|
13
13
|
const TagService_1 = require("../services/TagService");
|
|
14
14
|
const MetadataService_1 = require("../services/MetadataService");
|
|
15
|
-
const
|
|
15
|
+
const CommentService_1 = require("../services/CommentService");
|
|
16
|
+
const TaskBlockService_1 = require("../services/TaskBlockService");
|
|
16
17
|
const connection_1 = require("../db/connection");
|
|
17
18
|
const config_1 = require("../db/config");
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const STATUS_LABELS = {
|
|
21
|
-
icebox: 'Icebox',
|
|
22
|
-
backlog: 'Backlog',
|
|
23
|
-
ready: 'Ready',
|
|
24
|
-
in_progress: 'In Progress',
|
|
25
|
-
review: 'Review',
|
|
26
|
-
done: 'Done',
|
|
27
|
-
closed: 'Closed',
|
|
28
|
-
};
|
|
29
|
-
const STATUS_COLORS = {
|
|
30
|
-
icebox: '#6b7280',
|
|
31
|
-
backlog: '#3b82f6',
|
|
32
|
-
ready: '#8b5cf6',
|
|
33
|
-
in_progress: '#f97316',
|
|
34
|
-
review: '#eab308',
|
|
35
|
-
done: '#22c55e',
|
|
36
|
-
closed: '#374151',
|
|
37
|
-
};
|
|
38
|
-
function escapeHtml(text) {
|
|
39
|
-
return text
|
|
40
|
-
.replace(/&/g, '&')
|
|
41
|
-
.replace(/</g, '<')
|
|
42
|
-
.replace(/>/g, '>')
|
|
43
|
-
.replace(/"/g, '"')
|
|
44
|
-
.replace(/'/g, ''');
|
|
45
|
-
}
|
|
46
|
-
function renderCard(task, tags) {
|
|
47
|
-
const priority = task.priority;
|
|
48
|
-
const priorityBadge = priority
|
|
49
|
-
? `<span class="priority priority-${escapeHtml(priority)}">${escapeHtml(priority)}</span>`
|
|
50
|
-
: '';
|
|
51
|
-
const tagBadges = tags.map((t) => `<span class="tag">${escapeHtml(t.name)}</span>`).join('');
|
|
52
|
-
return `
|
|
53
|
-
<div class="card" draggable="true" data-id="${task.id}" data-status="${task.status}">
|
|
54
|
-
<div class="card-header">
|
|
55
|
-
<span class="card-id">#${task.id}</span>
|
|
56
|
-
${priorityBadge}
|
|
57
|
-
</div>
|
|
58
|
-
<div class="card-title">${escapeHtml(task.title)}</div>
|
|
59
|
-
${tagBadges ? `<div class="card-tags">${tagBadges}</div>` : ''}
|
|
60
|
-
</div>`;
|
|
61
|
-
}
|
|
62
|
-
const BOARD_STYLES = `
|
|
63
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
64
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; }
|
|
65
|
-
header { background: #1e293b; color: white; padding: 12px 20px; display: flex; align-items: center; justify-content: space-between; }
|
|
66
|
-
header h1 { font-size: 18px; font-weight: 700; }
|
|
67
|
-
.board-title { font-size: 14px; font-weight: 400; opacity: 0.75; }
|
|
68
|
-
.board-container { display: flex; width: 100%; height: calc(100vh - 92px); gap: 0; }
|
|
69
|
-
.board { display: flex; gap: 12px; padding: 16px; overflow-x: auto; flex: 1; align-items: flex-start; min-width: 0; }
|
|
70
|
-
.board.with-panel { padding-right: 0; }
|
|
71
|
-
.column { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; width: 240px; flex-shrink: 0; display: flex; flex-direction: column; border-top: 3px solid transparent; }
|
|
72
|
-
.column-header { padding: 10px 12px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e2e8f0; }
|
|
73
|
-
.column-title { font-size: 13px; font-weight: 700; }
|
|
74
|
-
.column-header-right { display: flex; align-items: center; gap: 6px; }
|
|
75
|
-
.column-count { background: #e2e8f0; color: #64748b; border-radius: 10px; font-size: 11px; font-weight: 600; padding: 2px 7px; }
|
|
76
|
-
.add-btn { background: none; border: 1px solid #cbd5e1; color: #64748b; border-radius: 4px; width: 22px; height: 22px; font-size: 14px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
|
77
|
-
.add-btn:hover { background: #e2e8f0; color: #1e293b; }
|
|
78
|
-
.column-body { padding: 8px; min-height: 60px; }
|
|
79
|
-
.column.drag-over .column-body { background: #eff6ff; border-radius: 6px; }
|
|
80
|
-
.card { background: white; border: 1px solid #e2e8f0; border-radius: 6px; padding: 10px; margin-bottom: 6px; cursor: grab; transition: box-shadow 0.15s; }
|
|
81
|
-
.card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
|
82
|
-
.card.dragging { opacity: 0.5; }
|
|
83
|
-
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 5px; }
|
|
84
|
-
.card-id { font-size: 11px; color: #94a3b8; font-weight: 600; }
|
|
85
|
-
.card-title { font-size: 13px; font-weight: 500; line-height: 1.4; }
|
|
86
|
-
.card-tags { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
|
|
87
|
-
.tag { background: #e0f2fe; color: #0369a1; font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 10px; }
|
|
88
|
-
.priority { font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 10px; text-transform: uppercase; }
|
|
89
|
-
.priority-critical { background: #fee2e2; color: #dc2626; }
|
|
90
|
-
.priority-high { background: #fee2e2; color: #dc2626; }
|
|
91
|
-
.priority-medium { background: #fef9c3; color: #ca8a04; }
|
|
92
|
-
.priority-low { background: #dcfce7; color: #16a34a; }
|
|
93
|
-
.context-menu { position: fixed; background: white; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); padding: 4px 0; z-index: 1000; display: none; min-width: 140px; }
|
|
94
|
-
.context-menu-item { padding: 8px 14px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 8px; }
|
|
95
|
-
.context-menu-item:hover { background: #f1f5f9; }
|
|
96
|
-
.context-menu-item.danger { color: #dc2626; }
|
|
97
|
-
.context-menu-item.danger:hover { background: #fef2f2; }
|
|
98
|
-
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 2000; display: none; align-items: center; justify-content: center; }
|
|
99
|
-
.modal-overlay.show { display: flex; }
|
|
100
|
-
.modal { background: white; border-radius: 8px; padding: 20px; width: 360px; box-shadow: 0 8px 24px rgba(0,0,0,0.2); }
|
|
101
|
-
.modal h2 { font-size: 16px; margin-bottom: 14px; }
|
|
102
|
-
.modal label { display: block; font-size: 12px; font-weight: 600; color: #64748b; margin-bottom: 4px; }
|
|
103
|
-
.modal input, .modal textarea, .modal select { width: 100%; border: 1px solid #e2e8f0; border-radius: 6px; padding: 8px 10px; font-size: 13px; font-family: inherit; margin-bottom: 12px; background: white; }
|
|
104
|
-
.modal textarea { resize: vertical; min-height: 60px; }
|
|
105
|
-
.modal input:focus, .modal textarea:focus, .modal select:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
106
|
-
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
107
|
-
.modal-actions button { padding: 6px 14px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: 1px solid #e2e8f0; background: white; color: #64748b; }
|
|
108
|
-
.modal-actions button:hover { background: #f1f5f9; }
|
|
109
|
-
.modal-actions button.primary { background: #3b82f6; color: white; border-color: #3b82f6; }
|
|
110
|
-
.modal-actions button.primary:hover { background: #2563eb; }
|
|
111
|
-
.toast { position: fixed; bottom: 20px; right: 20px; background: #ef4444; color: white; padding: 10px 16px; border-radius: 6px; font-size: 13px; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
|
|
112
|
-
.toast.show { opacity: 1; }
|
|
113
|
-
.detail-panel { position: relative; width: 0; height: calc(100vh - 92px); background: white; box-shadow: none; border-left: 0 solid #e2e8f0; display: flex; flex-direction: column; max-width: 800px; overflow: hidden; transition: width 0.25s ease; }
|
|
114
|
-
.detail-panel-resize-handle { position: absolute; top: 0; left: 0; width: 6px; height: 100%; cursor: col-resize; z-index: 10; background: transparent; }
|
|
115
|
-
.detail-panel-resize-handle:hover, .detail-panel-resize-handle.dragging { background: rgba(59,130,246,0.3); }
|
|
116
|
-
.detail-panel.open { width: 400px; min-width: 280px; border-left-width: 1px; }
|
|
117
|
-
.detail-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid #e2e8f0; flex-shrink: 0; }
|
|
118
|
-
.detail-panel-header h2 { font-size: 16px; font-weight: 700; color: #1e293b; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
119
|
-
.detail-panel-close { background: none; border: none; font-size: 20px; color: #64748b; cursor: pointer; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 4px; flex-shrink: 0; }
|
|
120
|
-
.detail-panel-close:hover { background: #f1f5f9; color: #1e293b; }
|
|
121
|
-
.detail-panel-body { flex: 1; overflow-y: auto; padding: 20px; min-width: 0; display: flex; flex-direction: column; }
|
|
122
|
-
.detail-field { margin-bottom: 16px; word-wrap: break-word; }
|
|
123
|
-
.description-field-wrapper { flex: 1; display: flex; flex-direction: column; min-height: 0; margin-bottom: 0; }
|
|
124
|
-
.detail-field-label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: #94a3b8; margin-bottom: 4px; letter-spacing: 0.05em; }
|
|
125
|
-
.detail-field-value { font-size: 13px; color: #1e293b; line-height: 1.5; }
|
|
126
|
-
.detail-field-value.empty { color: #94a3b8; font-style: italic; }
|
|
127
|
-
.detail-status-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; color: white; }
|
|
128
|
-
.detail-tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
129
|
-
.detail-meta-table { width: 100%; border-collapse: collapse; }
|
|
130
|
-
.detail-meta-table td { padding: 4px 0; font-size: 12px; }
|
|
131
|
-
.detail-meta-table td:first-child { color: #64748b; width: 100px; }
|
|
132
|
-
.detail-meta-table td:last-child { color: #1e293b; }
|
|
133
|
-
.detail-panel-footer { padding: 12px 20px; border-top: 1px solid #e2e8f0; display: flex; justify-content: flex-end; flex-shrink: 0; }
|
|
134
|
-
.detail-panel-footer button { padding: 7px 18px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: 1px solid #3b82f6; background: #3b82f6; color: white; }
|
|
135
|
-
.detail-panel-footer button:hover { background: #2563eb; border-color: #2563eb; }
|
|
136
|
-
.detail-edit-input { width: 100%; border: 1px solid #e2e8f0; border-radius: 6px; padding: 7px 10px; font-size: 13px; font-family: inherit; background: white; color: #1e293b; }
|
|
137
|
-
.detail-edit-input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
138
|
-
.detail-edit-textarea { width: 100%; border: 1px solid #e2e8f0; border-radius: 6px; padding: 7px 10px; font-size: 13px; font-family: inherit; resize: vertical; min-height: 240px; background: white; color: #1e293b; }
|
|
139
|
-
.detail-edit-textarea:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
140
|
-
.description-field-wrapper .detail-edit-textarea { flex: 1; resize: none; min-height: 0; }
|
|
141
|
-
.detail-edit-select { width: 100%; border: 1px solid #e2e8f0; border-radius: 6px; padding: 7px 10px; font-size: 13px; font-family: inherit; background: white; color: #1e293b; }
|
|
142
|
-
.detail-edit-select:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
143
|
-
.tag-select-wrapper { position: relative; }
|
|
144
|
-
.tag-select-control { border: 1px solid #e2e8f0; border-radius: 6px; padding: 4px 8px; min-height: 36px; cursor: text; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; background: white; }
|
|
145
|
-
.tag-select-control:focus-within { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
146
|
-
.tag-pill { background: #e0f2fe; color: #0369a1; font-size: 11px; font-weight: 600; padding: 2px 4px 2px 8px; border-radius: 10px; display: inline-flex; align-items: center; gap: 3px; }
|
|
147
|
-
.tag-pill-remove { background: none; border: none; color: #0369a1; cursor: pointer; font-size: 13px; line-height: 1; padding: 0 2px; display: inline-flex; align-items: center; border-radius: 50%; }
|
|
148
|
-
.tag-pill-remove:hover { color: #dc2626; background: rgba(220,38,38,0.1); }
|
|
149
|
-
.tag-select-input { border: none; outline: none; font-size: 12px; font-family: inherit; min-width: 80px; flex: 1; background: transparent; padding: 2px 0; color: #1e293b; }
|
|
150
|
-
.tag-select-input::placeholder { color: #94a3b8; }
|
|
151
|
-
.tag-select-dropdown { position: absolute; top: calc(100% + 2px); left: 0; right: 0; background: white; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 100; max-height: 180px; overflow-y: auto; display: none; }
|
|
152
|
-
.tag-select-dropdown.open { display: block; }
|
|
153
|
-
.tag-select-option { padding: 6px 10px; font-size: 12px; cursor: pointer; color: #1e293b; }
|
|
154
|
-
.tag-select-option:hover, .tag-select-option.focused { background: #eff6ff; color: #0369a1; }
|
|
155
|
-
.tag-select-no-options { padding: 6px 10px; font-size: 12px; color: #94a3b8; font-style: italic; }
|
|
156
|
-
.filter-bar { display: flex; align-items: center; gap: 16px; height: 44px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; padding: 0 16px; flex-shrink: 0; overflow-x: auto; }
|
|
157
|
-
.filter-group { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
|
158
|
-
.filter-label { font-size: 10px; font-weight: 700; text-transform: uppercase; color: #94a3b8; letter-spacing: 0.05em; white-space: nowrap; }
|
|
159
|
-
.filter-priority-btn { border: 1px solid #e2e8f0; background: white; border-radius: 4px; padding: 2px 8px; font-size: 11px; font-weight: 600; cursor: pointer; text-transform: uppercase; color: #64748b; }
|
|
160
|
-
.filter-priority-btn:hover { background: #f1f5f9; }
|
|
161
|
-
.filter-priority-btn.active[data-priority="critical"] { background: #fee2e2; color: #dc2626; border-color: #fca5a5; }
|
|
162
|
-
.filter-priority-btn.active[data-priority="high"] { background: #fee2e2; color: #dc2626; border-color: #fca5a5; }
|
|
163
|
-
.filter-priority-btn.active[data-priority="medium"] { background: #fef9c3; color: #ca8a04; border-color: #fde047; }
|
|
164
|
-
.filter-priority-btn.active[data-priority="low"] { background: #dcfce7; color: #16a34a; border-color: #86efac; }
|
|
165
|
-
.filter-assignee-input { border: 1px solid #e2e8f0; border-radius: 4px; padding: 3px 8px; font-size: 12px; font-family: inherit; background: white; color: #1e293b; width: 120px; }
|
|
166
|
-
.filter-assignee-input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
|
167
|
-
.filter-tag-pill { background: #e0f2fe; color: #0369a1; font-size: 11px; font-weight: 600; padding: 2px 4px 2px 8px; border-radius: 10px; display: inline-flex; align-items: center; gap: 3px; flex-shrink: 0; }
|
|
168
|
-
.filter-tag-pill-remove { background: none; border: none; color: #0369a1; cursor: pointer; font-size: 13px; line-height: 1; padding: 0 2px; display: inline-flex; align-items: center; border-radius: 50%; }
|
|
169
|
-
.filter-tag-pill-remove:hover { color: #dc2626; background: rgba(220,38,38,0.1); }
|
|
170
|
-
.filter-tag-dropdown-wrapper { flex-shrink: 0; }
|
|
171
|
-
.filter-tag-add-btn { border: 1px dashed #cbd5e1; background: white; border-radius: 4px; padding: 2px 8px; font-size: 11px; color: #64748b; cursor: pointer; white-space: nowrap; }
|
|
172
|
-
.filter-tag-add-btn:hover { background: #f1f5f9; border-color: #94a3b8; }
|
|
173
|
-
.filter-tag-dropdown { position: fixed; background: white; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 200; max-height: 180px; overflow-y: auto; display: none; min-width: 140px; }
|
|
174
|
-
.filter-tag-dropdown.open { display: block; }
|
|
175
|
-
.filter-tag-dropdown-option { padding: 6px 10px; font-size: 12px; cursor: pointer; color: #1e293b; white-space: nowrap; }
|
|
176
|
-
.filter-tag-dropdown-option:hover { background: #eff6ff; color: #0369a1; }
|
|
177
|
-
.filter-tag-dropdown-empty { padding: 6px 10px; font-size: 12px; color: #94a3b8; font-style: italic; }
|
|
178
|
-
.filter-clear-btn { border: 1px solid #e2e8f0; background: white; border-radius: 4px; padding: 2px 10px; font-size: 11px; font-weight: 600; cursor: pointer; color: #64748b; display: none; flex-shrink: 0; margin-left: auto; }
|
|
179
|
-
.filter-clear-btn:hover { background: #fee2e2; border-color: #fca5a5; color: #dc2626; }
|
|
180
|
-
.filter-clear-btn.visible { display: block; }`;
|
|
181
|
-
const BOARD_SCRIPT = `
|
|
182
|
-
let draggedCard = null;
|
|
183
|
-
let sourceBody = null;
|
|
184
|
-
|
|
185
|
-
document.querySelectorAll('.card').forEach(card => {
|
|
186
|
-
card.addEventListener('dragstart', e => {
|
|
187
|
-
draggedCard = card;
|
|
188
|
-
sourceBody = card.parentElement;
|
|
189
|
-
card.classList.add('dragging');
|
|
190
|
-
e.dataTransfer.effectAllowed = 'move';
|
|
191
|
-
});
|
|
192
|
-
card.addEventListener('dragend', () => {
|
|
193
|
-
card.classList.remove('dragging');
|
|
194
|
-
draggedCard = null;
|
|
195
|
-
sourceBody = null;
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
document.querySelectorAll('.column').forEach(col => {
|
|
200
|
-
col.addEventListener('dragover', e => {
|
|
201
|
-
e.preventDefault();
|
|
202
|
-
col.classList.add('drag-over');
|
|
203
|
-
});
|
|
204
|
-
col.addEventListener('dragleave', () => col.classList.remove('drag-over'));
|
|
205
|
-
col.addEventListener('drop', e => handleDrop(e, col.dataset.status, col));
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
async function handleDrop(e, newStatus, colEl) {
|
|
209
|
-
e.preventDefault();
|
|
210
|
-
colEl.classList.remove('drag-over');
|
|
211
|
-
if (!draggedCard) return;
|
|
212
|
-
const taskId = draggedCard.dataset.id;
|
|
213
|
-
const oldStatus = draggedCard.dataset.status;
|
|
214
|
-
if (oldStatus === newStatus) return;
|
|
215
|
-
|
|
216
|
-
const targetBody = document.getElementById('col-' + newStatus);
|
|
217
|
-
const prevBody = sourceBody;
|
|
218
|
-
targetBody.appendChild(draggedCard);
|
|
219
|
-
draggedCard.dataset.status = newStatus;
|
|
220
|
-
updateCount(oldStatus);
|
|
221
|
-
updateCount(newStatus);
|
|
222
|
-
|
|
223
|
-
try {
|
|
224
|
-
const res = await fetch('/api/tasks/' + taskId, {
|
|
225
|
-
method: 'PATCH',
|
|
226
|
-
headers: { 'Content-Type': 'application/json' },
|
|
227
|
-
body: JSON.stringify({ status: newStatus })
|
|
228
|
-
});
|
|
229
|
-
if (!res.ok) throw new Error('Server error');
|
|
230
|
-
} catch {
|
|
231
|
-
prevBody.appendChild(draggedCard);
|
|
232
|
-
draggedCard.dataset.status = oldStatus;
|
|
233
|
-
updateCount(oldStatus);
|
|
234
|
-
updateCount(newStatus);
|
|
235
|
-
showToast();
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function updateCount(status) {
|
|
240
|
-
const col = document.querySelector('.column[data-status="' + status + '"]');
|
|
241
|
-
if (!col) return;
|
|
242
|
-
col.querySelector('.column-count').textContent = col.querySelector('.column-body').children.length;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function showToast(msg) {
|
|
246
|
-
const toast = document.getElementById('toast');
|
|
247
|
-
if (msg) toast.textContent = msg;
|
|
248
|
-
toast.classList.add('show');
|
|
249
|
-
setTimeout(() => toast.classList.remove('show'), 3000);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Add task modal
|
|
253
|
-
const addModal = document.getElementById('add-modal');
|
|
254
|
-
const addTitle = document.getElementById('add-title');
|
|
255
|
-
const addBody = document.getElementById('add-body');
|
|
256
|
-
const addPriority = document.getElementById('add-priority');
|
|
257
|
-
const addStatus = document.getElementById('add-status');
|
|
258
|
-
|
|
259
|
-
document.querySelectorAll('.add-btn').forEach(btn => {
|
|
260
|
-
btn.addEventListener('click', e => {
|
|
261
|
-
e.stopPropagation();
|
|
262
|
-
addStatus.value = btn.dataset.status;
|
|
263
|
-
addTitle.value = '';
|
|
264
|
-
addBody.value = '';
|
|
265
|
-
addPriority.value = '';
|
|
266
|
-
addModal.classList.add('show');
|
|
267
|
-
addTitle.focus();
|
|
268
|
-
});
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
document.getElementById('add-cancel').addEventListener('click', () => {
|
|
272
|
-
addModal.classList.remove('show');
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
addModal.addEventListener('click', e => {
|
|
276
|
-
if (e.target === addModal) addModal.classList.remove('show');
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
addTitle.addEventListener('keydown', e => {
|
|
280
|
-
if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); document.getElementById('add-submit').click(); }
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
document.getElementById('add-submit').addEventListener('click', async () => {
|
|
284
|
-
const title = addTitle.value.trim();
|
|
285
|
-
if (!title) { addTitle.focus(); return; }
|
|
286
|
-
const status = addStatus.value;
|
|
287
|
-
addModal.classList.remove('show');
|
|
288
|
-
|
|
289
|
-
try {
|
|
290
|
-
const res = await fetch('/api/tasks', {
|
|
291
|
-
method: 'POST',
|
|
292
|
-
headers: { 'Content-Type': 'application/json' },
|
|
293
|
-
body: JSON.stringify({ title, body: addBody.value.trim() || null, status, priority: addPriority.value || null })
|
|
294
|
-
});
|
|
295
|
-
if (!res.ok) throw new Error('Server error');
|
|
296
|
-
location.reload();
|
|
297
|
-
} catch {
|
|
298
|
-
showToast('Failed to add task');
|
|
299
|
-
}
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
// Context menu
|
|
303
|
-
const ctxMenu = document.getElementById('context-menu');
|
|
304
|
-
let ctxTargetCard = null;
|
|
305
|
-
|
|
306
|
-
document.addEventListener('contextmenu', e => {
|
|
307
|
-
const card = e.target.closest('.card');
|
|
308
|
-
if (!card) { ctxMenu.style.display = 'none'; return; }
|
|
309
|
-
e.preventDefault();
|
|
310
|
-
ctxTargetCard = card;
|
|
311
|
-
ctxMenu.style.left = e.clientX + 'px';
|
|
312
|
-
ctxMenu.style.top = e.clientY + 'px';
|
|
313
|
-
ctxMenu.style.display = 'block';
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
document.addEventListener('click', e => {
|
|
317
|
-
if (!e.target.closest('#context-menu')) {
|
|
318
|
-
ctxMenu.style.display = 'none';
|
|
319
|
-
ctxTargetCard = null;
|
|
320
|
-
}
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
document.getElementById('ctx-delete').addEventListener('click', async e => {
|
|
324
|
-
e.stopPropagation();
|
|
325
|
-
ctxMenu.style.display = 'none';
|
|
326
|
-
if (!ctxTargetCard) return;
|
|
327
|
-
const card = ctxTargetCard;
|
|
328
|
-
ctxTargetCard = null;
|
|
329
|
-
const taskId = card.dataset.id;
|
|
330
|
-
const status = card.dataset.status;
|
|
331
|
-
if (!confirm('Delete task #' + taskId + '?')) return;
|
|
332
|
-
|
|
333
|
-
card.remove();
|
|
334
|
-
updateCount(status);
|
|
335
|
-
|
|
336
|
-
try {
|
|
337
|
-
const res = await fetch('/api/tasks/' + taskId, { method: 'DELETE' });
|
|
338
|
-
if (!res.ok) throw new Error('Server error');
|
|
339
|
-
} catch {
|
|
340
|
-
location.reload();
|
|
341
|
-
showToast('Failed to delete task');
|
|
342
|
-
}
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
// Detail panel - create and insert into board-container
|
|
346
|
-
const boardContainer = document.querySelector('.board-container');
|
|
347
|
-
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-panel-body" id="detail-panel-body"></div><div class="detail-panel-footer"><button id="detail-save-btn">Save</button></div></div>';
|
|
348
|
-
boardContainer.insertAdjacentHTML('beforeend', detailPanelHtml);
|
|
349
|
-
|
|
350
|
-
const detailPanel = document.getElementById('detail-panel');
|
|
351
|
-
const detailPanelTitle = document.getElementById('detail-panel-title');
|
|
352
|
-
const detailPanelBody = document.getElementById('detail-panel-body');
|
|
353
|
-
let detailTaskId = null;
|
|
354
|
-
|
|
355
|
-
function closeDetailPanel() {
|
|
356
|
-
detailPanel.classList.remove('open');
|
|
357
|
-
detailPanel.style.width = '';
|
|
358
|
-
detailTaskId = null;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
document.getElementById('detail-panel-close').addEventListener('click', closeDetailPanel);
|
|
362
|
-
|
|
363
|
-
// Detail panel resize
|
|
364
|
-
const resizeHandle = document.getElementById('detail-panel-resize-handle');
|
|
365
|
-
const PANEL_MIN_WIDTH = 280;
|
|
366
|
-
const PANEL_MAX_WIDTH = 800;
|
|
367
|
-
const PANEL_DEFAULT_WIDTH = 400;
|
|
368
|
-
|
|
369
|
-
// Initialize panel width from server config (async)
|
|
370
|
-
(async function initPanelWidth() {
|
|
371
|
-
let targetWidth = PANEL_DEFAULT_WIDTH;
|
|
372
|
-
try {
|
|
373
|
-
const res = await fetch('/api/config');
|
|
374
|
-
if (res.ok) {
|
|
375
|
-
const data = await res.json();
|
|
376
|
-
const savedWidth = data && data.board && data.board.detailPaneWidth;
|
|
377
|
-
if (typeof savedWidth === 'number' && savedWidth >= PANEL_MIN_WIDTH && savedWidth <= PANEL_MAX_WIDTH) {
|
|
378
|
-
targetWidth = savedWidth;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
} catch {
|
|
382
|
-
// Ignore errors, use default width
|
|
383
|
-
}
|
|
384
|
-
// Store the width for when panel opens (width is 0 when closed)
|
|
385
|
-
detailPanel.dataset.preferredWidth = String(targetWidth);
|
|
386
|
-
})();
|
|
387
|
-
|
|
388
|
-
resizeHandle.addEventListener('mousedown', function(e) {
|
|
389
|
-
e.preventDefault();
|
|
390
|
-
if (!detailPanel.classList.contains('open')) return;
|
|
391
|
-
const startX = e.clientX;
|
|
392
|
-
const startWidth = detailPanel.offsetWidth;
|
|
393
|
-
resizeHandle.classList.add('dragging');
|
|
394
|
-
document.body.style.userSelect = 'none';
|
|
395
|
-
document.body.style.cursor = 'col-resize';
|
|
396
|
-
detailPanel.style.transition = 'none';
|
|
397
|
-
|
|
398
|
-
function onMouseMove(e) {
|
|
399
|
-
const delta = startX - e.clientX;
|
|
400
|
-
const newWidth = Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, startWidth + delta));
|
|
401
|
-
detailPanel.style.width = newWidth + 'px';
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function onMouseUp() {
|
|
405
|
-
resizeHandle.classList.remove('dragging');
|
|
406
|
-
document.body.style.userSelect = '';
|
|
407
|
-
document.body.style.cursor = '';
|
|
408
|
-
detailPanel.style.transition = '';
|
|
409
|
-
const currentWidth = detailPanel.offsetWidth;
|
|
410
|
-
detailPanel.dataset.preferredWidth = String(currentWidth);
|
|
411
|
-
fetch('/api/config', {
|
|
412
|
-
method: 'PUT',
|
|
413
|
-
headers: { 'Content-Type': 'application/json' },
|
|
414
|
-
body: JSON.stringify({ board: { detailPaneWidth: currentWidth } })
|
|
415
|
-
}).catch(function() {
|
|
416
|
-
// Ignore errors when saving panel width
|
|
417
|
-
});
|
|
418
|
-
document.removeEventListener('mousemove', onMouseMove);
|
|
419
|
-
document.removeEventListener('mouseup', onMouseUp);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
document.addEventListener('mousemove', onMouseMove);
|
|
423
|
-
document.addEventListener('mouseup', onMouseUp);
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
const statusColors = ${JSON.stringify(STATUS_COLORS)};
|
|
427
|
-
const allStatuses = ${JSON.stringify(STATUSES)};
|
|
428
|
-
const statusLabels = ${JSON.stringify(STATUS_LABELS)};
|
|
429
|
-
const allPriorities = ${JSON.stringify(models_1.PRIORITIES)};
|
|
430
|
-
|
|
431
|
-
let allAvailableTags = [];
|
|
432
|
-
|
|
433
|
-
async function loadAllTags() {
|
|
434
|
-
try {
|
|
435
|
-
const res = await fetch('/api/tags');
|
|
436
|
-
if (!res.ok) return;
|
|
437
|
-
const data = await res.json();
|
|
438
|
-
allAvailableTags = data.tags || [];
|
|
439
|
-
} catch {
|
|
440
|
-
// Ignore errors loading tags
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
function renderTagsSection(currentTags) {
|
|
445
|
-
const container = document.getElementById('detail-tags-container');
|
|
446
|
-
if (!container) return;
|
|
447
|
-
|
|
448
|
-
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>';
|
|
449
|
-
|
|
450
|
-
const control = document.getElementById('tag-select-control');
|
|
451
|
-
const dropdown = document.getElementById('tag-select-dropdown');
|
|
452
|
-
let focusedOptionIndex = -1;
|
|
453
|
-
let inputValue = '';
|
|
454
|
-
|
|
455
|
-
function getFilteredTags() {
|
|
456
|
-
const currentTagIds = new Set(currentTags.map(t => t.id));
|
|
457
|
-
const available = allAvailableTags.filter(t => !currentTagIds.has(t.id));
|
|
458
|
-
if (!inputValue.trim()) return available;
|
|
459
|
-
const q = inputValue.toLowerCase();
|
|
460
|
-
return available.filter(t => t.name.toLowerCase().includes(q));
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const input = document.createElement('input');
|
|
464
|
-
input.className = 'tag-select-input';
|
|
465
|
-
input.type = 'text';
|
|
466
|
-
input.autocomplete = 'off';
|
|
467
|
-
control.appendChild(input);
|
|
468
|
-
|
|
469
|
-
function renderPills() {
|
|
470
|
-
control.querySelectorAll('.tag-pill').forEach(p => p.remove());
|
|
471
|
-
currentTags.forEach(t => {
|
|
472
|
-
const pill = document.createElement('span');
|
|
473
|
-
pill.className = 'tag-pill';
|
|
474
|
-
pill.dataset.tagId = t.id;
|
|
475
|
-
const label = document.createTextNode(t.name);
|
|
476
|
-
const removeBtn = document.createElement('button');
|
|
477
|
-
removeBtn.className = 'tag-pill-remove';
|
|
478
|
-
removeBtn.title = 'Remove tag';
|
|
479
|
-
removeBtn.setAttribute('data-tag-id', t.id);
|
|
480
|
-
removeBtn.innerHTML = '×';
|
|
481
|
-
removeBtn.addEventListener('click', async e => {
|
|
482
|
-
e.stopPropagation();
|
|
483
|
-
try {
|
|
484
|
-
const res = await fetch('/api/tasks/' + detailTaskId + '/tags/' + t.id, { method: 'DELETE' });
|
|
485
|
-
if (!res.ok) throw new Error('Server error');
|
|
486
|
-
const idx = currentTags.findIndex(x => String(x.id) === String(t.id));
|
|
487
|
-
if (idx !== -1) currentTags.splice(idx, 1);
|
|
488
|
-
renderPills();
|
|
489
|
-
renderDropdown();
|
|
490
|
-
} catch {
|
|
491
|
-
showToast('Failed to remove tag');
|
|
492
|
-
}
|
|
493
|
-
});
|
|
494
|
-
pill.appendChild(label);
|
|
495
|
-
pill.appendChild(removeBtn);
|
|
496
|
-
control.insertBefore(pill, input);
|
|
497
|
-
});
|
|
498
|
-
input.placeholder = currentTags.length === 0 ? 'Add tags...' : '';
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function renderDropdown() {
|
|
502
|
-
const filtered = getFilteredTags();
|
|
503
|
-
dropdown.innerHTML = '';
|
|
504
|
-
focusedOptionIndex = -1;
|
|
505
|
-
if (filtered.length === 0) {
|
|
506
|
-
const noOpt = document.createElement('div');
|
|
507
|
-
noOpt.className = 'tag-select-no-options';
|
|
508
|
-
noOpt.textContent = inputValue ? 'No matching tags' : 'No tags available';
|
|
509
|
-
dropdown.appendChild(noOpt);
|
|
510
|
-
} else {
|
|
511
|
-
filtered.forEach((t, i) => {
|
|
512
|
-
const opt = document.createElement('div');
|
|
513
|
-
opt.className = 'tag-select-option';
|
|
514
|
-
opt.dataset.tagId = t.id;
|
|
515
|
-
opt.textContent = t.name;
|
|
516
|
-
opt.addEventListener('mouseover', () => setFocusedOption(i));
|
|
517
|
-
opt.addEventListener('mousedown', async e => {
|
|
518
|
-
e.preventDefault();
|
|
519
|
-
await addTag(t.id);
|
|
520
|
-
});
|
|
521
|
-
dropdown.appendChild(opt);
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
function setFocusedOption(index) {
|
|
527
|
-
const opts = dropdown.querySelectorAll('.tag-select-option');
|
|
528
|
-
opts.forEach((o, i) => o.classList.toggle('focused', i === index));
|
|
529
|
-
focusedOptionIndex = index;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
function openDropdown() {
|
|
533
|
-
renderDropdown();
|
|
534
|
-
dropdown.classList.add('open');
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
function closeDropdown() {
|
|
538
|
-
dropdown.classList.remove('open');
|
|
539
|
-
focusedOptionIndex = -1;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
async function addTag(tagId) {
|
|
543
|
-
try {
|
|
544
|
-
const res = await fetch('/api/tasks/' + detailTaskId + '/tags', {
|
|
545
|
-
method: 'POST',
|
|
546
|
-
headers: { 'Content-Type': 'application/json' },
|
|
547
|
-
body: JSON.stringify({ tagId: Number(tagId) })
|
|
548
|
-
});
|
|
549
|
-
if (!res.ok) throw new Error('Server error');
|
|
550
|
-
const tag = allAvailableTags.find(t => String(t.id) === String(tagId));
|
|
551
|
-
if (tag) currentTags.push(tag);
|
|
552
|
-
input.value = '';
|
|
553
|
-
inputValue = '';
|
|
554
|
-
renderPills();
|
|
555
|
-
renderDropdown();
|
|
556
|
-
} catch {
|
|
557
|
-
showToast('Failed to add tag');
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
control.addEventListener('click', () => input.focus());
|
|
562
|
-
|
|
563
|
-
input.addEventListener('focus', () => openDropdown());
|
|
564
|
-
|
|
565
|
-
input.addEventListener('blur', () => setTimeout(() => closeDropdown(), 150));
|
|
566
|
-
|
|
567
|
-
input.addEventListener('input', () => {
|
|
568
|
-
inputValue = input.value;
|
|
569
|
-
renderDropdown();
|
|
570
|
-
if (!dropdown.classList.contains('open')) openDropdown();
|
|
571
|
-
});
|
|
572
|
-
|
|
573
|
-
input.addEventListener('keydown', async e => {
|
|
574
|
-
const filtered = getFilteredTags();
|
|
575
|
-
const opts = dropdown.querySelectorAll('.tag-select-option');
|
|
576
|
-
if (e.key === 'ArrowDown') {
|
|
577
|
-
e.preventDefault();
|
|
578
|
-
setFocusedOption(Math.min(focusedOptionIndex + 1, opts.length - 1));
|
|
579
|
-
} else if (e.key === 'ArrowUp') {
|
|
580
|
-
e.preventDefault();
|
|
581
|
-
setFocusedOption(Math.max(focusedOptionIndex - 1, 0));
|
|
582
|
-
} else if (e.key === 'Enter') {
|
|
583
|
-
e.preventDefault();
|
|
584
|
-
if (focusedOptionIndex >= 0 && filtered[focusedOptionIndex]) {
|
|
585
|
-
await addTag(filtered[focusedOptionIndex].id);
|
|
586
|
-
}
|
|
587
|
-
} else if (e.key === 'Escape') {
|
|
588
|
-
closeDropdown();
|
|
589
|
-
input.blur();
|
|
590
|
-
} else if (e.key === 'Backspace' && input.value === '' && currentTags.length > 0) {
|
|
591
|
-
e.preventDefault();
|
|
592
|
-
const last = currentTags[currentTags.length - 1];
|
|
593
|
-
try {
|
|
594
|
-
const res = await fetch('/api/tasks/' + detailTaskId + '/tags/' + last.id, { method: 'DELETE' });
|
|
595
|
-
if (!res.ok) throw new Error('Server error');
|
|
596
|
-
currentTags.splice(currentTags.length - 1, 1);
|
|
597
|
-
renderPills();
|
|
598
|
-
renderDropdown();
|
|
599
|
-
} catch {
|
|
600
|
-
showToast('Failed to remove tag');
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
renderPills();
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function renderDetailPanel(data) {
|
|
609
|
-
const task = data.task;
|
|
610
|
-
const tags = data.tags || [];
|
|
611
|
-
const metadata = data.metadata || [];
|
|
612
|
-
|
|
613
|
-
detailTaskId = task.id;
|
|
614
|
-
detailPanelTitle.textContent = '#' + task.id + ' ' + task.title;
|
|
615
|
-
|
|
616
|
-
let html = '';
|
|
617
|
-
|
|
618
|
-
// Title (editable)
|
|
619
|
-
html += '<div class="detail-field">';
|
|
620
|
-
html += '<div class="detail-field-label">Title</div>';
|
|
621
|
-
html += '<input id="detail-edit-title" class="detail-edit-input" type="text" value="' + escapeHtmlClient(task.title) + '">';
|
|
622
|
-
html += '</div>';
|
|
623
|
-
|
|
624
|
-
// Status (editable)
|
|
625
|
-
html += '<div class="detail-field">';
|
|
626
|
-
html += '<div class="detail-field-label">Status</div>';
|
|
627
|
-
html += '<select id="detail-edit-status" class="detail-edit-select">';
|
|
628
|
-
allStatuses.forEach(s => {
|
|
629
|
-
const selected = s === task.status ? ' selected' : '';
|
|
630
|
-
html += '<option value="' + s + '"' + selected + '>' + statusLabels[s] + '</option>';
|
|
631
|
-
});
|
|
632
|
-
html += '</select>';
|
|
633
|
-
html += '</div>';
|
|
634
|
-
|
|
635
|
-
// Priority (editable)
|
|
636
|
-
html += '<div class="detail-field">';
|
|
637
|
-
html += '<div class="detail-field-label">Priority</div>';
|
|
638
|
-
html += '<select id="detail-edit-priority" class="detail-edit-select">';
|
|
639
|
-
html += '<option value="">None</option>';
|
|
640
|
-
allPriorities.forEach(p => {
|
|
641
|
-
const selected = task.priority === p ? ' selected' : '';
|
|
642
|
-
html += '<option value="' + p + '"' + selected + '>' + p.charAt(0).toUpperCase() + p.slice(1) + '</option>';
|
|
643
|
-
});
|
|
644
|
-
html += '</select>';
|
|
645
|
-
html += '</div>';
|
|
646
|
-
|
|
647
|
-
// Tags (editable)
|
|
648
|
-
html += '<div class="detail-field">';
|
|
649
|
-
html += '<div class="detail-field-label">Tags</div>';
|
|
650
|
-
html += '<div id="detail-tags-container"></div>';
|
|
651
|
-
html += '</div>';
|
|
652
|
-
|
|
653
|
-
// Metadata table (read-only, non-priority)
|
|
654
|
-
const otherMeta = metadata.filter(m => m.key !== 'priority');
|
|
655
|
-
if (otherMeta.length > 0) {
|
|
656
|
-
html += '<div class="detail-field">';
|
|
657
|
-
html += '<div class="detail-field-label">Metadata</div>';
|
|
658
|
-
html += '<table class="detail-meta-table">';
|
|
659
|
-
otherMeta.forEach(m => {
|
|
660
|
-
html += '<tr><td>' + escapeHtmlClient(m.key) + '</td><td>' + escapeHtmlClient(m.value) + '</td></tr>';
|
|
661
|
-
});
|
|
662
|
-
html += '</table></div>';
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
// Timestamps (read-only)
|
|
666
|
-
html += '<div class="detail-field">';
|
|
667
|
-
html += '<div class="detail-field-label">Created</div>';
|
|
668
|
-
html += '<div class="detail-field-value">' + escapeHtmlClient(task.created_at) + '</div>';
|
|
669
|
-
html += '</div>';
|
|
670
|
-
html += '<div class="detail-field">';
|
|
671
|
-
html += '<div class="detail-field-label">Updated</div>';
|
|
672
|
-
html += '<div class="detail-field-value">' + escapeHtmlClient(task.updated_at) + '</div>';
|
|
673
|
-
html += '</div>';
|
|
674
|
-
|
|
675
|
-
// Body (editable)
|
|
676
|
-
html += '<div class="detail-field description-field-wrapper">';
|
|
677
|
-
html += '<div class="detail-field-label">Description</div>';
|
|
678
|
-
html += '<textarea id="detail-edit-body" class="detail-edit-textarea">' + escapeHtmlClient(task.body || '') + '</textarea>';
|
|
679
|
-
html += '</div>';
|
|
680
|
-
|
|
681
|
-
detailPanelBody.innerHTML = html;
|
|
682
|
-
|
|
683
|
-
// Render tags section after DOM update
|
|
684
|
-
loadAllTags().then(() => renderTagsSection([...tags]));
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
function escapeHtmlClient(str) {
|
|
688
|
-
if (!str) return '';
|
|
689
|
-
const div = document.createElement('div');
|
|
690
|
-
div.textContent = String(str);
|
|
691
|
-
return div.innerHTML;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
document.getElementById('detail-save-btn').addEventListener('click', async () => {
|
|
695
|
-
if (detailTaskId === null) return;
|
|
696
|
-
const titleInput = document.getElementById('detail-edit-title');
|
|
697
|
-
const title = titleInput ? titleInput.value.trim() : '';
|
|
698
|
-
if (!title) { if (titleInput) titleInput.focus(); return; }
|
|
699
|
-
const bodyEl = document.getElementById('detail-edit-body');
|
|
700
|
-
const statusEl = document.getElementById('detail-edit-status');
|
|
701
|
-
const priorityEl = document.getElementById('detail-edit-priority');
|
|
702
|
-
|
|
703
|
-
try {
|
|
704
|
-
const res = await fetch('/api/tasks/' + detailTaskId, {
|
|
705
|
-
method: 'PATCH',
|
|
706
|
-
headers: { 'Content-Type': 'application/json' },
|
|
707
|
-
body: JSON.stringify({
|
|
708
|
-
title,
|
|
709
|
-
body: bodyEl ? (bodyEl.value.trim() || null) : null,
|
|
710
|
-
status: statusEl ? statusEl.value : undefined,
|
|
711
|
-
priority: priorityEl ? (priorityEl.value || null) : null
|
|
712
|
-
})
|
|
713
|
-
});
|
|
714
|
-
if (!res.ok) throw new Error('Server error');
|
|
715
|
-
// Fetch updated task data and refresh detail panel instead of reloading
|
|
716
|
-
const getRes = await fetch('/api/tasks/' + detailTaskId);
|
|
717
|
-
if (!getRes.ok) throw new Error('Failed to fetch updated task');
|
|
718
|
-
const data = await getRes.json();
|
|
719
|
-
renderDetailPanel(data);
|
|
720
|
-
showToast('Task saved successfully');
|
|
721
|
-
// Update lastUpdatedAt so polling doesn't treat our own save as an external update
|
|
722
|
-
try {
|
|
723
|
-
const tsRes = await fetch('/api/board/updated-at');
|
|
724
|
-
if (tsRes.ok) {
|
|
725
|
-
const tsData = await tsRes.json();
|
|
726
|
-
lastUpdatedAt = tsData.updatedAt;
|
|
727
|
-
}
|
|
728
|
-
} catch {
|
|
729
|
-
// Ignore errors when syncing timestamp
|
|
730
|
-
}
|
|
731
|
-
// Refresh board cards in the background
|
|
732
|
-
refreshBoardCards();
|
|
733
|
-
} catch {
|
|
734
|
-
showToast('Failed to update task');
|
|
735
|
-
}
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
document.querySelectorAll('.card').forEach(card => {
|
|
739
|
-
card.addEventListener('click', async e => {
|
|
740
|
-
if (e.defaultPrevented) return;
|
|
741
|
-
const taskId = card.dataset.id;
|
|
742
|
-
try {
|
|
743
|
-
const res = await fetch('/api/tasks/' + taskId);
|
|
744
|
-
if (!res.ok) throw new Error('Server error');
|
|
745
|
-
const data = await res.json();
|
|
746
|
-
renderDetailPanel(data);
|
|
747
|
-
if (!detailPanel.classList.contains('open')) {
|
|
748
|
-
const preferredWidth = detailPanel.dataset.preferredWidth || PANEL_DEFAULT_WIDTH;
|
|
749
|
-
detailPanel.style.width = preferredWidth + 'px';
|
|
750
|
-
detailPanel.classList.add('open');
|
|
751
|
-
}
|
|
752
|
-
} catch {
|
|
753
|
-
showToast('Failed to load task details');
|
|
754
|
-
}
|
|
755
|
-
});
|
|
756
|
-
});
|
|
757
|
-
|
|
758
|
-
// Filter state (defined before refreshBoardCards so it can use them)
|
|
759
|
-
let activeFilters = { tagIds: [], priorities: [], assignee: '' };
|
|
760
|
-
|
|
761
|
-
function buildFilterParams() {
|
|
762
|
-
const params = new URLSearchParams();
|
|
763
|
-
if (activeFilters.priorities.length > 0) {
|
|
764
|
-
params.set('priority', activeFilters.priorities.join(','));
|
|
765
|
-
}
|
|
766
|
-
if (activeFilters.tagIds.length > 0) {
|
|
767
|
-
params.set('tags', activeFilters.tagIds.join(','));
|
|
768
|
-
}
|
|
769
|
-
if (activeFilters.assignee) {
|
|
770
|
-
params.set('assignee', activeFilters.assignee);
|
|
771
|
-
}
|
|
772
|
-
return params;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// Board polling: reload when updated_at changes (skip during drag)
|
|
776
|
-
let lastUpdatedAt = null;
|
|
777
|
-
async function refreshBoardCards() {
|
|
778
|
-
const filterParams = buildFilterParams();
|
|
779
|
-
const url = '/api/board/cards' + (filterParams.toString() ? '?' + filterParams.toString() : '');
|
|
780
|
-
try {
|
|
781
|
-
const res = await fetch(url);
|
|
782
|
-
if (!res.ok) return;
|
|
783
|
-
const data = await res.json();
|
|
784
|
-
const columns = data.columns;
|
|
785
|
-
columns.forEach(col => {
|
|
786
|
-
const body = document.getElementById('col-' + col.status);
|
|
787
|
-
if (!body) return;
|
|
788
|
-
body.innerHTML = col.html;
|
|
789
|
-
const colEl = body.closest('.column');
|
|
790
|
-
if (colEl) colEl.querySelector('.column-count').textContent = col.count;
|
|
791
|
-
// Re-attach drag event listeners to new cards
|
|
792
|
-
body.querySelectorAll('.card').forEach(card => {
|
|
793
|
-
card.addEventListener('dragstart', e => {
|
|
794
|
-
draggedCard = card;
|
|
795
|
-
sourceBody = card.parentElement;
|
|
796
|
-
card.classList.add('dragging');
|
|
797
|
-
e.dataTransfer.effectAllowed = 'move';
|
|
798
|
-
});
|
|
799
|
-
card.addEventListener('dragend', () => {
|
|
800
|
-
card.classList.remove('dragging');
|
|
801
|
-
draggedCard = null;
|
|
802
|
-
sourceBody = null;
|
|
803
|
-
});
|
|
804
|
-
card.addEventListener('click', async e => {
|
|
805
|
-
if (e.defaultPrevented) return;
|
|
806
|
-
const taskId = card.dataset.id;
|
|
807
|
-
try {
|
|
808
|
-
const res = await fetch('/api/tasks/' + taskId);
|
|
809
|
-
if (!res.ok) throw new Error('Server error');
|
|
810
|
-
const data = await res.json();
|
|
811
|
-
renderDetailPanel(data);
|
|
812
|
-
if (!detailPanel.classList.contains('open')) {
|
|
813
|
-
const preferredWidth = detailPanel.dataset.preferredWidth || PANEL_DEFAULT_WIDTH;
|
|
814
|
-
detailPanel.style.width = preferredWidth + 'px';
|
|
815
|
-
detailPanel.classList.add('open');
|
|
816
|
-
}
|
|
817
|
-
} catch {
|
|
818
|
-
showToast('Failed to load task details');
|
|
819
|
-
}
|
|
820
|
-
});
|
|
821
|
-
});
|
|
822
|
-
});
|
|
823
|
-
// If detail panel is open, refresh its content if the task was updated
|
|
824
|
-
if (detailTaskId !== null) {
|
|
825
|
-
const editableFields = ['detail-edit-title', 'detail-edit-body', 'detail-edit-status', 'detail-edit-priority'];
|
|
826
|
-
const isEditing = editableFields.some(id => document.activeElement && document.activeElement.id === id);
|
|
827
|
-
if (isEditing) {
|
|
828
|
-
const warning = document.getElementById('detail-panel-update-warning');
|
|
829
|
-
if (!warning) {
|
|
830
|
-
const warningEl = document.createElement('div');
|
|
831
|
-
warningEl.id = 'detail-panel-update-warning';
|
|
832
|
-
warningEl.style.cssText = 'color: red; font-size: 0.85em; padding: 4px 8px; background: #fff0f0; border: 1px solid #ffcccc; border-radius: 4px; margin-bottom: 8px;';
|
|
833
|
-
warningEl.textContent = 'This task has been updated in the database. Save or discard your changes to see the latest version.';
|
|
834
|
-
detailPanelBody.insertBefore(warningEl, detailPanelBody.firstChild);
|
|
835
|
-
}
|
|
836
|
-
} else {
|
|
837
|
-
try {
|
|
838
|
-
const taskRes = await fetch('/api/tasks/' + detailTaskId);
|
|
839
|
-
if (taskRes.ok) {
|
|
840
|
-
const taskData = await taskRes.json();
|
|
841
|
-
renderDetailPanel(taskData);
|
|
842
|
-
}
|
|
843
|
-
} catch {
|
|
844
|
-
// Ignore network errors during detail panel refresh
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
} catch {
|
|
849
|
-
// Ignore network errors during card refresh
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
async function pollBoardUpdates() {
|
|
853
|
-
if (draggedCard !== null) return;
|
|
854
|
-
try {
|
|
855
|
-
const res = await fetch('/api/board/updated-at');
|
|
856
|
-
if (!res.ok) return;
|
|
857
|
-
const data = await res.json();
|
|
858
|
-
const ts = data.updatedAt;
|
|
859
|
-
if (lastUpdatedAt === null) {
|
|
860
|
-
lastUpdatedAt = ts;
|
|
861
|
-
} else if (ts !== lastUpdatedAt) {
|
|
862
|
-
lastUpdatedAt = ts;
|
|
863
|
-
if (detailPanel.classList.contains('open')) {
|
|
864
|
-
await refreshBoardCards();
|
|
865
|
-
} else {
|
|
866
|
-
location.reload();
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
} catch {
|
|
870
|
-
// Ignore network errors during polling
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
setInterval(pollBoardUpdates, 5000);
|
|
874
|
-
pollBoardUpdates();
|
|
875
|
-
|
|
876
|
-
function isFiltersActive() {
|
|
877
|
-
return activeFilters.priorities.length > 0 || activeFilters.tagIds.length > 0 || activeFilters.assignee !== '';
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
function applyFilters() {
|
|
881
|
-
const clearBtn = document.getElementById('filter-clear');
|
|
882
|
-
if (clearBtn) {
|
|
883
|
-
if (isFiltersActive()) {
|
|
884
|
-
clearBtn.classList.add('visible');
|
|
885
|
-
} else {
|
|
886
|
-
clearBtn.classList.remove('visible');
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
refreshBoardCards();
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
function renderFilterTagPills() {
|
|
893
|
-
const container = document.getElementById('filter-tags-control');
|
|
894
|
-
if (!container) return;
|
|
895
|
-
// Remove existing pills
|
|
896
|
-
container.querySelectorAll('.filter-tag-pill').forEach(p => p.remove());
|
|
897
|
-
// Add pills for active tag filters
|
|
898
|
-
activeFilters.tagIds.forEach(tagId => {
|
|
899
|
-
const tag = allAvailableTags.find(t => t.id === tagId);
|
|
900
|
-
if (!tag) return;
|
|
901
|
-
const pill = document.createElement('span');
|
|
902
|
-
pill.className = 'filter-tag-pill';
|
|
903
|
-
const label = document.createTextNode(tag.name);
|
|
904
|
-
const removeBtn = document.createElement('button');
|
|
905
|
-
removeBtn.className = 'filter-tag-pill-remove';
|
|
906
|
-
removeBtn.title = 'Remove tag filter';
|
|
907
|
-
removeBtn.innerHTML = '×';
|
|
908
|
-
removeBtn.addEventListener('click', () => {
|
|
909
|
-
const idx = activeFilters.tagIds.indexOf(tagId);
|
|
910
|
-
if (idx !== -1) activeFilters.tagIds.splice(idx, 1);
|
|
911
|
-
renderFilterTagPills();
|
|
912
|
-
applyFilters();
|
|
913
|
-
});
|
|
914
|
-
pill.appendChild(label);
|
|
915
|
-
pill.appendChild(removeBtn);
|
|
916
|
-
container.insertBefore(pill, container.querySelector('.filter-tag-dropdown-wrapper'));
|
|
917
|
-
});
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
function initFilterBar() {
|
|
921
|
-
// Priority toggle buttons
|
|
922
|
-
document.querySelectorAll('.filter-priority-btn').forEach(btn => {
|
|
923
|
-
btn.addEventListener('click', () => {
|
|
924
|
-
const priority = btn.dataset.priority;
|
|
925
|
-
const idx = activeFilters.priorities.indexOf(priority);
|
|
926
|
-
if (idx === -1) {
|
|
927
|
-
activeFilters.priorities.push(priority);
|
|
928
|
-
btn.classList.add('active');
|
|
929
|
-
} else {
|
|
930
|
-
activeFilters.priorities.splice(idx, 1);
|
|
931
|
-
btn.classList.remove('active');
|
|
932
|
-
}
|
|
933
|
-
applyFilters();
|
|
934
|
-
});
|
|
935
|
-
});
|
|
936
|
-
|
|
937
|
-
// Assignee input with debounce
|
|
938
|
-
const assigneeInput = document.getElementById('filter-assignee');
|
|
939
|
-
let assigneeTimer = null;
|
|
940
|
-
if (assigneeInput) {
|
|
941
|
-
assigneeInput.addEventListener('input', () => {
|
|
942
|
-
clearTimeout(assigneeTimer);
|
|
943
|
-
assigneeTimer = setTimeout(() => {
|
|
944
|
-
activeFilters.assignee = assigneeInput.value.trim();
|
|
945
|
-
applyFilters();
|
|
946
|
-
}, 300);
|
|
947
|
-
});
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
// Clear button
|
|
951
|
-
const clearBtn = document.getElementById('filter-clear');
|
|
952
|
-
if (clearBtn) {
|
|
953
|
-
clearBtn.addEventListener('click', () => {
|
|
954
|
-
activeFilters.tagIds = [];
|
|
955
|
-
activeFilters.priorities = [];
|
|
956
|
-
activeFilters.assignee = '';
|
|
957
|
-
document.querySelectorAll('.filter-priority-btn').forEach(btn => btn.classList.remove('active'));
|
|
958
|
-
if (assigneeInput) assigneeInput.value = '';
|
|
959
|
-
renderFilterTagPills();
|
|
960
|
-
applyFilters();
|
|
961
|
-
});
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
// Tag filter dropdown
|
|
965
|
-
const tagsControl = document.getElementById('filter-tags-control');
|
|
966
|
-
if (tagsControl) {
|
|
967
|
-
const dropdownWrapper = document.createElement('div');
|
|
968
|
-
dropdownWrapper.className = 'filter-tag-dropdown-wrapper';
|
|
969
|
-
|
|
970
|
-
const addBtn = document.createElement('button');
|
|
971
|
-
addBtn.className = 'filter-tag-add-btn';
|
|
972
|
-
addBtn.textContent = '+ Tag';
|
|
973
|
-
|
|
974
|
-
const dropdown = document.createElement('div');
|
|
975
|
-
dropdown.className = 'filter-tag-dropdown';
|
|
976
|
-
|
|
977
|
-
dropdownWrapper.appendChild(addBtn);
|
|
978
|
-
dropdownWrapper.appendChild(dropdown);
|
|
979
|
-
tagsControl.appendChild(dropdownWrapper);
|
|
980
|
-
|
|
981
|
-
function renderTagDropdown() {
|
|
982
|
-
dropdown.innerHTML = '';
|
|
983
|
-
const available = allAvailableTags.filter(t => !activeFilters.tagIds.includes(t.id));
|
|
984
|
-
if (available.length === 0) {
|
|
985
|
-
const empty = document.createElement('div');
|
|
986
|
-
empty.className = 'filter-tag-dropdown-empty';
|
|
987
|
-
empty.textContent = 'No tags available';
|
|
988
|
-
dropdown.appendChild(empty);
|
|
989
|
-
} else {
|
|
990
|
-
available.forEach(tag => {
|
|
991
|
-
const opt = document.createElement('div');
|
|
992
|
-
opt.className = 'filter-tag-dropdown-option';
|
|
993
|
-
opt.textContent = tag.name;
|
|
994
|
-
opt.addEventListener('mousedown', (e) => {
|
|
995
|
-
e.preventDefault();
|
|
996
|
-
activeFilters.tagIds.push(tag.id);
|
|
997
|
-
dropdown.classList.remove('open');
|
|
998
|
-
renderFilterTagPills();
|
|
999
|
-
applyFilters();
|
|
1000
|
-
});
|
|
1001
|
-
dropdown.appendChild(opt);
|
|
1002
|
-
});
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
addBtn.addEventListener('click', () => {
|
|
1007
|
-
if (dropdown.classList.contains('open')) {
|
|
1008
|
-
dropdown.classList.remove('open');
|
|
1009
|
-
} else {
|
|
1010
|
-
renderTagDropdown();
|
|
1011
|
-
const rect = addBtn.getBoundingClientRect();
|
|
1012
|
-
dropdown.style.top = (rect.bottom + 2) + 'px';
|
|
1013
|
-
dropdown.style.left = rect.left + 'px';
|
|
1014
|
-
dropdown.classList.add('open');
|
|
1015
|
-
}
|
|
1016
|
-
});
|
|
1017
|
-
|
|
1018
|
-
document.addEventListener('click', (e) => {
|
|
1019
|
-
if (!dropdownWrapper.contains(e.target)) {
|
|
1020
|
-
dropdown.classList.remove('open');
|
|
1021
|
-
}
|
|
1022
|
-
});
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
// Initialize filter bar after tags are loaded
|
|
1027
|
-
loadAllTags().then(() => {
|
|
1028
|
-
initFilterBar();
|
|
1029
|
-
});`;
|
|
1030
|
-
function renderColumn(status, tasks, tagMap) {
|
|
1031
|
-
const color = STATUS_COLORS[status];
|
|
1032
|
-
const label = STATUS_LABELS[status];
|
|
1033
|
-
const cards = tasks.map((t) => renderCard(t, tagMap.get(t.id) || [])).join('');
|
|
1034
|
-
return `
|
|
1035
|
-
<div class="column" data-status="${status}">
|
|
1036
|
-
<div class="column-header" style="border-top-color:${color}">
|
|
1037
|
-
<span class="column-title" style="color:${color}">${label}</span>
|
|
1038
|
-
<span class="column-header-right">
|
|
1039
|
-
<span class="column-count">${tasks.length}</span>
|
|
1040
|
-
<button class="add-btn" data-status="${status}" title="Add task">+</button>
|
|
1041
|
-
</span>
|
|
1042
|
-
</div>
|
|
1043
|
-
<div class="column-body" id="col-${status}">
|
|
1044
|
-
${cards}
|
|
1045
|
-
</div>
|
|
1046
|
-
</div>`;
|
|
1047
|
-
}
|
|
1048
|
-
const BOARD_PRIORITY_OPTIONS = models_1.PRIORITIES.map((p) => `<option value="${p}">${p.charAt(0).toUpperCase() + p.slice(1)}</option>`).join('\n ');
|
|
1049
|
-
const BOARD_BODY_STATIC = `
|
|
1050
|
-
<div class="modal-overlay" id="add-modal">
|
|
1051
|
-
<div class="modal">
|
|
1052
|
-
<h2>Add Task</h2>
|
|
1053
|
-
<label for="add-title">Title</label>
|
|
1054
|
-
<input type="text" id="add-title" placeholder="Task title">
|
|
1055
|
-
<label for="add-body">Description</label>
|
|
1056
|
-
<textarea id="add-body" placeholder="Optional"></textarea>
|
|
1057
|
-
<label for="add-priority">Priority</label>
|
|
1058
|
-
<select id="add-priority">
|
|
1059
|
-
<option value="">None</option>
|
|
1060
|
-
${BOARD_PRIORITY_OPTIONS}
|
|
1061
|
-
</select>
|
|
1062
|
-
<input type="hidden" id="add-status">
|
|
1063
|
-
<div class="modal-actions">
|
|
1064
|
-
<button id="add-cancel">Cancel</button>
|
|
1065
|
-
<button id="add-submit" class="primary">Add</button>
|
|
1066
|
-
</div>
|
|
1067
|
-
</div>
|
|
1068
|
-
</div>
|
|
1069
|
-
<div class="context-menu" id="context-menu">
|
|
1070
|
-
<div class="context-menu-item danger" id="ctx-delete">Delete task</div>
|
|
1071
|
-
</div>
|
|
1072
|
-
<div class="toast" id="toast">Failed to update task</div>
|
|
1073
|
-
<script>${BOARD_SCRIPT}
|
|
1074
|
-
</script>`;
|
|
1075
|
-
function renderBoard(tasksByStatus, tagMap, boardTitle) {
|
|
1076
|
-
const columns = STATUSES.map((status) => renderColumn(status, tasksByStatus.get(status) || [], tagMap)).join('');
|
|
1077
|
-
const titleHtml = boardTitle ? `<span class="board-title">${escapeHtml(boardTitle)}</span>` : '';
|
|
1078
|
-
return `<!DOCTYPE html>
|
|
1079
|
-
<html lang="en">
|
|
1080
|
-
<head>
|
|
1081
|
-
<meta charset="UTF-8">
|
|
1082
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1083
|
-
<title>agkan board</title>
|
|
1084
|
-
<style>${BOARD_STYLES}
|
|
1085
|
-
</style>
|
|
1086
|
-
</head>
|
|
1087
|
-
<body>
|
|
1088
|
-
<header><h1>agkan board</h1>${titleHtml}</header>
|
|
1089
|
-
<div class="filter-bar" id="filter-bar">
|
|
1090
|
-
<div class="filter-group">
|
|
1091
|
-
<span class="filter-label">Priority</span>
|
|
1092
|
-
<button class="filter-priority-btn" data-priority="critical">critical</button>
|
|
1093
|
-
<button class="filter-priority-btn" data-priority="high">high</button>
|
|
1094
|
-
<button class="filter-priority-btn" data-priority="medium">medium</button>
|
|
1095
|
-
<button class="filter-priority-btn" data-priority="low">low</button>
|
|
1096
|
-
</div>
|
|
1097
|
-
<div class="filter-group">
|
|
1098
|
-
<span class="filter-label">Tags</span>
|
|
1099
|
-
<div id="filter-tags-control" style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;"></div>
|
|
1100
|
-
</div>
|
|
1101
|
-
<div class="filter-group">
|
|
1102
|
-
<span class="filter-label">Assignee</span>
|
|
1103
|
-
<input type="text" id="filter-assignee" class="filter-assignee-input" placeholder="Filter by assignee">
|
|
1104
|
-
</div>
|
|
1105
|
-
<button class="filter-clear-btn" id="filter-clear">Clear filters</button>
|
|
1106
|
-
</div>
|
|
1107
|
-
<div class="board-container">
|
|
1108
|
-
<div class="board">${columns}</div>${BOARD_BODY_STATIC}
|
|
1109
|
-
</div>
|
|
1110
|
-
</body>
|
|
1111
|
-
</html>`;
|
|
1112
|
-
}
|
|
1113
|
-
function sortByPriority(tasks) {
|
|
1114
|
-
return [...tasks].sort((a, b) => {
|
|
1115
|
-
const oa = a.priority ? models_1.PRIORITY_ORDER[a.priority] : 4;
|
|
1116
|
-
const ob = b.priority ? models_1.PRIORITY_ORDER[b.priority] : 4;
|
|
1117
|
-
return oa - ob;
|
|
1118
|
-
});
|
|
1119
|
-
}
|
|
1120
|
-
function buildTaskUpdateInput(body) {
|
|
1121
|
-
const input = {};
|
|
1122
|
-
if (body.status !== undefined) {
|
|
1123
|
-
if (!STATUSES.includes(body.status)) {
|
|
1124
|
-
return { input, error: 'Invalid status' };
|
|
1125
|
-
}
|
|
1126
|
-
input.status = body.status;
|
|
1127
|
-
}
|
|
1128
|
-
if (body.title !== undefined) {
|
|
1129
|
-
if (!body.title.trim()) {
|
|
1130
|
-
return { input, error: 'Title cannot be empty' };
|
|
1131
|
-
}
|
|
1132
|
-
input.title = body.title.trim();
|
|
1133
|
-
}
|
|
1134
|
-
if (body.body !== undefined) {
|
|
1135
|
-
input.body = body.body ?? '';
|
|
1136
|
-
}
|
|
1137
|
-
if (body.priority !== undefined) {
|
|
1138
|
-
input.priority = body.priority && (0, models_1.isPriority)(body.priority) ? body.priority : null;
|
|
1139
|
-
}
|
|
1140
|
-
return { input };
|
|
1141
|
-
}
|
|
1142
|
-
function buildTasksByStatus(tasks) {
|
|
1143
|
-
const tasksByStatus = new Map();
|
|
1144
|
-
for (const status of STATUSES) {
|
|
1145
|
-
tasksByStatus.set(status, []);
|
|
1146
|
-
}
|
|
1147
|
-
for (const task of tasks) {
|
|
1148
|
-
tasksByStatus.get(task.status)?.push(task);
|
|
1149
|
-
}
|
|
1150
|
-
for (const [status, statusTasks] of tasksByStatus) {
|
|
1151
|
-
tasksByStatus.set(status, sortByPriority(statusTasks));
|
|
1152
|
-
}
|
|
1153
|
-
return tasksByStatus;
|
|
1154
|
-
}
|
|
1155
|
-
function getBoardUpdatedAt(database) {
|
|
1156
|
-
const baseRow = database
|
|
1157
|
-
.prepare(`
|
|
1158
|
-
SELECT MAX(updated_at) as max_updated_at FROM (
|
|
1159
|
-
SELECT updated_at FROM tasks UNION ALL SELECT updated_at FROM task_metadata
|
|
1160
|
-
)
|
|
1161
|
-
`)
|
|
1162
|
-
.get();
|
|
1163
|
-
const tagsRow = database
|
|
1164
|
-
.prepare(`
|
|
1165
|
-
SELECT MAX(created_at) as max_created_at, COUNT(*) as count FROM task_tags
|
|
1166
|
-
`)
|
|
1167
|
-
.get();
|
|
1168
|
-
if (baseRow.max_updated_at === null && tagsRow.max_created_at === null)
|
|
1169
|
-
return null;
|
|
1170
|
-
return `${baseRow.max_updated_at}|${tagsRow.max_created_at}|${tagsRow.count}`;
|
|
1171
|
-
}
|
|
1172
|
-
function registerTaskApiRoutes(app, { ts, tts, tags, ms }) {
|
|
1173
|
-
app.get('/api/tasks', (c) => c.json({ tasks: ts.listTasks({}, 'id', 'asc') }));
|
|
1174
|
-
app.post('/api/tasks', async (c) => {
|
|
1175
|
-
const body = await c.req.json();
|
|
1176
|
-
if (!body.title || typeof body.title !== 'string' || !body.title.trim()) {
|
|
1177
|
-
return c.json({ error: 'Title is required' }, 400);
|
|
1178
|
-
}
|
|
1179
|
-
const status = body.status && STATUSES.includes(body.status) ? body.status : 'backlog';
|
|
1180
|
-
const priority = body.priority && (0, models_1.isPriority)(body.priority) ? body.priority : undefined;
|
|
1181
|
-
return c.json(ts.createTask({ title: body.title.trim(), body: body.body || undefined, status, priority }), 201);
|
|
1182
|
-
});
|
|
1183
|
-
app.get('/api/tasks/:id', (c) => {
|
|
1184
|
-
const id = Number(c.req.param('id'));
|
|
1185
|
-
if (isNaN(id))
|
|
1186
|
-
return c.json({ error: 'Invalid task id' }, 400);
|
|
1187
|
-
const task = ts.getTask(id);
|
|
1188
|
-
if (!task)
|
|
1189
|
-
return c.json({ error: 'Task not found' }, 404);
|
|
1190
|
-
return c.json({ task, tags: tts.getTagsForTask(id), metadata: ms.listMetadata(id) });
|
|
1191
|
-
});
|
|
1192
|
-
app.patch('/api/tasks/:id', async (c) => {
|
|
1193
|
-
const id = Number(c.req.param('id'));
|
|
1194
|
-
if (isNaN(id))
|
|
1195
|
-
return c.json({ error: 'Invalid task id' }, 400);
|
|
1196
|
-
const { input, error } = buildTaskUpdateInput(await c.req.json());
|
|
1197
|
-
if (error)
|
|
1198
|
-
return c.json({ error }, 400);
|
|
1199
|
-
const task = ts.updateTask(id, input);
|
|
1200
|
-
if (!task)
|
|
1201
|
-
return c.json({ error: 'Task not found' }, 404);
|
|
1202
|
-
return c.json(task);
|
|
1203
|
-
});
|
|
1204
|
-
app.delete('/api/tasks/:id', (c) => {
|
|
1205
|
-
const id = Number(c.req.param('id'));
|
|
1206
|
-
if (isNaN(id))
|
|
1207
|
-
return c.json({ error: 'Invalid task id' }, 400);
|
|
1208
|
-
if (!ts.getTask(id))
|
|
1209
|
-
return c.json({ error: 'Task not found' }, 404);
|
|
1210
|
-
ts.deleteTask(id);
|
|
1211
|
-
return c.json({ success: true });
|
|
1212
|
-
});
|
|
1213
|
-
app.get('/api/tags', (c) => c.json({ tags: tags.listTags() }));
|
|
1214
|
-
app.post('/api/tasks/:id/tags', async (c) => {
|
|
1215
|
-
const id = Number(c.req.param('id'));
|
|
1216
|
-
if (isNaN(id))
|
|
1217
|
-
return c.json({ error: 'Invalid task id' }, 400);
|
|
1218
|
-
const body = await c.req.json();
|
|
1219
|
-
if (body.tagId === undefined || body.tagId === null)
|
|
1220
|
-
return c.json({ error: 'tagId is required' }, 400);
|
|
1221
|
-
const tagId = Number(body.tagId);
|
|
1222
|
-
if (!ts.getTask(id))
|
|
1223
|
-
return c.json({ error: 'Task not found' }, 404);
|
|
1224
|
-
if (!tags.getTag(tagId))
|
|
1225
|
-
return c.json({ error: 'Tag not found' }, 404);
|
|
1226
|
-
tts.addTagToTask({ task_id: id, tag_id: tagId });
|
|
1227
|
-
return c.json({ success: true }, 201);
|
|
1228
|
-
});
|
|
1229
|
-
app.delete('/api/tasks/:id/tags/:tagId', (c) => {
|
|
1230
|
-
const id = Number(c.req.param('id'));
|
|
1231
|
-
if (isNaN(id))
|
|
1232
|
-
return c.json({ error: 'Invalid task id' }, 400);
|
|
1233
|
-
const tagId = Number(c.req.param('tagId'));
|
|
1234
|
-
if (isNaN(tagId))
|
|
1235
|
-
return c.json({ error: 'Invalid tag id' }, 400);
|
|
1236
|
-
const removed = tts.removeTagFromTask(id, tagId);
|
|
1237
|
-
if (!removed)
|
|
1238
|
-
return c.json({ error: 'Tag not attached to task' }, 404);
|
|
1239
|
-
return c.json({ success: true });
|
|
1240
|
-
});
|
|
1241
|
-
}
|
|
1242
|
-
function buildBoardCardsPayload(tasksByStatus, tagMap) {
|
|
1243
|
-
return STATUSES.map((status) => {
|
|
1244
|
-
const tasks = tasksByStatus.get(status) || [];
|
|
1245
|
-
const html = tasks.map((t) => renderCard(t, tagMap.get(t.id) || [])).join('');
|
|
1246
|
-
return { status, html, count: tasks.length };
|
|
1247
|
-
});
|
|
1248
|
-
}
|
|
1249
|
-
function parseBoardCardFilters(query) {
|
|
1250
|
-
const filters = {};
|
|
1251
|
-
if (query.tags) {
|
|
1252
|
-
const tagIds = query.tags
|
|
1253
|
-
.split(',')
|
|
1254
|
-
.map((s) => Number(s.trim()))
|
|
1255
|
-
.filter((n) => !isNaN(n) && n > 0);
|
|
1256
|
-
if (tagIds.length > 0)
|
|
1257
|
-
filters.tagIds = tagIds;
|
|
1258
|
-
}
|
|
1259
|
-
if (query.priority) {
|
|
1260
|
-
const priorities = query.priority
|
|
1261
|
-
.split(',')
|
|
1262
|
-
.map((s) => s.trim())
|
|
1263
|
-
.filter((s) => s.length > 0);
|
|
1264
|
-
if (priorities.length > 0)
|
|
1265
|
-
filters.priority = priorities;
|
|
1266
|
-
}
|
|
1267
|
-
if (query.assignee && query.assignee.trim()) {
|
|
1268
|
-
filters.assignees = query.assignee.trim();
|
|
1269
|
-
}
|
|
1270
|
-
return filters;
|
|
1271
|
-
}
|
|
1272
|
-
function registerConfigApiRoutes(app, configDir) {
|
|
1273
|
-
app.get('/api/config', (c) => {
|
|
1274
|
-
const boardConfig = (0, boardConfig_1.readBoardConfig)(configDir);
|
|
1275
|
-
return c.json({ board: boardConfig });
|
|
1276
|
-
});
|
|
1277
|
-
app.put('/api/config', async (c) => {
|
|
1278
|
-
const body = await c.req.json();
|
|
1279
|
-
const boardBody = body?.board ?? {};
|
|
1280
|
-
if (boardBody.detailPaneWidth !== undefined) {
|
|
1281
|
-
const width = boardBody.detailPaneWidth;
|
|
1282
|
-
if (typeof width !== 'number' || !Number.isFinite(width)) {
|
|
1283
|
-
return c.json({ error: 'detailPaneWidth must be a number' }, 400);
|
|
1284
|
-
}
|
|
1285
|
-
if (width > boardConfig_1.DETAIL_PANE_MAX_WIDTH) {
|
|
1286
|
-
return c.json({ error: `detailPaneWidth must not exceed ${boardConfig_1.DETAIL_PANE_MAX_WIDTH}` }, 400);
|
|
1287
|
-
}
|
|
1288
|
-
(0, boardConfig_1.writeBoardConfig)(configDir, { detailPaneWidth: width });
|
|
1289
|
-
}
|
|
1290
|
-
return c.json({ success: true });
|
|
1291
|
-
});
|
|
1292
|
-
}
|
|
1293
|
-
function registerBoardRoutes(app, services) {
|
|
1294
|
-
const { ts, tts, database, boardTitle, configDir } = services;
|
|
1295
|
-
app.get('/', (c) => {
|
|
1296
|
-
const tasksByStatus = buildTasksByStatus(ts.listTasks({}, 'id', 'asc'));
|
|
1297
|
-
return c.html(renderBoard(tasksByStatus, tts.getAllTaskTags(), boardTitle));
|
|
1298
|
-
});
|
|
1299
|
-
app.get('/api/board/updated-at', (c) => c.json({ updatedAt: getBoardUpdatedAt(database) }));
|
|
1300
|
-
app.get('/api/board/cards', (c) => {
|
|
1301
|
-
const filters = parseBoardCardFilters({
|
|
1302
|
-
tags: c.req.query('tags'),
|
|
1303
|
-
priority: c.req.query('priority'),
|
|
1304
|
-
assignee: c.req.query('assignee'),
|
|
1305
|
-
});
|
|
1306
|
-
const tasksByStatus = buildTasksByStatus(ts.listTasks(filters, 'id', 'asc'));
|
|
1307
|
-
const columns = buildBoardCardsPayload(tasksByStatus, tts.getAllTaskTags());
|
|
1308
|
-
return c.json({ columns });
|
|
1309
|
-
});
|
|
1310
|
-
registerTaskApiRoutes(app, services);
|
|
1311
|
-
registerConfigApiRoutes(app, configDir);
|
|
1312
|
-
}
|
|
1313
|
-
function createBoardApp(taskService, taskTagService, metadataService, db, boardTitle, tagService, configDir) {
|
|
19
|
+
const boardRoutes_1 = require("./boardRoutes");
|
|
20
|
+
function createBoardApp(taskService, taskTagService, metadataService, db, boardTitle, tagService, configDir, commentService, taskBlockService) {
|
|
1314
21
|
const app = new hono_1.Hono();
|
|
1315
22
|
const resolvedConfigDir = configDir ?? path_1.default.join(process.cwd(), (0, config_1.getDefaultDirName)());
|
|
23
|
+
const resolvedDb = db ?? (0, connection_1.getDatabase)();
|
|
1316
24
|
const services = {
|
|
1317
25
|
ts: taskService ?? new TaskService_1.TaskService(),
|
|
1318
26
|
tts: taskTagService ?? new TaskTagService_1.TaskTagService(),
|
|
1319
27
|
tags: tagService ?? new TagService_1.TagService(),
|
|
1320
28
|
ms: metadataService ?? new MetadataService_1.MetadataService(),
|
|
1321
|
-
|
|
29
|
+
cs: commentService ?? new CommentService_1.CommentService(resolvedDb),
|
|
30
|
+
tbs: taskBlockService ?? new TaskBlockService_1.TaskBlockService(resolvedDb),
|
|
31
|
+
database: resolvedDb,
|
|
1322
32
|
boardTitle,
|
|
1323
33
|
configDir: resolvedConfigDir,
|
|
1324
34
|
};
|
|
1325
|
-
registerBoardRoutes(app, services);
|
|
35
|
+
(0, boardRoutes_1.registerBoardRoutes)(app, services);
|
|
1326
36
|
return app;
|
|
1327
37
|
}
|
|
1328
38
|
function startBoardServer(port, boardTitle) {
|
|
1329
39
|
const app = createBoardApp(undefined, undefined, undefined, undefined, boardTitle);
|
|
1330
|
-
(0, node_server_1.serve)({
|
|
1331
|
-
|
|
40
|
+
(0, node_server_1.serve)({
|
|
41
|
+
fetch: app.fetch,
|
|
42
|
+
port,
|
|
43
|
+
}, (info) => {
|
|
44
|
+
console.log(`Server is running on http://localhost:${info.port}`);
|
|
1332
45
|
});
|
|
1333
46
|
}
|
|
1334
47
|
//# sourceMappingURL=server.js.map
|