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