kanbanqube 1.0.1

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/public/app.js ADDED
@@ -0,0 +1,2897 @@
1
+ "use strict";
2
+
3
+ const USER_STORAGE_KEY = "kanbanqube.userName";
4
+ const USER_EMAIL_STORAGE_KEY = "kanbanqube.userEmail";
5
+ const SHOW_CARD_DESCRIPTIONS_STORAGE_KEY = "kanbanqube.showCardDescriptions";
6
+ const INLINE_CARD_TITLE_EDIT_STORAGE_KEY = "kanbanqube.inlineCardTitleEdit";
7
+ const GIT_SYNC_IN_BACKGROUND_STORAGE_KEY = "kanbanqube.gitSyncInBackground";
8
+ const ICON_STYLE_STORAGE_KEY = "kanbanqube.iconStyle";
9
+ const ICON_PATHS = {
10
+ "3d": "/icon_3d.png",
11
+ flat: "/icon_flat.png"
12
+ };
13
+ const DEMO_BOARD_PATH = "/demo_board.json";
14
+ const SYNC_TIMESTAMP_FORMAT = {
15
+ month: "short",
16
+ day: "numeric",
17
+ year: "numeric",
18
+ hour: "numeric",
19
+ minute: "2-digit",
20
+ second: "2-digit",
21
+ hour12: true
22
+ };
23
+
24
+ const state = {
25
+ board: null,
26
+ config: null,
27
+ currentUserName: localStorage.getItem(USER_STORAGE_KEY) || "",
28
+ currentUserEmail: localStorage.getItem(USER_EMAIL_STORAGE_KEY) || "",
29
+ showCardDescriptions: localStorage.getItem(SHOW_CARD_DESCRIPTIONS_STORAGE_KEY) === "true",
30
+ inlineCardTitleEdit: localStorage.getItem(INLINE_CARD_TITLE_EDIT_STORAGE_KEY) === "true",
31
+ gitSyncInBackground: localStorage.getItem(GIT_SYNC_IN_BACKGROUND_STORAGE_KEY) === "true",
32
+ iconStyle: localStorage.getItem(ICON_STYLE_STORAGE_KEY) === "flat" ? "flat" : "3d",
33
+ searchTerm: "",
34
+ labelSearchTerm: "",
35
+ labelEditorOpen: false,
36
+ descriptionEditing: false,
37
+ selectedCardId: null,
38
+ keyboardCardId: null,
39
+ saveTimer: null,
40
+ isSaving: false,
41
+ saveMessage: "Loading board…",
42
+ syncStatusMessage: "",
43
+ lastSyncLog: "",
44
+ lastSyncAt: "",
45
+ isSyncing: false,
46
+ editingBoardTitle: false,
47
+ editingBoardTitleValue: "",
48
+ editingLaneTitleId: null,
49
+ editingLaneTitleValue: "",
50
+ editingCardTitleId: null,
51
+ editingCardTitleValue: "",
52
+ pendingNewCardIds: new Set(),
53
+ drag: null
54
+ };
55
+
56
+ const boardScroller = document.getElementById("boardScroller");
57
+ const aboutButton = document.getElementById("aboutButton");
58
+ const brandIcon = aboutButton.querySelector("img");
59
+ const aboutDialog = document.getElementById("aboutDialog");
60
+ const aboutImage = document.getElementById("aboutImage");
61
+ const faviconLink = document.getElementById("faviconLink");
62
+ const boardTitle = document.getElementById("boardTitle");
63
+ const boardTitleInlineInput = document.getElementById("boardTitleInlineInput");
64
+ const userBadge = document.getElementById("userBadge");
65
+ const searchInput = document.getElementById("searchInput");
66
+ const saveStatus = document.getElementById("saveStatus");
67
+ const syncButton = document.getElementById("syncButton");
68
+ const archiveButton = document.getElementById("archiveButton");
69
+ const settingsButton = document.getElementById("settingsButton");
70
+ const syncLogDialog = document.getElementById("syncLogDialog");
71
+ const syncLogContent = document.getElementById("syncLogContent");
72
+ const syncLogTimestamp = document.getElementById("syncLogTimestamp");
73
+ const closeSyncLogButton = document.getElementById("closeSyncLogButton");
74
+ const syncLogCloseButton = document.getElementById("syncLogCloseButton");
75
+ const archiveDialog = document.getElementById("archiveDialog");
76
+ const archiveList = document.getElementById("archiveList");
77
+ const closeArchiveButton = document.getElementById("closeArchiveButton");
78
+
79
+ const cardDialog = document.getElementById("cardDialog");
80
+ const cardTitleInput = document.getElementById("cardTitleInput");
81
+ const archivedCardBanner = document.getElementById("archivedCardBanner");
82
+ const cardDetailsCover = document.getElementById("cardDetailsCover");
83
+ const removeCoverButton = document.getElementById("removeCoverButton");
84
+ const cardDescriptionDisplay = document.getElementById("cardDescriptionDisplay");
85
+ const cardDescriptionInput = document.getElementById("cardDescriptionInput");
86
+ const attachmentInput = document.getElementById("attachmentInput");
87
+ const addAttachmentButton = document.getElementById("addAttachmentButton");
88
+ const attachmentDropZone = document.getElementById("attachmentDropZone");
89
+ const attachmentsContainer = document.getElementById("attachmentsContainer");
90
+ const editDescriptionButton = document.getElementById("editDescriptionButton");
91
+ const checklistsContainer = document.getElementById("checklistsContainer");
92
+ const cardLabels = document.getElementById("cardLabels");
93
+ const labelEditorContainer = document.getElementById("labelEditorContainer");
94
+ const activityList = document.getElementById("activityList");
95
+ const addLabelButton = document.getElementById("addLabelButton");
96
+ const commentInput = document.getElementById("commentInput");
97
+ const addCommentButton = document.getElementById("addCommentButton");
98
+ const addChecklistButton = document.getElementById("addChecklistButton");
99
+ const deleteCardButton = document.getElementById("deleteCardButton");
100
+ const closeCardButton = document.getElementById("closeCardButton");
101
+
102
+ const settingsDialog = document.getElementById("settingsDialog");
103
+ const settingsUserName = document.getElementById("settingsUserName");
104
+ const settingsUserEmail = document.getElementById("settingsUserEmail");
105
+ const settingsIconStyleInputs = [...document.querySelectorAll("input[name=\"settingsIconStyle\"]")];
106
+ const settingsShowCardDescriptions = document.getElementById("settingsShowCardDescriptions");
107
+ const settingsInlineCardTitleEdit = document.getElementById("settingsInlineCardTitleEdit");
108
+ const settingsGitSyncInBackground = document.getElementById("settingsGitSyncInBackground");
109
+ const importBoardInput = document.getElementById("importBoardInput");
110
+ const importBoardButton = document.getElementById("importBoardButton");
111
+ const settingsImportHelp = document.getElementById("settingsImportHelp");
112
+ const settingsBoardFile = document.getElementById("settingsBoardFile");
113
+ const settingsRemote = document.getElementById("settingsRemote");
114
+ const saveSettingsButton = document.getElementById("saveSettingsButton");
115
+ const closeSettingsButton = document.getElementById("closeSettingsButton");
116
+
117
+ const promptDialog = document.getElementById("promptDialog");
118
+ const promptLabel = document.getElementById("promptLabel");
119
+ const promptTitle = document.getElementById("promptTitle");
120
+ const promptInputLabel = document.getElementById("promptInputLabel");
121
+ const promptInput = document.getElementById("promptInput");
122
+ const promptConfirmButton = document.getElementById("promptConfirmButton");
123
+ const promptCancelButton = document.getElementById("promptCancelButton");
124
+
125
+ const appDialog = document.getElementById("appDialog");
126
+ const appDialogLabel = document.getElementById("appDialogLabel");
127
+ const appDialogTitle = document.getElementById("appDialogTitle");
128
+ const appDialogMessage = document.getElementById("appDialogMessage");
129
+ const appDialogCloseButton = document.getElementById("appDialogCloseButton");
130
+ const appDialogCancelButton = document.getElementById("appDialogCancelButton");
131
+ const appDialogConfirmButton = document.getElementById("appDialogConfirmButton");
132
+
133
+ const laneTemplate = document.getElementById("laneTemplate");
134
+ const cardTemplate = document.getElementById("cardTemplate");
135
+ let syncStatusPollTimer = null;
136
+ let syncStatusPollInFlight = false;
137
+
138
+ const labelColorMap = {
139
+ green: "#22c55e",
140
+ yellow: "#f4b400",
141
+ orange: "#fb923c",
142
+ red: "#ef4444",
143
+ purple: "#8b5cf6",
144
+ blue: "#3b82f6",
145
+ sky: "#38bdf8",
146
+ lime: "#84cc16",
147
+ pink: "#ec4899",
148
+ black: "#1f2937",
149
+ green_dark: "#15803d",
150
+ yellow_dark: "#a16207",
151
+ orange_dark: "#c2410c",
152
+ red_dark: "#991b1b",
153
+ purple_dark: "#5b21b6",
154
+ blue_dark: "#1d4ed8",
155
+ sky_dark: "#0369a1",
156
+ lime_dark: "#4d7c0f",
157
+ pink_dark: "#9d174d",
158
+ black_dark: "#111827"
159
+ };
160
+
161
+ try {
162
+ await bootstrap();
163
+ } catch (error) {
164
+ setSaveMessage(error.message || "Could not load the board.");
165
+ }
166
+
167
+ async function bootstrap() {
168
+ const [boardResponse, configResponse] = await Promise.all([
169
+ fetch("/api/board"),
170
+ fetch("/api/config")
171
+ ]);
172
+
173
+ if (!boardResponse.ok) {
174
+ throw new Error("Could not load board data.");
175
+ }
176
+
177
+ state.board = await boardResponse.json();
178
+ state.config = configResponse.ok ? await configResponse.json() : { boardFile: "board.json", gitRemote: null, gitUserName: null, gitUserEmail: null };
179
+ hydrateIdentityFromGitConfig();
180
+ applyIconStyle();
181
+ const storageLabel = state.config.storagePath ? `${state.config.storagePath}/` : (state.config.boardFile || "board.json");
182
+ settingsBoardFile.textContent = `Storage: ${storageLabel}`;
183
+ settingsRemote.textContent = state.config.gitRemote ? `Remote: ${state.config.gitRemote}` : "Remote: not configured";
184
+
185
+ wireEvents();
186
+ if (migrateLegacyBoardData()) {
187
+ setSaveMessage("Updating board format…");
188
+ await saveBoardNow();
189
+ } else {
190
+ setSaveMessage("Ready");
191
+ }
192
+ render();
193
+ await maybeOfferDemoBoard();
194
+ }
195
+
196
+ async function maybeOfferDemoBoard() {
197
+ if (!isBoardEmpty()) return;
198
+ const confirmed = await openConfirmDialog({
199
+ label: "Demo board",
200
+ title: "Load demo board?",
201
+ message: "This board is empty. Load a sample e-commerce product board with realistic cards?",
202
+ confirmLabel: "Load demo",
203
+ cancelLabel: "Keep empty"
204
+ });
205
+ if (!confirmed || !isBoardEmpty()) return;
206
+
207
+ setSaveMessage("Loading demo board...");
208
+ state.board = await loadDemoBoard();
209
+ render();
210
+ await saveBoardNow();
211
+ setSaveMessage("Demo board loaded");
212
+ render();
213
+ }
214
+
215
+ function isBoardEmpty() {
216
+ return (state.board?.cards || []).length === 0;
217
+ }
218
+
219
+ async function loadDemoBoard() {
220
+ const response = await fetch(DEMO_BOARD_PATH);
221
+ if (!response.ok) {
222
+ throw new Error("Could not load demo board.");
223
+ }
224
+ return response.json();
225
+ }
226
+
227
+ function wireEvents() {
228
+ aboutButton.addEventListener("click", openAboutDialog);
229
+ aboutDialog.addEventListener("click", (event) => {
230
+ if (event.target === aboutDialog) {
231
+ aboutDialog.close();
232
+ }
233
+ });
234
+ aboutImage.addEventListener("click", () => aboutDialog.close());
235
+ document.addEventListener("keydown", handleBoardKeyboardNavigation);
236
+
237
+ searchInput.addEventListener("input", () => {
238
+ state.searchTerm = searchInput.value.trim().toLowerCase();
239
+ renderBoard();
240
+ });
241
+
242
+ settingsButton.addEventListener("click", openSettingsDialog);
243
+ archiveButton.addEventListener("click", openArchiveDialog);
244
+ boardTitle.addEventListener("click", startBoardTitleEdit);
245
+ closeSettingsButton.addEventListener("click", () => settingsDialog.close());
246
+ closeArchiveButton.addEventListener("click", () => archiveDialog.close());
247
+ saveSettingsButton.addEventListener("click", saveSettings);
248
+ importBoardButton.addEventListener("click", () => importBoardInput.click());
249
+ importBoardInput.addEventListener("change", () => {
250
+ const file = importBoardInput.files?.[0];
251
+ importBoardInput.value = "";
252
+ if (file) importBoardFromFile(file);
253
+ });
254
+
255
+ syncButton.addEventListener("click", syncBoard);
256
+ saveStatus.addEventListener("click", openSyncLogDialog);
257
+ closeSyncLogButton.addEventListener("click", () => syncLogDialog.close());
258
+ syncLogCloseButton.addEventListener("click", () => syncLogDialog.close());
259
+ boardScroller.addEventListener("dragover", handleLaneDragOver);
260
+ boardScroller.addEventListener("drop", handleLaneDrop);
261
+
262
+ closeCardButton.addEventListener("click", () => cardDialog.close());
263
+ removeCoverButton.addEventListener("click", removeCoverFromSelectedCard);
264
+ deleteCardButton.addEventListener("click", deleteSelectedCard);
265
+ addLabelButton.addEventListener("click", toggleLabelEditor);
266
+ addCommentButton.addEventListener("click", addCommentToSelectedCard);
267
+ addChecklistButton.addEventListener("click", addChecklistToSelectedCard);
268
+ addAttachmentButton.addEventListener("click", () => attachmentInput.click());
269
+ attachmentInput.addEventListener("change", () => {
270
+ const files = [...attachmentInput.files];
271
+ attachmentInput.value = "";
272
+ uploadFilesToSelectedCard(files);
273
+ });
274
+ attachmentDropZone.addEventListener("dragover", handleAttachmentDragOver);
275
+ attachmentDropZone.addEventListener("dragleave", handleAttachmentDragLeave);
276
+ attachmentDropZone.addEventListener("drop", handleAttachmentDrop);
277
+ cardDialog.addEventListener("dragover", handleAttachmentDragOver);
278
+ cardDialog.addEventListener("dragleave", handleAttachmentDragLeave);
279
+ cardDialog.addEventListener("drop", handleAttachmentDrop);
280
+ editDescriptionButton.addEventListener("click", toggleDescriptionEditing);
281
+
282
+ commentInput.addEventListener("keydown", (event) => {
283
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
284
+ event.preventDefault();
285
+ addCommentToSelectedCard();
286
+ }
287
+ });
288
+
289
+ cardTitleInput.addEventListener("input", () => {
290
+ const card = getSelectedCard();
291
+ if (!card) return;
292
+ card.name = cardTitleInput.value;
293
+ touchCard(card);
294
+ queueSave("Card updated");
295
+ renderBoard();
296
+ if (archiveDialog.open) {
297
+ renderArchiveDialog();
298
+ }
299
+ });
300
+
301
+ cardDescriptionInput.addEventListener("input", () => {
302
+ const card = getSelectedCard();
303
+ if (!card) return;
304
+ card.desc = cardDescriptionInput.value;
305
+ touchCard(card);
306
+ queueSave("Card updated");
307
+ renderBoard();
308
+ renderCardDialog();
309
+ });
310
+
311
+ promptCancelButton.addEventListener("click", () => promptDialog.close("cancel"));
312
+ appDialogCloseButton.addEventListener("click", () => appDialog.close("cancel"));
313
+ appDialogCancelButton.addEventListener("click", () => appDialog.close("cancel"));
314
+
315
+ promptDialog.querySelector("form").addEventListener("submit", (event) => {
316
+ event.preventDefault();
317
+ promptDialog.close("confirm");
318
+ });
319
+
320
+ boardTitleInlineInput.addEventListener("click", (event) => {
321
+ event.stopPropagation();
322
+ });
323
+ boardTitleInlineInput.addEventListener("input", () => {
324
+ state.editingBoardTitleValue = boardTitleInlineInput.value;
325
+ });
326
+ boardTitleInlineInput.addEventListener("keydown", (event) => {
327
+ if (event.key === "Enter") {
328
+ event.preventDefault();
329
+ commitBoardTitleEdit();
330
+ } else if (event.key === "Escape") {
331
+ event.preventDefault();
332
+ cancelBoardTitleEdit();
333
+ }
334
+ });
335
+ boardTitleInlineInput.addEventListener("blur", commitBoardTitleEdit);
336
+
337
+ cardDialog.addEventListener("close", () => {
338
+ state.selectedCardId = null;
339
+ state.descriptionEditing = false;
340
+ });
341
+ }
342
+
343
+ function openAboutDialog() {
344
+ if (!aboutDialog.open) {
345
+ aboutDialog.showModal();
346
+ }
347
+ }
348
+
349
+ function applyIconStyle() {
350
+ const path = ICON_PATHS[state.iconStyle] || ICON_PATHS["3d"];
351
+ brandIcon.src = path;
352
+ faviconLink.href = path;
353
+ }
354
+
355
+ function render() {
356
+ renderHeader();
357
+ renderBoard();
358
+ renderCardDialog();
359
+ renderArchiveDialog();
360
+ }
361
+
362
+ function renderHeader() {
363
+ boardTitle.textContent = state.board?.name || "KanbanQube Board";
364
+ boardTitle.hidden = state.editingBoardTitle;
365
+ boardTitle.classList.toggle("is-inline-editable", true);
366
+ boardTitleInlineInput.hidden = !state.editingBoardTitle;
367
+ boardTitleInlineInput.value = state.editingBoardTitleValue;
368
+ userBadge.textContent = state.currentUserName.trim() || "Guest";
369
+ const archivedCount = archivedCards().length;
370
+ archiveButton.textContent = archivedCount ? `Archive (${archivedCount})` : "Archive";
371
+ const canSync = Boolean(state.config?.gitRemote);
372
+ syncButton.disabled = state.isSyncing || !canSync;
373
+ syncButton.title = canSync ? "" : "Git remote not configured";
374
+ saveStatus.textContent = state.syncStatusMessage || state.saveMessage;
375
+ const canOpenLog = state.isSyncing || Boolean(state.lastSyncLog.trim());
376
+ saveStatus.disabled = !canOpenLog;
377
+ saveStatus.classList.toggle("is-clickable", canOpenLog);
378
+ }
379
+
380
+ function renderBoard() {
381
+ boardScroller.textContent = "";
382
+ const lists = openLists();
383
+
384
+ for (const list of lists) {
385
+ const laneNode = laneTemplate.content.firstElementChild.cloneNode(true);
386
+ laneNode.dataset.listId = list.id;
387
+ const laneTitle = laneNode.querySelector(".lane-title");
388
+ const laneTitleInput = laneNode.querySelector(".lane-title-inline-input");
389
+ const isEditingLaneTitle = state.editingLaneTitleId === list.id;
390
+ laneTitle.textContent = list.name || "Lane";
391
+ laneTitle.classList.toggle("is-inline-editable", true);
392
+ laneTitle.hidden = isEditingLaneTitle;
393
+ laneTitleInput.hidden = !isEditingLaneTitle;
394
+ laneTitleInput.value = state.editingLaneTitleValue;
395
+ laneTitle.addEventListener("click", () => startLaneTitleEdit(list.id));
396
+ laneTitleInput.addEventListener("click", (event) => {
397
+ event.stopPropagation();
398
+ });
399
+ laneTitleInput.addEventListener("input", () => {
400
+ state.editingLaneTitleValue = laneTitleInput.value;
401
+ });
402
+ laneTitleInput.addEventListener("keydown", (event) => {
403
+ event.stopPropagation();
404
+ if (event.key === "Enter") {
405
+ event.preventDefault();
406
+ commitLaneTitleEdit(list.id);
407
+ } else if (event.key === "Escape") {
408
+ event.preventDefault();
409
+ cancelLaneTitleEdit();
410
+ }
411
+ });
412
+ laneTitleInput.addEventListener("blur", () => commitLaneTitleEdit(list.id));
413
+
414
+ const cards = visibleCardsForList(list.id);
415
+ laneNode.querySelector(".lane-count").textContent = String(cardsForList(list.id).length);
416
+
417
+ const cardList = laneNode.querySelector(".card-list");
418
+ for (const card of cards) {
419
+ const cardNode = renderCard(card);
420
+ cardList.append(cardNode);
421
+ }
422
+
423
+ laneNode.querySelector(".add-card-button").addEventListener("click", () => addCard(list.id));
424
+ laneNode.querySelector(".delete-lane-button").addEventListener("click", () => deleteLane(list.id));
425
+
426
+ enableCardDnD(laneNode, list.id);
427
+ enableLaneDnD(laneNode);
428
+
429
+ boardScroller.append(laneNode);
430
+ }
431
+
432
+ const addLaneCard = document.createElement("section");
433
+ addLaneCard.className = "add-lane-card";
434
+ const button = document.createElement("button");
435
+ button.type = "button";
436
+ button.className = "ghost-button";
437
+ button.textContent = "+ Add another lane";
438
+ button.addEventListener("click", createLane);
439
+ addLaneCard.append(button);
440
+ boardScroller.append(addLaneCard);
441
+ }
442
+
443
+ async function createLane() {
444
+ const title = await openPrompt({
445
+ label: "Lane",
446
+ title: "Create a lane",
447
+ inputLabel: "Lane name",
448
+ confirmLabel: "Create",
449
+ value: ""
450
+ });
451
+
452
+ if (!title) return;
453
+
454
+ const list = {
455
+ id: createHexId(),
456
+ idBoard: state.board.id,
457
+ name: title,
458
+ closed: false,
459
+ pos: nextLanePos()
460
+ };
461
+
462
+ state.board.lists.push(list);
463
+ pushAction("createList", {
464
+ list: { id: list.id, name: list.name },
465
+ board: { id: state.board.id, name: state.board.name }
466
+ });
467
+ queueSave("Lane added");
468
+ render();
469
+ }
470
+
471
+ function renderCard(card) {
472
+ const node = cardTemplate.content.firstElementChild.cloneNode(true);
473
+ node.dataset.cardId = card.id;
474
+ node.classList.toggle("is-done", isCardDone(card));
475
+ node.classList.toggle("is-keyboard-selected", state.keyboardCardId === card.id);
476
+ node.setAttribute("aria-selected", String(state.keyboardCardId === card.id));
477
+
478
+ const cardLabelsStrip = node.querySelector(".card-label-strip");
479
+ const labels = labelsForCard(card).slice(0, 4);
480
+ for (const label of labels) {
481
+ const labelNode = document.createElement("span");
482
+ labelNode.className = "card-label";
483
+ labelNode.style.background = colorForLabel(label.color);
484
+ labelNode.title = label.name || label.color;
485
+ cardLabelsStrip.append(labelNode);
486
+ }
487
+
488
+ const coverNode = node.querySelector(".card-cover");
489
+ const coverUrl = coverUrlForCard(card);
490
+ if (coverUrl) {
491
+ coverNode.classList.add("has-cover");
492
+ coverNode.style.backgroundImage = `url("${coverUrl}")`;
493
+ }
494
+
495
+ const doneToggle = node.querySelector(".card-done-toggle");
496
+ const done = isCardDone(card);
497
+ doneToggle.textContent = done ? "✓" : "";
498
+ doneToggle.setAttribute("aria-pressed", String(done));
499
+ doneToggle.setAttribute("aria-label", done ? "Mark task not done" : "Mark task done");
500
+ doneToggle.addEventListener("click", (event) => {
501
+ event.stopPropagation();
502
+ toggleCardDone(card);
503
+ });
504
+
505
+ const archiveButton = node.querySelector(".card-archive-button");
506
+ archiveButton.hidden = !done;
507
+ archiveButton.addEventListener("click", (event) => {
508
+ event.stopPropagation();
509
+ archiveCard(card);
510
+ });
511
+
512
+ const titleNode = node.querySelector(".card-title");
513
+ const titleInput = node.querySelector(".card-title-inline-input");
514
+ const hasTitle = Boolean(card.name?.trim());
515
+ const isEditingTitle = state.editingCardTitleId === card.id;
516
+ titleNode.textContent = hasTitle ? card.name : "Task";
517
+ titleNode.classList.toggle("is-placeholder", !hasTitle);
518
+ titleNode.classList.toggle("is-done", done);
519
+ titleNode.classList.toggle("is-inline-editable", state.inlineCardTitleEdit || isEditingTitle);
520
+ titleNode.hidden = isEditingTitle;
521
+ titleInput.hidden = !isEditingTitle;
522
+ titleInput.value = state.editingCardTitleValue;
523
+ titleInput.addEventListener("click", (event) => {
524
+ event.stopPropagation();
525
+ });
526
+ if (state.inlineCardTitleEdit) {
527
+ titleNode.addEventListener("click", (event) => {
528
+ event.stopPropagation();
529
+ startCardTitleEdit(card.id);
530
+ });
531
+ }
532
+ titleInput.addEventListener("input", () => {
533
+ state.editingCardTitleValue = titleInput.value;
534
+ });
535
+ titleInput.addEventListener("keydown", (event) => {
536
+ event.stopPropagation();
537
+ if (event.key === "Enter") {
538
+ event.preventDefault();
539
+ commitCardTitleEdit(card.id);
540
+ } else if (event.key === "Escape") {
541
+ event.preventDefault();
542
+ cancelCardTitleEdit();
543
+ }
544
+ });
545
+ titleInput.addEventListener("blur", () => commitCardTitleEdit(card.id));
546
+ const descriptionNode = node.querySelector(".card-description");
547
+ descriptionNode.textContent = card.desc || "";
548
+ descriptionNode.hidden = !state.showCardDescriptions || !card.desc;
549
+
550
+ const footer = node.querySelector(".card-footer");
551
+ for (const badge of buildCardBadges(card)) {
552
+ const badgeNode = document.createElement("span");
553
+ badgeNode.className = "badge";
554
+ if (badge.symbol) {
555
+ const symbol = document.createElement("span");
556
+ symbol.className = "badge-symbol";
557
+ symbol.textContent = badge.symbol;
558
+ badgeNode.append(symbol);
559
+ } else {
560
+ badgeNode.append(createIcon(badge.icon));
561
+ }
562
+ if (badge.text) {
563
+ const text = document.createElement("span");
564
+ text.textContent = badge.text;
565
+ badgeNode.append(text);
566
+ }
567
+ footer.append(badgeNode);
568
+ }
569
+
570
+ node.addEventListener("click", () => {
571
+ setKeyboardCard(card.id, true);
572
+ openCard(card.id);
573
+ });
574
+ node.addEventListener("dragover", (event) => {
575
+ if (!eventHasFiles(event)) return;
576
+ event.preventDefault();
577
+ event.stopPropagation();
578
+ event.dataTransfer.dropEffect = "copy";
579
+ node.classList.add("is-file-drop-target");
580
+ });
581
+ node.addEventListener("dragleave", () => {
582
+ node.classList.remove("is-file-drop-target");
583
+ });
584
+ node.addEventListener("drop", (event) => {
585
+ if (!eventHasFiles(event)) return;
586
+ event.preventDefault();
587
+ event.stopPropagation();
588
+ node.classList.remove("is-file-drop-target");
589
+ uploadFilesToCard(card.id, [...event.dataTransfer.files]);
590
+ });
591
+ return node;
592
+ }
593
+
594
+ function renderCardDialog() {
595
+ const card = getSelectedCard();
596
+ if (!card) return;
597
+
598
+ cardTitleInput.value = card.name || "";
599
+ archivedCardBanner.hidden = !isCardArchived(card);
600
+ const coverUrl = coverUrlForCard(card);
601
+ cardDetailsCover.hidden = !coverUrl;
602
+ cardDetailsCover.style.backgroundImage = coverUrl ? `url("${coverUrl}")` : "";
603
+ removeCoverButton.hidden = !coverUrl;
604
+ cardDescriptionInput.value = card.desc || "";
605
+ commentInput.value = "";
606
+ renderDescriptionDisplay(card.desc || "");
607
+ cardDescriptionDisplay.hidden = state.descriptionEditing;
608
+ cardDescriptionInput.hidden = !state.descriptionEditing;
609
+ editDescriptionButton.textContent = state.descriptionEditing ? "Done" : "Edit";
610
+
611
+ renderLabelsEditor(card);
612
+ renderAttachments(card);
613
+ renderChecklists(card);
614
+ renderActivity(card);
615
+ }
616
+
617
+ function renderArchiveDialog() {
618
+ archiveList.textContent = "";
619
+ const cards = archivedCards();
620
+
621
+ if (cards.length === 0) {
622
+ const empty = document.createElement("div");
623
+ empty.className = "empty-state archive-empty-state";
624
+ empty.textContent = "No archived cards.";
625
+ archiveList.append(empty);
626
+ return;
627
+ }
628
+
629
+ for (const card of cards) {
630
+ const row = document.createElement("article");
631
+ row.className = "archive-item";
632
+ row.tabIndex = 0;
633
+
634
+ const main = document.createElement("div");
635
+ main.className = "archive-item-main";
636
+
637
+ const title = document.createElement("h3");
638
+ title.className = "archive-item-title";
639
+ title.textContent = card.name?.trim() || "Task";
640
+ main.append(title);
641
+
642
+ const meta = document.createElement("p");
643
+ meta.className = "archive-item-meta";
644
+ const lane = listById(card.idList);
645
+ meta.textContent = `In ${lane?.name || "Unknown lane"}`;
646
+ main.append(meta);
647
+
648
+ const actions = document.createElement("div");
649
+ actions.className = "archive-item-actions";
650
+
651
+ const restoreButton = document.createElement("button");
652
+ restoreButton.type = "button";
653
+ restoreButton.className = "ghost-button";
654
+ restoreButton.textContent = "Restore";
655
+ restoreButton.addEventListener("click", (event) => {
656
+ event.stopPropagation();
657
+ restoreArchivedCard(card.id);
658
+ });
659
+ actions.append(restoreButton);
660
+
661
+ const deleteButton = document.createElement("button");
662
+ deleteButton.type = "button";
663
+ deleteButton.className = "danger-button";
664
+ deleteButton.textContent = "Delete";
665
+ deleteButton.addEventListener("click", (event) => {
666
+ event.stopPropagation();
667
+ deleteArchivedCard(card.id);
668
+ });
669
+ actions.append(deleteButton);
670
+
671
+ row.append(main, actions);
672
+ row.addEventListener("click", () => openArchivedCard(card.id));
673
+ row.addEventListener("keydown", (event) => {
674
+ if (event.key === "Enter" || event.key === " ") {
675
+ event.preventDefault();
676
+ openArchivedCard(card.id);
677
+ }
678
+ });
679
+ archiveList.append(row);
680
+ }
681
+ }
682
+
683
+ function renderLabelsEditor(card) {
684
+ cardLabels.textContent = "";
685
+ labelEditorContainer.textContent = "";
686
+
687
+ const assignedLabels = labelsForCard(card);
688
+ if (assignedLabels.length === 0) {
689
+ const empty = document.createElement("div");
690
+ empty.className = "empty-state label-summary-trigger";
691
+ empty.textContent = "No labels assigned.";
692
+ empty.addEventListener("click", openLabelEditor);
693
+ cardLabels.append(empty);
694
+ } else {
695
+ for (const label of assignedLabels) {
696
+ const pill = document.createElement("button");
697
+ pill.type = "button";
698
+ pill.className = "label-pill";
699
+ pill.textContent = label.name || label.color;
700
+ pill.style.background = colorForLabel(label.color);
701
+ pill.addEventListener("click", openLabelEditor);
702
+ cardLabels.append(pill);
703
+ }
704
+ }
705
+
706
+ addLabelButton.textContent = state.labelEditorOpen ? "Ă—" : "+";
707
+ addLabelButton.setAttribute("aria-label", state.labelEditorOpen ? "Close labels panel" : "Open labels panel");
708
+ if (!state.labelEditorOpen) return;
709
+
710
+ const labels = sortedBoardLabels();
711
+ const searchTerm = state.labelSearchTerm.trim().toLowerCase();
712
+ const filteredLabels = searchTerm
713
+ ? labels.filter((label) => `${label.name || ""} ${label.color || ""}`.toLowerCase().includes(searchTerm))
714
+ : labels;
715
+
716
+ const panel = document.createElement("section");
717
+ panel.className = "label-editor-panel";
718
+
719
+ const searchInput = document.createElement("input");
720
+ searchInput.className = "label-search-input";
721
+ searchInput.type = "search";
722
+ searchInput.placeholder = "Search labels…";
723
+ searchInput.value = state.labelSearchTerm;
724
+ searchInput.addEventListener("input", () => {
725
+ state.labelSearchTerm = searchInput.value;
726
+ renderCardDialog();
727
+ });
728
+ panel.append(searchInput);
729
+
730
+ if (labels.length === 0) {
731
+ const empty = document.createElement("div");
732
+ empty.className = "empty-state";
733
+ empty.textContent = "No labels yet. Add one for this card.";
734
+ panel.append(empty);
735
+ const createButton = document.createElement("button");
736
+ createButton.type = "button";
737
+ createButton.className = "ghost-button";
738
+ createButton.textContent = "Create a new label";
739
+ createButton.addEventListener("click", addLabelToSelectedCard);
740
+ panel.append(createButton);
741
+ appendLabelEditorPanel(panel);
742
+ return;
743
+ }
744
+
745
+ const list = document.createElement("div");
746
+ list.className = "label-editor-list";
747
+
748
+ for (const label of filteredLabels) {
749
+ const row = document.createElement("div");
750
+ row.className = "label-editor-row";
751
+
752
+ const toggle = document.createElement("input");
753
+ toggle.className = "label-toggle";
754
+ toggle.type = "checkbox";
755
+ toggle.checked = card.idLabels.includes(label.id);
756
+ toggle.addEventListener("change", () => {
757
+ if (toggle.checked) {
758
+ if (!card.idLabels.includes(label.id)) card.idLabels.push(label.id);
759
+ } else {
760
+ card.idLabels = card.idLabels.filter((labelId) => labelId !== label.id);
761
+ }
762
+ touchCard(card);
763
+ queueSave("Labels updated");
764
+ renderCardDialog();
765
+ });
766
+
767
+ const nameInput = document.createElement("input");
768
+ nameInput.className = "label-name-input";
769
+ nameInput.type = "text";
770
+ nameInput.value = label.name || "";
771
+ nameInput.placeholder = "Label name";
772
+ nameInput.style.backgroundColor = colorForLabel(label.color);
773
+ nameInput.addEventListener("input", () => {
774
+ label.name = nameInput.value.trim();
775
+ queueSave("Label updated");
776
+ renderCardDialog();
777
+ renderBoard();
778
+ });
779
+
780
+ const colorSelect = document.createElement("select");
781
+ colorSelect.className = "label-color-select";
782
+ for (const color of labelColorOptions()) {
783
+ const option = document.createElement("option");
784
+ option.value = color;
785
+ option.textContent = color.replaceAll("_", " ");
786
+ option.selected = color === label.color;
787
+ colorSelect.append(option);
788
+ }
789
+ colorSelect.addEventListener("change", () => {
790
+ label.color = colorSelect.value;
791
+ queueSave("Label updated");
792
+ renderCardDialog();
793
+ renderBoard();
794
+ });
795
+
796
+ const removeButton = document.createElement("button");
797
+ removeButton.type = "button";
798
+ removeButton.className = "icon-button";
799
+ removeButton.append(createIcon("trash"));
800
+ removeButton.addEventListener("click", () => {
801
+ deleteLabel(label.id);
802
+ queueSave("Label removed");
803
+ render();
804
+ });
805
+
806
+ row.append(toggle, nameInput, colorSelect, removeButton);
807
+ list.append(row);
808
+ }
809
+
810
+ if (filteredLabels.length === 0) {
811
+ const empty = document.createElement("div");
812
+ empty.className = "empty-state";
813
+ empty.textContent = "No labels match that search.";
814
+ panel.append(empty);
815
+ } else {
816
+ panel.append(list);
817
+ }
818
+
819
+ const createButton = document.createElement("button");
820
+ createButton.type = "button";
821
+ createButton.className = "ghost-button";
822
+ createButton.textContent = "Create a new label";
823
+ createButton.addEventListener("click", addLabelToSelectedCard);
824
+ panel.append(createButton);
825
+
826
+ appendLabelEditorPanel(panel);
827
+ }
828
+
829
+ function appendLabelEditorPanel(panel) {
830
+ const anchor = labelEditorContainer.closest(".sidebar-panel") || labelEditorContainer;
831
+ const rect = anchor.getBoundingClientRect();
832
+ const margin = 16;
833
+ const width = Math.min(rect.width, window.innerWidth - margin * 2);
834
+ const left = Math.min(Math.max(rect.right - width, margin), window.innerWidth - width - margin);
835
+ const top = Math.min(rect.bottom - 4, window.innerHeight - margin - 220);
836
+ const maxHeight = Math.max(220, window.innerHeight - top - margin);
837
+
838
+ panel.style.left = `${Math.round(left)}px`;
839
+ panel.style.top = `${Math.round(top)}px`;
840
+ panel.style.width = `${Math.round(width)}px`;
841
+ panel.style.maxHeight = `${Math.round(maxHeight)}px`;
842
+ labelEditorContainer.append(panel);
843
+ }
844
+
845
+ function renderAttachments(card) {
846
+ attachmentsContainer.textContent = "";
847
+ attachmentDropZone.classList.remove("is-dragging");
848
+
849
+ const attachments = Array.isArray(card.attachments) ? card.attachments : [];
850
+
851
+ if (attachments.length === 0) {
852
+ const empty = document.createElement("div");
853
+ empty.className = "empty-state";
854
+ empty.textContent = "No attachments yet.";
855
+ attachmentsContainer.append(empty);
856
+ return;
857
+ }
858
+
859
+ for (const attachment of attachments) {
860
+ const row = document.createElement("a");
861
+ row.className = "attachment-row";
862
+ row.href = attachment.url;
863
+ row.target = "_blank";
864
+ row.rel = "noopener noreferrer";
865
+
866
+ const icon = document.createElement("span");
867
+ icon.className = "attachment-icon";
868
+ icon.append(createIcon(isImageAttachment(attachment) ? "image" : "file"));
869
+
870
+ const main = document.createElement("span");
871
+ main.className = "attachment-main";
872
+
873
+ const name = document.createElement("strong");
874
+ name.textContent = attachment.name || "Attachment";
875
+ main.append(name);
876
+
877
+ const meta = document.createElement("span");
878
+ meta.textContent = formatAttachmentMeta(attachment);
879
+ main.append(meta);
880
+
881
+ row.append(icon, main);
882
+ if (isImageAttachment(attachment)) {
883
+ const coverButton = document.createElement("button");
884
+ coverButton.type = "button";
885
+ coverButton.className = "ghost-button attachment-cover-button";
886
+ coverButton.textContent = card.cover?.idAttachment === attachment.id ? "Cover" : "Make cover";
887
+ coverButton.disabled = card.cover?.idAttachment === attachment.id;
888
+ coverButton.addEventListener("click", (event) => {
889
+ event.preventDefault();
890
+ event.stopPropagation();
891
+ setAttachmentAsCover(card.id, attachment.id);
892
+ });
893
+ row.append(coverButton);
894
+ }
895
+ const deleteButton = document.createElement("button");
896
+ deleteButton.type = "button";
897
+ deleteButton.className = "icon-button attachment-delete-button";
898
+ deleteButton.setAttribute("aria-label", `Delete ${attachment.name || "attachment"}`);
899
+ deleteButton.append(createIcon("trash"));
900
+ deleteButton.addEventListener("click", (event) => {
901
+ event.preventDefault();
902
+ event.stopPropagation();
903
+ removeAttachmentFromCard(card.id, attachment.id);
904
+ });
905
+ row.append(deleteButton);
906
+ attachmentsContainer.append(row);
907
+ }
908
+ }
909
+
910
+ function renderChecklists(card) {
911
+ checklistsContainer.textContent = "";
912
+ const checklists = checklistsForCard(card.id);
913
+
914
+ if (checklists.length === 0) {
915
+ const empty = document.createElement("div");
916
+ empty.className = "empty-state";
917
+ empty.textContent = "No checklists yet.";
918
+ checklistsContainer.append(empty);
919
+ return;
920
+ }
921
+
922
+ for (const checklist of checklists) {
923
+ const checklistNode = document.createElement("section");
924
+ checklistNode.className = "checklist";
925
+
926
+ const header = document.createElement("div");
927
+ header.className = "checklist-header";
928
+
929
+ const titleInput = document.createElement("input");
930
+ titleInput.className = "checklist-title-input";
931
+ titleInput.value = checklist.name;
932
+ titleInput.addEventListener("input", () => {
933
+ checklist.name = titleInput.value.trim() || "Checklist";
934
+ queueSave("Checklist updated");
935
+ });
936
+
937
+ const removeChecklistButton = document.createElement("button");
938
+ removeChecklistButton.type = "button";
939
+ removeChecklistButton.className = "icon-button";
940
+ removeChecklistButton.append(createIcon("trash"));
941
+ removeChecklistButton.addEventListener("click", () => {
942
+ state.board.checklists = state.board.checklists.filter((item) => item.id !== checklist.id);
943
+ card.idChecklists = card.idChecklists.filter((checklistId) => checklistId !== checklist.id);
944
+ touchCard(card);
945
+ queueSave("Checklist removed");
946
+ renderCardDialog();
947
+ renderBoard();
948
+ });
949
+
950
+ header.append(titleInput, removeChecklistButton);
951
+ checklistNode.append(header);
952
+
953
+ const itemsNode = document.createElement("div");
954
+ itemsNode.className = "checklist-items";
955
+
956
+ for (const item of checklist.checkItems) {
957
+ const row = document.createElement("label");
958
+ row.className = `checklist-item${item.state === "complete" ? " completed" : ""}`;
959
+
960
+ const checkbox = document.createElement("input");
961
+ checkbox.type = "checkbox";
962
+ checkbox.checked = item.state === "complete";
963
+ checkbox.addEventListener("change", () => {
964
+ item.state = checkbox.checked ? "complete" : "incomplete";
965
+ row.classList.toggle("completed", checkbox.checked);
966
+ touchCard(card);
967
+ pushAction("updateCheckItemStateOnCard", {
968
+ idCard: card.id,
969
+ card: cardActionSnapshot(card),
970
+ checklist: { id: checklist.id, name: checklist.name },
971
+ checkItem: { id: item.id, name: item.name, state: item.state }
972
+ });
973
+ queueSave("Checklist updated");
974
+ renderBoard();
975
+ });
976
+
977
+ const input = document.createElement("input");
978
+ input.className = "checklist-item-input";
979
+ input.type = "text";
980
+ input.value = item.name;
981
+ input.addEventListener("input", () => {
982
+ item.name = input.value.trim() || "Checklist item";
983
+ queueSave("Checklist updated");
984
+ });
985
+
986
+ const removeButton = document.createElement("button");
987
+ removeButton.type = "button";
988
+ removeButton.className = "icon-button";
989
+ removeButton.textContent = "−";
990
+ removeButton.addEventListener("click", () => {
991
+ checklist.checkItems = checklist.checkItems.filter((candidate) => candidate.id !== item.id);
992
+ touchCard(card);
993
+ queueSave("Checklist updated");
994
+ renderCardDialog();
995
+ renderBoard();
996
+ });
997
+
998
+ row.append(checkbox, input, removeButton);
999
+ itemsNode.append(row);
1000
+ }
1001
+
1002
+ const addItemButton = document.createElement("button");
1003
+ addItemButton.type = "button";
1004
+ addItemButton.className = "ghost-button";
1005
+ addItemButton.textContent = "Add item";
1006
+ addItemButton.addEventListener("click", () => {
1007
+ checklist.checkItems.push({
1008
+ id: createHexId(),
1009
+ name: "New item",
1010
+ pos: nextChecklistItemPos(checklist),
1011
+ state: "incomplete",
1012
+ due: null,
1013
+ dueReminder: -1,
1014
+ idMember: null,
1015
+ idChecklist: checklist.id,
1016
+ nameData: { emoji: {} }
1017
+ });
1018
+ touchCard(card);
1019
+ queueSave("Checklist updated");
1020
+ renderCardDialog();
1021
+ renderBoard();
1022
+ });
1023
+
1024
+ checklistNode.append(itemsNode, addItemButton);
1025
+ checklistsContainer.append(checklistNode);
1026
+ }
1027
+ }
1028
+
1029
+ function renderActivity(card) {
1030
+ activityList.textContent = "";
1031
+ const actions = actionsForCard(card.id);
1032
+
1033
+ if (actions.length === 0) {
1034
+ const empty = document.createElement("div");
1035
+ empty.className = "empty-state";
1036
+ empty.textContent = "No recent activity for this card.";
1037
+ activityList.append(empty);
1038
+ return;
1039
+ }
1040
+
1041
+ for (const action of actions.slice(0, 8)) {
1042
+ const entry = document.createElement("article");
1043
+ entry.className = "activity-entry";
1044
+
1045
+ const title = document.createElement("strong");
1046
+ title.textContent = humanizeAction(action);
1047
+
1048
+ const detail = document.createElement("div");
1049
+ const detailText = action.data?.text || action.data?.attachment?.name || action.data?.checkItem?.name || action.data?.listAfter?.name || action.data?.list?.name || "";
1050
+ appendFormattedText(detail, detailText);
1051
+
1052
+ const time = document.createElement("time");
1053
+ time.dateTime = action.date;
1054
+ time.textContent = new Date(action.date).toLocaleString();
1055
+
1056
+ entry.append(title);
1057
+ if (detailText) entry.append(detail);
1058
+ entry.append(time);
1059
+ activityList.append(entry);
1060
+ }
1061
+ }
1062
+
1063
+ function addCommentToSelectedCard() {
1064
+ const card = getSelectedCard();
1065
+ if (!card) return;
1066
+ const text = commentInput.value.trim();
1067
+ if (!text) {
1068
+ commentInput.focus();
1069
+ return;
1070
+ }
1071
+
1072
+ touchCard(card);
1073
+ pushAction("commentCard", {
1074
+ idCard: card.id,
1075
+ text,
1076
+ textData: { emoji: {} },
1077
+ card: cardActionSnapshot(card),
1078
+ board: { id: state.board.id, name: state.board.name },
1079
+ list: { id: card.idList, name: listById(card.idList)?.name || "" }
1080
+ });
1081
+ if (card.badges) card.badges.comments = (card.badges.comments || 0) + 1;
1082
+ commentInput.value = "";
1083
+ queueSave("Comment added");
1084
+ render();
1085
+ }
1086
+
1087
+ function addLabelToSelectedCard() {
1088
+ const card = getSelectedCard();
1089
+ if (!card) return;
1090
+
1091
+ const label = {
1092
+ id: createHexId(),
1093
+ idBoard: state.board.id,
1094
+ name: "New label",
1095
+ color: "blue",
1096
+ uses: 0
1097
+ };
1098
+
1099
+ state.board.labels.push(label);
1100
+ if (!card.idLabels.includes(label.id)) card.idLabels.push(label.id);
1101
+ touchCard(card);
1102
+ state.labelEditorOpen = true;
1103
+ queueSave("Label added");
1104
+ render();
1105
+ }
1106
+
1107
+ function toggleLabelEditor() {
1108
+ state.labelEditorOpen = !state.labelEditorOpen;
1109
+ if (!state.labelEditorOpen) state.labelSearchTerm = "";
1110
+ renderCardDialog();
1111
+ }
1112
+
1113
+ function openLabelEditor() {
1114
+ state.labelEditorOpen = true;
1115
+ renderCardDialog();
1116
+ }
1117
+
1118
+ async function addCard(listId) {
1119
+ const lane = listById(listId);
1120
+ const card = {
1121
+ id: createHexId(),
1122
+ idBoard: state.board.id,
1123
+ idList: listId,
1124
+ name: "",
1125
+ desc: "",
1126
+ closed: false,
1127
+ pos: nextCardPos(listId),
1128
+ idLabels: [],
1129
+ idMembers: [],
1130
+ idChecklists: [],
1131
+ attachments: [],
1132
+ cover: {
1133
+ idAttachment: null,
1134
+ color: null,
1135
+ idUploadedBackground: null,
1136
+ size: "normal",
1137
+ brightness: "dark",
1138
+ yPosition: 0.5,
1139
+ idPlugin: null
1140
+ },
1141
+ due: null,
1142
+ dueComplete: false,
1143
+ start: null,
1144
+ subscribed: false,
1145
+ kanbanQubeDone: false,
1146
+ shortLink: createHexId().slice(-8),
1147
+ shortUrl: "",
1148
+ url: "",
1149
+ dateLastActivity: new Date().toISOString(),
1150
+ labels: [],
1151
+ badges: {
1152
+ attachments: 0,
1153
+ attachmentsByType: { trello: { board: 0, card: 0 } },
1154
+ checkItems: 0,
1155
+ checkItemsChecked: 0,
1156
+ comments: 0,
1157
+ description: false,
1158
+ due: null,
1159
+ dueComplete: false,
1160
+ externalSource: null,
1161
+ fogbugz: "",
1162
+ lastUpdatedByAi: false,
1163
+ location: false,
1164
+ maliciousAttachments: 0,
1165
+ start: null,
1166
+ subscribed: false,
1167
+ viewingMemberVoted: false,
1168
+ votes: 0
1169
+ }
1170
+ };
1171
+
1172
+ state.board.cards.push(card);
1173
+ state.editingCardTitleId = card.id;
1174
+ state.editingCardTitleValue = "";
1175
+ state.pendingNewCardIds.add(card.id);
1176
+ pushAction("createCard", {
1177
+ card: cardActionSnapshot(card),
1178
+ list: { id: listId, name: lane?.name || "Lane" },
1179
+ board: { id: state.board.id, name: state.board.name }
1180
+ });
1181
+ render();
1182
+ focusInlineInput(document.querySelector(`.card[data-card-id="${card.id}"] .card-title-inline-input`));
1183
+ }
1184
+
1185
+ async function uploadFilesToSelectedCard(files) {
1186
+ const card = getSelectedCard();
1187
+ if (!card) return;
1188
+ await uploadFilesToCard(card.id, files);
1189
+ }
1190
+
1191
+ async function uploadFilesToCard(cardId, files) {
1192
+ const card = (state.board?.cards || []).find((candidate) => candidate.id === cardId && !candidate.closed);
1193
+ const uploadFiles = files.filter((file) => file && file.size > 0);
1194
+ if (!card || uploadFiles.length === 0) return;
1195
+
1196
+ setSaveMessage(`Uploading ${uploadFiles.length} file${uploadFiles.length === 1 ? "" : "s"}…`);
1197
+
1198
+ try {
1199
+ const formData = new FormData();
1200
+ for (const file of uploadFiles) {
1201
+ formData.append("files", file, file.name);
1202
+ }
1203
+
1204
+ const response = await fetch("/api/uploads", {
1205
+ method: "POST",
1206
+ body: formData
1207
+ });
1208
+
1209
+ const payload = await response.json();
1210
+ if (!response.ok) {
1211
+ throw new Error(payload.error || "Upload failed.");
1212
+ }
1213
+
1214
+ const attachments = Array.isArray(payload.files) ? payload.files : [];
1215
+ if (attachments.length === 0) return;
1216
+
1217
+ card.attachments = Array.isArray(card.attachments) ? card.attachments : [];
1218
+ card.attachments.push(...attachments);
1219
+ if (card.badges) {
1220
+ card.badges.attachments = card.attachments.length;
1221
+ card.badges.attachmentsByType = {
1222
+ trello: { board: 0, card: card.attachments.length }
1223
+ };
1224
+ }
1225
+
1226
+ const firstImage = attachments.find(isImageAttachment);
1227
+ if (firstImage && !card.cover?.idAttachment) {
1228
+ card.cover = coverState(firstImage.id);
1229
+ }
1230
+
1231
+ touchCard(card);
1232
+ pushAction("addAttachmentToCard", {
1233
+ idCard: card.id,
1234
+ card: cardActionSnapshot(card),
1235
+ attachment: {
1236
+ id: attachments[0].id,
1237
+ name: attachments.length === 1 ? attachments[0].name : `${attachments.length} files`
1238
+ },
1239
+ list: { id: card.idList, name: listById(card.idList)?.name || "" }
1240
+ });
1241
+ queueSave("Attachment added");
1242
+ render();
1243
+ if (cardDialog.open && state.selectedCardId === card.id) {
1244
+ renderCardDialog();
1245
+ }
1246
+ } catch (error) {
1247
+ setSaveMessage(error.message || "Upload failed.");
1248
+ await openMessageDialog({
1249
+ label: "Upload",
1250
+ title: "Upload failed",
1251
+ message: error.message || "Upload failed."
1252
+ });
1253
+ }
1254
+ }
1255
+
1256
+ async function importBoardFromFile(file) {
1257
+ if ((state.board?.cards || []).length > 0) {
1258
+ await openMessageDialog({
1259
+ label: "Import",
1260
+ title: "Import unavailable",
1261
+ message: "Import is only available when the board has no cards."
1262
+ });
1263
+ return;
1264
+ }
1265
+
1266
+ const confirmed = await openConfirmDialog({
1267
+ label: "Import",
1268
+ title: "Import board JSON?",
1269
+ message: "This will replace the current empty board with the imported board.",
1270
+ confirmLabel: "Import",
1271
+ cancelLabel: "Cancel"
1272
+ });
1273
+ if (!confirmed) return;
1274
+
1275
+ setSaveMessage("Importing board…");
1276
+ try {
1277
+ const formData = new FormData();
1278
+ formData.append("file", file, file.name);
1279
+ const response = await fetch("/api/import", {
1280
+ method: "POST",
1281
+ body: formData
1282
+ });
1283
+ const payload = await response.json();
1284
+ if (!response.ok) {
1285
+ throw new Error(payload.error || "Import failed.");
1286
+ }
1287
+
1288
+ state.board = payload;
1289
+ state.selectedCardId = null;
1290
+ state.descriptionEditing = false;
1291
+ settingsDialog.close();
1292
+ setSaveMessage("Board imported");
1293
+ render();
1294
+ } catch (error) {
1295
+ setSaveMessage(error.message || "Import failed.");
1296
+ await openMessageDialog({
1297
+ label: "Import",
1298
+ title: "Import failed",
1299
+ message: error.message || "Import failed."
1300
+ });
1301
+ }
1302
+ }
1303
+
1304
+ async function removeAttachmentFromCard(cardId, attachmentId) {
1305
+ const card = (state.board?.cards || []).find((candidate) => candidate.id === cardId);
1306
+ const attachment = card?.attachments?.find((candidate) => candidate.id === attachmentId);
1307
+ if (!card || !attachment) return;
1308
+
1309
+ const confirmed = await openConfirmDialog({
1310
+ label: "Attachment",
1311
+ title: "Delete attachment?",
1312
+ message: `Remove "${attachment.name || "Attachment"}" from this card? Uploaded files are deleted from the vault when no other card uses them.`,
1313
+ confirmLabel: "Delete",
1314
+ cancelLabel: "Cancel",
1315
+ danger: true
1316
+ });
1317
+ if (!confirmed) return;
1318
+
1319
+ const storedName = storedUploadFileName(attachment);
1320
+ card.attachments = (card.attachments || []).filter((candidate) => candidate.id !== attachmentId);
1321
+ if (card.cover?.idAttachment === attachmentId) {
1322
+ card.cover = coverState(null);
1323
+ }
1324
+ if (card.badges) {
1325
+ card.badges.attachments = card.attachments.length;
1326
+ card.badges.attachmentsByType = {
1327
+ trello: { board: 0, card: card.attachments.length }
1328
+ };
1329
+ }
1330
+
1331
+ touchCard(card);
1332
+ pushAction("deleteAttachmentFromCard", {
1333
+ idCard: card.id,
1334
+ card: cardActionSnapshot(card),
1335
+ attachment: {
1336
+ id: attachment.id,
1337
+ name: attachment.name || "Attachment"
1338
+ },
1339
+ list: { id: card.idList, name: listById(card.idList)?.name || "" }
1340
+ });
1341
+
1342
+ try {
1343
+ setSaveMessage("Deleting attachment…");
1344
+ await saveBoardNow();
1345
+ if (storedName && isLocalUploadAttachment(attachment)) {
1346
+ const response = await fetch(`/api/uploads/${encodeURIComponent(storedName)}`, { method: "DELETE" });
1347
+ const payload = await response.json().catch(() => ({}));
1348
+ if (!response.ok) {
1349
+ throw new Error(payload.error || "Attachment metadata was removed, but the upload file could not be deleted.");
1350
+ }
1351
+ }
1352
+ setSaveMessage("Attachment deleted");
1353
+ render();
1354
+ } catch (error) {
1355
+ setSaveMessage(error.message || "Attachment delete failed.");
1356
+ await openMessageDialog({
1357
+ label: "Attachment",
1358
+ title: "Delete failed",
1359
+ message: error.message || "Attachment delete failed."
1360
+ });
1361
+ }
1362
+ }
1363
+
1364
+ function removeCoverFromSelectedCard(event) {
1365
+ event?.stopPropagation();
1366
+ const card = getSelectedCard();
1367
+ if (!card?.cover?.idAttachment) return;
1368
+
1369
+ card.cover = coverState(null);
1370
+ touchCard(card);
1371
+ pushAction("updateCard", {
1372
+ idCard: card.id,
1373
+ card: cardActionSnapshot(card),
1374
+ list: { id: card.idList, name: listById(card.idList)?.name || "" },
1375
+ old: { cover: true },
1376
+ cover: false
1377
+ });
1378
+ queueSave("Cover removed");
1379
+ render();
1380
+ }
1381
+
1382
+ function setAttachmentAsCover(cardId, attachmentId) {
1383
+ const card = (state.board?.cards || []).find((candidate) => candidate.id === cardId && !candidate.closed);
1384
+ const attachment = card?.attachments?.find((candidate) => candidate.id === attachmentId);
1385
+ if (!card || !attachment || !isImageAttachment(attachment)) return;
1386
+
1387
+ card.cover = coverState(attachment.id);
1388
+ touchCard(card);
1389
+ pushAction("updateCard", {
1390
+ idCard: card.id,
1391
+ card: cardActionSnapshot(card),
1392
+ list: { id: card.idList, name: listById(card.idList)?.name || "" },
1393
+ cover: true
1394
+ });
1395
+ queueSave("Cover updated");
1396
+ render();
1397
+ }
1398
+
1399
+ function toggleCardDone(card) {
1400
+ const wasDone = isCardDone(card);
1401
+ card.kanbanQubeDone = !wasDone;
1402
+ touchCard(card);
1403
+ pushAction("updateCard", {
1404
+ idCard: card.id,
1405
+ card: cardActionSnapshot(card),
1406
+ list: { id: card.idList, name: listById(card.idList)?.name || "" },
1407
+ old: { kanbanQubeDone: wasDone },
1408
+ kanbanQubeDone: card.kanbanQubeDone
1409
+ });
1410
+ queueSave(card.kanbanQubeDone ? "Card marked done" : "Card marked not done");
1411
+ render();
1412
+ }
1413
+
1414
+ function archiveCard(card, options = {}) {
1415
+ if ((!options.force && !isCardDone(card)) || isCardArchived(card)) return;
1416
+ card.closed = true;
1417
+ touchCard(card);
1418
+ pushAction("updateCard", {
1419
+ idCard: card.id,
1420
+ card: cardActionSnapshot(card),
1421
+ list: { id: card.idList, name: listById(card.idList)?.name || "" },
1422
+ old: { closed: false },
1423
+ closed: true
1424
+ });
1425
+ queueSave("Card archived");
1426
+ render();
1427
+ }
1428
+
1429
+ function restoreArchivedCard(cardId) {
1430
+ const card = (state.board?.cards || []).find((candidate) => candidate.id === cardId);
1431
+ if (!card || !isCardArchived(card)) return;
1432
+ card.closed = false;
1433
+ touchCard(card);
1434
+ pushAction("updateCard", {
1435
+ idCard: card.id,
1436
+ card: cardActionSnapshot(card),
1437
+ list: { id: card.idList, name: listById(card.idList)?.name || "" },
1438
+ old: { closed: true },
1439
+ closed: false
1440
+ });
1441
+ queueSave("Card restored");
1442
+ render();
1443
+ }
1444
+
1445
+ async function deleteArchivedCard(cardId) {
1446
+ const card = (state.board?.cards || []).find((candidate) => candidate.id === cardId);
1447
+ if (!card || !isCardArchived(card)) return;
1448
+ const confirmed = await openConfirmDialog({
1449
+ label: "Delete card",
1450
+ title: "Delete archived card?",
1451
+ message: `Delete "${card.name || "Task"}" permanently? This cannot be undone.`,
1452
+ confirmLabel: "Delete",
1453
+ danger: true
1454
+ });
1455
+ if (!confirmed) return;
1456
+ removeCardCompletely(card.id);
1457
+ queueSave("Archived card deleted");
1458
+ render();
1459
+ }
1460
+
1461
+ async function deleteLane(listId) {
1462
+ const list = listById(listId);
1463
+ if (!list) return;
1464
+ const cardCount = allCardsForList(listId).length;
1465
+ const confirmed = await openConfirmDialog({
1466
+ label: "Delete lane",
1467
+ title: "Delete lane?",
1468
+ message: `Delete "${list.name}" and archive ${cardCount} card(s) in it?`,
1469
+ confirmLabel: "Delete",
1470
+ danger: true
1471
+ });
1472
+ if (!confirmed) return;
1473
+
1474
+ list.closed = true;
1475
+ list.dateClosed = new Date().toISOString();
1476
+ for (const card of allCardsForList(listId)) {
1477
+ card.closed = true;
1478
+ }
1479
+ pushAction("updateList", {
1480
+ list: { id: list.id, name: list.name },
1481
+ old: { closed: false },
1482
+ board: { id: state.board.id, name: state.board.name }
1483
+ });
1484
+ queueSave("Lane deleted");
1485
+ render();
1486
+ }
1487
+
1488
+ function openCard(cardId) {
1489
+ state.keyboardCardId = cardId;
1490
+ state.selectedCardId = cardId;
1491
+ state.labelEditorOpen = false;
1492
+ state.labelSearchTerm = "";
1493
+ state.descriptionEditing = false;
1494
+ renderCardDialog();
1495
+ if (!cardDialog.open) {
1496
+ cardDialog.showModal();
1497
+ }
1498
+ }
1499
+
1500
+ function handleBoardKeyboardNavigation(event) {
1501
+ if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter", " ", "c", "C"].includes(event.key) && !/^[1-9]$/.test(event.key)) return;
1502
+ if (isTypingTarget(event.target) || document.querySelector("dialog[open]")) return;
1503
+
1504
+ if (event.key === "Enter") {
1505
+ if (!state.keyboardCardId || !visibleCardById(state.keyboardCardId)) return;
1506
+ event.preventDefault();
1507
+ openCard(state.keyboardCardId);
1508
+ return;
1509
+ }
1510
+
1511
+ if (event.key === " ") {
1512
+ const card = keyboardSelectedVisibleCard();
1513
+ if (!card) return;
1514
+ event.preventDefault();
1515
+ toggleCardDone(card);
1516
+ return;
1517
+ }
1518
+
1519
+ if (event.key === "c" || event.key === "C") {
1520
+ const card = keyboardSelectedVisibleCard();
1521
+ if (!card) return;
1522
+ event.preventDefault();
1523
+ state.keyboardCardId = null;
1524
+ archiveCard(card, { force: true });
1525
+ return;
1526
+ }
1527
+
1528
+ if (/^[1-9]$/.test(event.key)) {
1529
+ const card = keyboardSelectedVisibleCard();
1530
+ if (!card) return;
1531
+ event.preventDefault();
1532
+ toggleKeyboardLabel(card, Number(event.key) - 1);
1533
+ return;
1534
+ }
1535
+
1536
+ event.preventDefault();
1537
+ moveKeyboardCardSelection(event.key);
1538
+ }
1539
+
1540
+ function isTypingTarget(target) {
1541
+ const element = target instanceof Element ? target : null;
1542
+ if (!element) return false;
1543
+ return Boolean(element.closest("input, textarea, select, [contenteditable='true']"));
1544
+ }
1545
+
1546
+ function moveKeyboardCardSelection(key) {
1547
+ const lanes = openLists().map((list) => ({
1548
+ id: list.id,
1549
+ cards: visibleCardsForList(list.id)
1550
+ }));
1551
+ const nonEmptyLane = lanes.find((lane) => lane.cards.length > 0);
1552
+ if (!nonEmptyLane) return;
1553
+
1554
+ let laneIndex = lanes.findIndex((lane) => lane.cards.some((card) => card.id === state.keyboardCardId));
1555
+ let cardIndex = laneIndex >= 0
1556
+ ? lanes[laneIndex].cards.findIndex((card) => card.id === state.keyboardCardId)
1557
+ : -1;
1558
+
1559
+ if (laneIndex < 0 || cardIndex < 0) {
1560
+ setKeyboardCard(nonEmptyLane.cards[0].id, true);
1561
+ return;
1562
+ }
1563
+
1564
+ if (key === "ArrowUp") {
1565
+ cardIndex = Math.max(0, cardIndex - 1);
1566
+ } else if (key === "ArrowDown") {
1567
+ cardIndex = Math.min(lanes[laneIndex].cards.length - 1, cardIndex + 1);
1568
+ } else if (key === "ArrowLeft" || key === "ArrowRight") {
1569
+ const direction = key === "ArrowRight" ? 1 : -1;
1570
+ const nextLaneIndex = nextLaneWithCards(lanes, laneIndex, direction);
1571
+ if (nextLaneIndex === -1) return;
1572
+ laneIndex = nextLaneIndex;
1573
+ cardIndex = Math.min(cardIndex, lanes[laneIndex].cards.length - 1);
1574
+ }
1575
+
1576
+ setKeyboardCard(lanes[laneIndex].cards[cardIndex].id, true);
1577
+ }
1578
+
1579
+ function nextLaneWithCards(lanes, startIndex, direction) {
1580
+ for (let index = startIndex + direction; index >= 0 && index < lanes.length; index += direction) {
1581
+ if (lanes[index].cards.length > 0) return index;
1582
+ }
1583
+ return -1;
1584
+ }
1585
+
1586
+ function visibleCardById(cardId) {
1587
+ return openLists().some((list) => visibleCardsForList(list.id).some((card) => card.id === cardId));
1588
+ }
1589
+
1590
+ function keyboardSelectedVisibleCard() {
1591
+ if (!state.keyboardCardId) return null;
1592
+ for (const list of openLists()) {
1593
+ const card = visibleCardsForList(list.id).find((candidate) => candidate.id === state.keyboardCardId);
1594
+ if (card) return card;
1595
+ }
1596
+ return null;
1597
+ }
1598
+
1599
+ function toggleKeyboardLabel(card, labelIndex) {
1600
+ const label = sortedBoardLabels()[labelIndex];
1601
+ if (!label) return;
1602
+
1603
+ card.idLabels = Array.isArray(card.idLabels) ? card.idLabels : [];
1604
+ const previousLabels = [...card.idLabels];
1605
+ const hadLabel = previousLabels.includes(label.id);
1606
+ card.idLabels = hadLabel
1607
+ ? previousLabels.filter((labelId) => labelId !== label.id)
1608
+ : [...previousLabels, label.id];
1609
+ touchCard(card);
1610
+ pushAction("updateCard", {
1611
+ idCard: card.id,
1612
+ card: cardActionSnapshot(card),
1613
+ list: { id: card.idList, name: listById(card.idList)?.name || "" },
1614
+ label: { id: label.id, name: label.name || label.color, color: label.color },
1615
+ old: { idLabels: previousLabels },
1616
+ idLabels: card.idLabels
1617
+ });
1618
+ queueSave(hadLabel ? "Label removed" : "Label added");
1619
+ renderBoard();
1620
+ }
1621
+
1622
+ function sortedBoardLabels() {
1623
+ return [...(state.board?.labels || [])].sort((left, right) => {
1624
+ const leftName = (left.name || left.color || "").toLowerCase();
1625
+ const rightName = (right.name || right.color || "").toLowerCase();
1626
+ return leftName.localeCompare(rightName);
1627
+ });
1628
+ }
1629
+
1630
+ function setKeyboardCard(cardId, shouldRender = true) {
1631
+ state.keyboardCardId = cardId;
1632
+ if (shouldRender) renderBoard();
1633
+ requestAnimationFrame(() => {
1634
+ document.querySelector(`.card[data-card-id="${cardId}"]`)?.scrollIntoView({
1635
+ block: "nearest",
1636
+ inline: "nearest"
1637
+ });
1638
+ });
1639
+ }
1640
+
1641
+ async function deleteSelectedCard() {
1642
+ const card = getSelectedCard();
1643
+ if (!card) return;
1644
+ const confirmed = await openConfirmDialog({
1645
+ label: "Delete card",
1646
+ title: "Delete card?",
1647
+ message: `Delete "${card.name || "Task"}" permanently? This cannot be undone.`,
1648
+ confirmLabel: "Delete",
1649
+ danger: true
1650
+ });
1651
+ if (!confirmed) return;
1652
+ removeCardCompletely(card.id);
1653
+ queueSave("Card deleted");
1654
+ cardDialog.close();
1655
+ render();
1656
+ }
1657
+
1658
+ function addChecklistToSelectedCard() {
1659
+ const card = getSelectedCard();
1660
+ if (!card) return;
1661
+ const checklist = {
1662
+ id: createHexId(),
1663
+ idBoard: state.board.id,
1664
+ idCard: card.id,
1665
+ name: `Checklist ${card.idChecklists.length + 1}`,
1666
+ checkItems: []
1667
+ };
1668
+ state.board.checklists.push(checklist);
1669
+ card.idChecklists.push(checklist.id);
1670
+ touchCard(card);
1671
+ queueSave("Checklist added");
1672
+ renderCardDialog();
1673
+ renderBoard();
1674
+ }
1675
+
1676
+ function toggleDescriptionEditing() {
1677
+ state.descriptionEditing = !state.descriptionEditing;
1678
+ renderCardDialog();
1679
+ if (state.descriptionEditing) {
1680
+ cardDescriptionInput.focus();
1681
+ cardDescriptionInput.setSelectionRange(cardDescriptionInput.value.length, cardDescriptionInput.value.length);
1682
+ }
1683
+ }
1684
+
1685
+ function renderDescriptionDisplay(text) {
1686
+ const content = typeof text === "string" ? text : "";
1687
+ cardDescriptionDisplay.textContent = "";
1688
+ cardDescriptionDisplay.classList.toggle("is-empty", !content.trim());
1689
+
1690
+ if (!content.trim()) {
1691
+ cardDescriptionDisplay.textContent = "No description yet.";
1692
+ return;
1693
+ }
1694
+
1695
+ const fragment = document.createDocumentFragment();
1696
+ const blocks = content.split(/\n{2,}/);
1697
+
1698
+ for (const block of blocks) {
1699
+ const heading = parseMarkdownHeading(block);
1700
+ const node = heading
1701
+ ? document.createElement(`h${heading.level}`)
1702
+ : document.createElement("p");
1703
+ const blockContent = heading ? heading.text : block;
1704
+ const lines = blockContent.split("\n");
1705
+
1706
+ lines.forEach((line, index) => {
1707
+ appendFormattedLine(node, line);
1708
+ if (index < lines.length - 1) {
1709
+ node.append(document.createElement("br"));
1710
+ }
1711
+ });
1712
+
1713
+ fragment.append(node);
1714
+ }
1715
+
1716
+ cardDescriptionDisplay.append(fragment);
1717
+ }
1718
+
1719
+ function appendFormattedLine(container, text) {
1720
+ const image = parseMarkdownImage(text);
1721
+ if (!image) {
1722
+ appendFormattedText(container, text);
1723
+ return;
1724
+ }
1725
+
1726
+ const imageNode = createSafeImage(image.href, unescapeMarkdownText(image.alt), image.title);
1727
+ if (imageNode) {
1728
+ container.append(imageNode);
1729
+ return;
1730
+ }
1731
+
1732
+ appendFormattedText(container, text);
1733
+ }
1734
+
1735
+ function appendFormattedText(container, text) {
1736
+ let index = 0;
1737
+
1738
+ while (index < text.length) {
1739
+ const match = findNextFormattedLink(text, index);
1740
+ if (!match) {
1741
+ container.append(document.createTextNode(text.slice(index)));
1742
+ return;
1743
+ }
1744
+
1745
+ if (match.start > index) {
1746
+ container.append(document.createTextNode(text.slice(index, match.start)));
1747
+ }
1748
+
1749
+ const link = createSafeLink(match.label || match.href, match.href, match.title || "");
1750
+
1751
+ if (link) {
1752
+ container.append(link);
1753
+ } else {
1754
+ container.append(document.createTextNode(text.slice(match.start, match.end)));
1755
+ }
1756
+
1757
+ index = match.end;
1758
+ }
1759
+ }
1760
+
1761
+ function parseMarkdownHeading(block) {
1762
+ const trimmed = block.trim();
1763
+ let level = 0;
1764
+ while (level < trimmed.length && trimmed[level] === "#" && level < 6) {
1765
+ level += 1;
1766
+ }
1767
+ if (level === 0 || trimmed[level] !== " ") return null;
1768
+ return { level, text: trimmed.slice(level + 1) };
1769
+ }
1770
+
1771
+ function parseMarkdownImage(text) {
1772
+ const trimmed = text.trim();
1773
+ if (!trimmed.startsWith("![") || !trimmed.endsWith(")")) return null;
1774
+ const altEnd = trimmed.indexOf("](");
1775
+ if (altEnd === -1) return null;
1776
+ const destination = parseMarkdownDestination(trimmed.slice(altEnd + 2, -1));
1777
+ if (!destination?.href) return null;
1778
+ return {
1779
+ alt: trimmed.slice(2, altEnd),
1780
+ href: destination.href,
1781
+ title: destination.title
1782
+ };
1783
+ }
1784
+
1785
+ function findNextFormattedLink(text, startIndex) {
1786
+ for (let index = startIndex; index < text.length; index += 1) {
1787
+ const match = parseFormattedLinkAt(text, index);
1788
+ if (match) return match;
1789
+ }
1790
+ return null;
1791
+ }
1792
+
1793
+ function parseFormattedLinkAt(text, index) {
1794
+ if (text[index] === "[") {
1795
+ return parseBracketLinkAt(text, index);
1796
+ }
1797
+ if (text.startsWith("http://", index) || text.startsWith("https://", index)) {
1798
+ return parsePlainUrlAt(text, index);
1799
+ }
1800
+ return null;
1801
+ }
1802
+
1803
+ function parseBracketLinkAt(text, index) {
1804
+ const labelEnd = text.indexOf("]", index + 1);
1805
+ if (labelEnd === -1) return null;
1806
+ const labelText = text.slice(index + 1, labelEnd);
1807
+
1808
+ if (text[labelEnd + 1] === "(") {
1809
+ const destinationEnd = text.indexOf(")", labelEnd + 2);
1810
+ if (destinationEnd === -1) return null;
1811
+ const destination = parseMarkdownDestination(text.slice(labelEnd + 2, destinationEnd));
1812
+ if (!destination) return null;
1813
+ return {
1814
+ start: index,
1815
+ end: destinationEnd + 1,
1816
+ label: unescapeMarkdownText(labelText),
1817
+ href: destination.href,
1818
+ title: destination.title
1819
+ };
1820
+ }
1821
+
1822
+ const separator = labelText.indexOf("|");
1823
+ if (separator !== -1) {
1824
+ const label = labelText.slice(0, separator);
1825
+ const href = labelText.slice(separator + 1);
1826
+ if (isHttpUrlText(href)) {
1827
+ return { start: index, end: labelEnd + 1, label, href, title: "" };
1828
+ }
1829
+ }
1830
+
1831
+ if (isHttpUrlText(labelText)) {
1832
+ return { start: index, end: labelEnd + 1, label: labelText, href: labelText, title: "" };
1833
+ }
1834
+
1835
+ return null;
1836
+ }
1837
+
1838
+ function parseMarkdownDestination(value) {
1839
+ const trimmed = value.trim();
1840
+ const titleMarker = trimmed.indexOf(" \"");
1841
+ const hasTitle = titleMarker !== -1 && trimmed.endsWith("\"");
1842
+ const href = hasTitle ? trimmed.slice(0, titleMarker) : trimmed;
1843
+ if (!isHttpUrlText(href)) return null;
1844
+ return {
1845
+ href,
1846
+ title: hasTitle ? trimmed.slice(titleMarker + 2, -1) : ""
1847
+ };
1848
+ }
1849
+
1850
+ function parsePlainUrlAt(text, index) {
1851
+ let end = index;
1852
+ while (end < text.length && !isUrlStopCharacter(text[end])) {
1853
+ end += 1;
1854
+ }
1855
+ const href = text.slice(index, end);
1856
+ return { start: index, end, label: href, href, title: "" };
1857
+ }
1858
+
1859
+ function isUrlStopCharacter(character) {
1860
+ return character === " " || character === "\t" || character === "\n" || character === "<" || character === "]";
1861
+ }
1862
+
1863
+ function isHttpUrlText(value) {
1864
+ return value.startsWith("http://") || value.startsWith("https://");
1865
+ }
1866
+
1867
+ function createSafeImage(href, alt = "", title = "") {
1868
+ let parsedUrl;
1869
+ try {
1870
+ parsedUrl = new URL(href);
1871
+ } catch {
1872
+ return null;
1873
+ }
1874
+
1875
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
1876
+ return null;
1877
+ }
1878
+
1879
+ const link = document.createElement("a");
1880
+ link.className = "description-image-link";
1881
+ link.href = parsedUrl.toString();
1882
+ link.target = "_blank";
1883
+ link.rel = "noopener noreferrer";
1884
+ if (title) link.title = title;
1885
+
1886
+ const image = document.createElement("img");
1887
+ image.className = "description-image";
1888
+ image.src = parsedUrl.toString();
1889
+ image.alt = alt || title || "Attached image";
1890
+ image.loading = "lazy";
1891
+ image.decoding = "async";
1892
+ if (title) image.title = title;
1893
+
1894
+ link.append(image);
1895
+ return link;
1896
+ }
1897
+
1898
+ function createSafeLink(label, href, title = "") {
1899
+ let parsedUrl;
1900
+ try {
1901
+ parsedUrl = new URL(href);
1902
+ } catch {
1903
+ return null;
1904
+ }
1905
+
1906
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
1907
+ return null;
1908
+ }
1909
+
1910
+ const link = document.createElement("a");
1911
+ link.href = parsedUrl.toString();
1912
+ link.target = "_blank";
1913
+ link.rel = "noopener noreferrer";
1914
+ link.textContent = label;
1915
+ if (title) link.title = title;
1916
+ return link;
1917
+ }
1918
+
1919
+ function unescapeMarkdownText(text) {
1920
+ return String(text).replace(/\\([\\`*_[\]{}()#+\-.!|>])/g, "$1");
1921
+ }
1922
+
1923
+ function saveSettings() {
1924
+ const nextIconStyle = settingsIconStyleInputs.find((input) => input.checked)?.value === "flat" ? "flat" : "3d";
1925
+ const nextShowCardDescriptions = settingsShowCardDescriptions.checked;
1926
+ const nextInlineCardTitleEdit = settingsInlineCardTitleEdit.checked;
1927
+ const nextGitSyncInBackground = settingsGitSyncInBackground.checked;
1928
+ let didPersistLocalSetting = false;
1929
+
1930
+ if (state.iconStyle !== nextIconStyle) {
1931
+ state.iconStyle = nextIconStyle;
1932
+ localStorage.setItem(ICON_STYLE_STORAGE_KEY, nextIconStyle);
1933
+ applyIconStyle();
1934
+ didPersistLocalSetting = true;
1935
+ }
1936
+
1937
+ if (state.showCardDescriptions !== nextShowCardDescriptions) {
1938
+ state.showCardDescriptions = nextShowCardDescriptions;
1939
+ localStorage.setItem(SHOW_CARD_DESCRIPTIONS_STORAGE_KEY, String(nextShowCardDescriptions));
1940
+ didPersistLocalSetting = true;
1941
+ }
1942
+
1943
+ if (state.inlineCardTitleEdit !== nextInlineCardTitleEdit) {
1944
+ state.inlineCardTitleEdit = nextInlineCardTitleEdit;
1945
+ localStorage.setItem(INLINE_CARD_TITLE_EDIT_STORAGE_KEY, String(nextInlineCardTitleEdit));
1946
+ if (!nextInlineCardTitleEdit) {
1947
+ state.editingCardTitleId = null;
1948
+ state.editingCardTitleValue = "";
1949
+ }
1950
+ didPersistLocalSetting = true;
1951
+ }
1952
+
1953
+ if (state.gitSyncInBackground !== nextGitSyncInBackground) {
1954
+ state.gitSyncInBackground = nextGitSyncInBackground;
1955
+ localStorage.setItem(GIT_SYNC_IN_BACKGROUND_STORAGE_KEY, String(nextGitSyncInBackground));
1956
+ didPersistLocalSetting = true;
1957
+ }
1958
+
1959
+ if (didPersistLocalSetting) {
1960
+ setSaveMessage("Settings saved locally");
1961
+ renderBoard();
1962
+ } else {
1963
+ setSaveMessage("Settings saved locally");
1964
+ }
1965
+
1966
+ settingsDialog.close();
1967
+ render();
1968
+ }
1969
+
1970
+ function openSettingsDialog() {
1971
+ settingsUserName.value = state.currentUserName;
1972
+ settingsUserEmail.value = state.currentUserEmail;
1973
+ for (const input of settingsIconStyleInputs) {
1974
+ input.checked = input.value === state.iconStyle;
1975
+ }
1976
+ settingsShowCardDescriptions.checked = state.showCardDescriptions;
1977
+ settingsInlineCardTitleEdit.checked = state.inlineCardTitleEdit;
1978
+ settingsGitSyncInBackground.checked = state.gitSyncInBackground;
1979
+ const canImport = (state.board?.cards || []).length === 0;
1980
+ importBoardButton.disabled = !canImport;
1981
+ settingsImportHelp.textContent = canImport
1982
+ ? "Import is available because this board has no cards."
1983
+ : "Import is disabled because this board already has cards.";
1984
+
1985
+ if (!settingsDialog.open) {
1986
+ settingsDialog.showModal();
1987
+ }
1988
+ }
1989
+
1990
+ function startBoardTitleEdit() {
1991
+ state.editingBoardTitle = true;
1992
+ state.editingBoardTitleValue = state.board?.name || "";
1993
+ renderHeader();
1994
+ focusInlineInput(boardTitleInlineInput);
1995
+ }
1996
+
1997
+ function commitBoardTitleEdit() {
1998
+ if (!state.editingBoardTitle) return;
1999
+ const nextBoardName = state.editingBoardTitleValue.trim() || "KanbanQube Board";
2000
+ state.editingBoardTitle = false;
2001
+ state.editingBoardTitleValue = "";
2002
+ if (state.board.name === nextBoardName) {
2003
+ renderHeader();
2004
+ } else {
2005
+ updateBoardName(nextBoardName, "Board updated");
2006
+ }
2007
+ }
2008
+
2009
+ function cancelBoardTitleEdit() {
2010
+ state.editingBoardTitle = false;
2011
+ state.editingBoardTitleValue = "";
2012
+ renderHeader();
2013
+ }
2014
+
2015
+ function startLaneTitleEdit(listId) {
2016
+ const list = listById(listId);
2017
+ if (!list) return;
2018
+ state.editingLaneTitleId = listId;
2019
+ state.editingLaneTitleValue = list.name || "";
2020
+ renderBoard();
2021
+ focusInlineInput(document.querySelector(`.lane[data-list-id="${listId}"] .lane-title-inline-input`));
2022
+ }
2023
+
2024
+ function commitLaneTitleEdit(listId) {
2025
+ if (state.editingLaneTitleId !== listId) return;
2026
+ const list = listById(listId);
2027
+ if (!list) {
2028
+ cancelLaneTitleEdit();
2029
+ return;
2030
+ }
2031
+
2032
+ const nextName = state.editingLaneTitleValue.trim() || "Lane";
2033
+ state.editingLaneTitleId = null;
2034
+ state.editingLaneTitleValue = "";
2035
+
2036
+ if (list.name !== nextName) {
2037
+ list.name = nextName;
2038
+ pushAction("updateList", {
2039
+ list: { id: list.id, name: list.name },
2040
+ board: { id: state.board.id, name: state.board.name }
2041
+ });
2042
+ queueSave("Lane renamed");
2043
+ render();
2044
+ return;
2045
+ }
2046
+
2047
+ renderBoard();
2048
+ }
2049
+
2050
+ function cancelLaneTitleEdit() {
2051
+ state.editingLaneTitleId = null;
2052
+ state.editingLaneTitleValue = "";
2053
+ renderBoard();
2054
+ }
2055
+
2056
+ function startCardTitleEdit(cardId) {
2057
+ if (!state.inlineCardTitleEdit) return;
2058
+ const card = (state.board?.cards || []).find((candidate) => candidate.id === cardId && !candidate.closed);
2059
+ if (!card) return;
2060
+ state.editingCardTitleId = cardId;
2061
+ state.editingCardTitleValue = card.name || "";
2062
+ renderBoard();
2063
+ focusInlineInput(document.querySelector(`.card[data-card-id="${cardId}"] .card-title-inline-input`));
2064
+ }
2065
+
2066
+ function commitCardTitleEdit(cardId) {
2067
+ if (state.editingCardTitleId !== cardId) return;
2068
+ const card = (state.board?.cards || []).find((candidate) => candidate.id === cardId && !candidate.closed);
2069
+ if (!card) {
2070
+ cancelCardTitleEdit();
2071
+ return;
2072
+ }
2073
+
2074
+ const nextName = state.editingCardTitleValue.trim();
2075
+ const isPendingNewCard = state.pendingNewCardIds.has(cardId);
2076
+ state.editingCardTitleId = null;
2077
+ state.editingCardTitleValue = "";
2078
+ state.pendingNewCardIds.delete(cardId);
2079
+
2080
+ if (card.name !== nextName || isPendingNewCard) {
2081
+ card.name = nextName;
2082
+ touchCard(card);
2083
+ queueSave(isPendingNewCard ? "Card added" : "Card updated");
2084
+ renderBoard();
2085
+ if (archiveDialog.open) {
2086
+ renderArchiveDialog();
2087
+ }
2088
+ return;
2089
+ }
2090
+
2091
+ renderBoard();
2092
+ }
2093
+
2094
+ function cancelCardTitleEdit() {
2095
+ const cardId = state.editingCardTitleId;
2096
+ state.editingCardTitleId = null;
2097
+ state.editingCardTitleValue = "";
2098
+ state.pendingNewCardIds.delete(cardId);
2099
+ renderBoard();
2100
+ }
2101
+
2102
+ function focusInlineInput(input) {
2103
+ if (!input) return;
2104
+ requestAnimationFrame(() => {
2105
+ input.focus();
2106
+ input.setSelectionRange(input.value.length, input.value.length);
2107
+ });
2108
+ }
2109
+
2110
+ function updateBoardName(name, saveMessage) {
2111
+ state.board.name = name;
2112
+ pushAction("updateBoard", {
2113
+ board: { id: state.board.id, name: state.board.name }
2114
+ });
2115
+ queueSave(saveMessage);
2116
+ render();
2117
+ }
2118
+
2119
+ async function syncBoard() {
2120
+ if (!state.config?.gitRemote) {
2121
+ await openMessageDialog({
2122
+ label: "Git sync",
2123
+ title: "Git remote not configured",
2124
+ message: "Add a Git remote before syncing this board."
2125
+ });
2126
+ return;
2127
+ }
2128
+
2129
+ state.isSyncing = true;
2130
+ state.syncStatusMessage = "Syncing with git…";
2131
+ state.lastSyncLog = "Syncing with git…";
2132
+ state.lastSyncAt = new Date().toISOString();
2133
+ setSaveMessage("Syncing with git…");
2134
+ startSyncStatusPolling();
2135
+ if (!state.gitSyncInBackground) {
2136
+ openSyncLogDialog();
2137
+ }
2138
+ try {
2139
+ await flushPendingBoardSave();
2140
+
2141
+ const response = await fetch("/api/sync", { method: "POST" });
2142
+ const payload = await response.json();
2143
+ state.lastSyncLog = payload.output || "No sync output was returned.";
2144
+ state.lastSyncAt = payload.startedAt || state.lastSyncAt;
2145
+ if (!response.ok || !payload.ok) {
2146
+ throw new Error(payload.output || "Git sync failed.");
2147
+ }
2148
+ await reloadBoardAfterSync();
2149
+ state.syncStatusMessage = "Board synced";
2150
+ setSaveMessage("Board synced");
2151
+ } catch (error) {
2152
+ state.syncStatusMessage = error.message || "Git sync failed.";
2153
+ state.lastSyncLog = error.message || "Git sync failed.";
2154
+ setSaveMessage(error.message || "Git sync failed.");
2155
+ await openMessageDialog({
2156
+ label: "Git sync",
2157
+ title: "Sync failed",
2158
+ message: error.message || "Git sync failed."
2159
+ });
2160
+ } finally {
2161
+ stopSyncStatusPolling();
2162
+ state.isSyncing = false;
2163
+ renderHeader();
2164
+ renderSyncLogDialogContent();
2165
+ }
2166
+ }
2167
+
2168
+ function openSyncLogDialog() {
2169
+ if (!state.isSyncing && !state.lastSyncLog.trim()) return;
2170
+ renderSyncLogDialogContent();
2171
+ if (!syncLogDialog.open) {
2172
+ syncLogDialog.showModal();
2173
+ }
2174
+ }
2175
+
2176
+ function renderSyncLogDialogContent() {
2177
+ syncLogContent.textContent = state.lastSyncLog || "No git sync has run yet.";
2178
+ syncLogTimestamp.textContent = state.lastSyncAt
2179
+ ? `Ran ${formatSyncTimestamp(state.lastSyncAt)}`
2180
+ : "No git sync has run yet.";
2181
+ syncLogCloseButton.disabled = state.isSyncing;
2182
+ syncLogCloseButton.textContent = state.isSyncing ? "Sync in progress..." : "Close";
2183
+ }
2184
+
2185
+ function formatSyncTimestamp(value) {
2186
+ const date = new Date(value);
2187
+ if (Number.isNaN(date.valueOf())) return "Unknown time";
2188
+ const parts = new Intl.DateTimeFormat(undefined, SYNC_TIMESTAMP_FORMAT).formatToParts(date);
2189
+ const month = parts.find((part) => part.type === "month")?.value || "";
2190
+ const day = parts.find((part) => part.type === "day")?.value || "";
2191
+ const year = parts.find((part) => part.type === "year")?.value || "";
2192
+ const hour = parts.find((part) => part.type === "hour")?.value || "";
2193
+ const minute = parts.find((part) => part.type === "minute")?.value || "";
2194
+ const second = parts.find((part) => part.type === "second")?.value || "";
2195
+ const dayPeriod = parts.find((part) => part.type === "dayPeriod")?.value || "";
2196
+ return `${month} ${day} ${year}, ${hour}:${minute}:${second} ${dayPeriod}`.trim();
2197
+ }
2198
+
2199
+ function startSyncStatusPolling() {
2200
+ stopSyncStatusPolling();
2201
+ syncStatusPollTimer = globalThis.setInterval(() => {
2202
+ void refreshSyncStatus();
2203
+ }, 500);
2204
+ }
2205
+
2206
+ function stopSyncStatusPolling() {
2207
+ if (syncStatusPollTimer !== null) {
2208
+ globalThis.clearInterval(syncStatusPollTimer);
2209
+ syncStatusPollTimer = null;
2210
+ }
2211
+ }
2212
+
2213
+ async function refreshSyncStatus() {
2214
+ if (syncStatusPollInFlight) return;
2215
+ syncStatusPollInFlight = true;
2216
+ try {
2217
+ const response = await fetch("/api/sync-status");
2218
+ if (!response.ok) return;
2219
+ const payload = await response.json();
2220
+ state.lastSyncLog = payload.output || state.lastSyncLog;
2221
+ state.lastSyncAt = payload.startedAt || state.lastSyncAt;
2222
+ if (syncLogDialog.open) {
2223
+ renderSyncLogDialogContent();
2224
+ }
2225
+ } finally {
2226
+ syncStatusPollInFlight = false;
2227
+ }
2228
+ }
2229
+
2230
+ function queueSave(message) {
2231
+ setSaveMessage(`${message} — saving…`);
2232
+ clearTimeout(state.saveTimer);
2233
+ state.saveTimer = setTimeout(() => {
2234
+ state.saveTimer = null;
2235
+ saveBoardNow().catch((error) => {
2236
+ setSaveMessage(error.message || "Save failed.");
2237
+ });
2238
+ }, 220);
2239
+ }
2240
+
2241
+ async function flushPendingBoardSave() {
2242
+ if (state.saveTimer !== null) {
2243
+ clearTimeout(state.saveTimer);
2244
+ state.saveTimer = null;
2245
+ await saveBoardNow();
2246
+ return;
2247
+ }
2248
+
2249
+ if (state.isSaving) {
2250
+ await saveBoardNow();
2251
+ }
2252
+ }
2253
+
2254
+ async function reloadBoardAfterSync() {
2255
+ const selectedCardId = state.selectedCardId;
2256
+ const response = await fetch("/api/board");
2257
+ if (!response.ok) {
2258
+ throw new Error("Board synced, but the updated board could not be loaded.");
2259
+ }
2260
+
2261
+ state.board = await response.json();
2262
+ state.editingBoardTitle = false;
2263
+ state.editingBoardTitleValue = "";
2264
+ state.editingLaneTitleId = null;
2265
+ state.editingLaneTitleValue = "";
2266
+ state.editingCardTitleId = null;
2267
+ state.editingCardTitleValue = "";
2268
+
2269
+ if (selectedCardId && !(state.board.cards || []).some((card) => card.id === selectedCardId)) {
2270
+ state.selectedCardId = null;
2271
+ state.descriptionEditing = false;
2272
+ if (cardDialog.open) cardDialog.close();
2273
+ }
2274
+
2275
+ render();
2276
+ }
2277
+
2278
+ async function saveBoardNow() {
2279
+ if (!state.board) return;
2280
+ state.isSaving = true;
2281
+ const response = await fetch("/api/board", {
2282
+ method: "PUT",
2283
+ headers: { "Content-Type": "application/json" },
2284
+ body: JSON.stringify(state.board)
2285
+ });
2286
+
2287
+ if (!response.ok) {
2288
+ state.isSaving = false;
2289
+ throw new Error("Could not save board.json.");
2290
+ }
2291
+
2292
+ state.board = await response.json();
2293
+ state.isSaving = false;
2294
+ setSaveMessage(`Saved ${new Date().toLocaleTimeString()}`);
2295
+ render();
2296
+ }
2297
+
2298
+ function setSaveMessage(message) {
2299
+ state.saveMessage = message;
2300
+ renderHeader();
2301
+ }
2302
+
2303
+ function moveCardToLane(card, listId) {
2304
+ if (!card || card.idList === listId) return;
2305
+
2306
+ const fromList = listById(card.idList);
2307
+ const toList = listById(listId);
2308
+ card.idList = listId;
2309
+ card.pos = nextCardPos(listId);
2310
+ touchCard(card);
2311
+ pushAction("updateCard", {
2312
+ idCard: card.id,
2313
+ card: cardActionSnapshot(card),
2314
+ listBefore: fromList ? { id: fromList.id, name: fromList.name } : undefined,
2315
+ listAfter: toList ? { id: toList.id, name: toList.name } : undefined
2316
+ });
2317
+ queueSave("Card moved");
2318
+ render();
2319
+ }
2320
+
2321
+ function touchCard(card) {
2322
+ card.dateLastActivity = new Date().toISOString();
2323
+ }
2324
+
2325
+ function pushAction(type, data) {
2326
+ const member = ensureCurrentUserMember();
2327
+ state.board.actions.unshift({
2328
+ id: createHexId(),
2329
+ type,
2330
+ date: new Date().toISOString(),
2331
+ memberCreator: member,
2332
+ idMemberCreator: member.id,
2333
+ data
2334
+ });
2335
+ }
2336
+
2337
+ function ensureCurrentUserMember() {
2338
+ const name = state.currentUserName.trim() || "Guest";
2339
+ const username = slugify(name);
2340
+ let member = state.board.members.find((candidate) => candidate.username === username);
2341
+ if (!member) {
2342
+ member = {
2343
+ id: createHexId(),
2344
+ fullName: name,
2345
+ username,
2346
+ email: state.currentUserEmail.trim() || undefined,
2347
+ initials: initialsFor(name),
2348
+ nonPublic: {
2349
+ fullName: name,
2350
+ initials: initialsFor(name)
2351
+ }
2352
+ };
2353
+ state.board.members.push(member);
2354
+ }
2355
+ return member;
2356
+ }
2357
+
2358
+ function deleteLabel(labelId) {
2359
+ state.board.labels = (state.board.labels || []).filter((label) => label.id !== labelId);
2360
+ for (const card of state.board.cards || []) {
2361
+ if (Array.isArray(card.idLabels)) {
2362
+ card.idLabels = card.idLabels.filter((currentLabelId) => currentLabelId !== labelId);
2363
+ }
2364
+ }
2365
+ }
2366
+
2367
+ function actionsForCard(cardId) {
2368
+ return (state.board.actions || []).filter((action) => {
2369
+ const targetCardId = action?.data?.idCard || action?.data?.card?.id;
2370
+ return targetCardId === cardId;
2371
+ });
2372
+ }
2373
+
2374
+ function labelsForCard(card) {
2375
+ const labels = new Map((state.board.labels || []).map((label) => [label.id, label]));
2376
+ return (card.idLabels || []).map((labelId) => labels.get(labelId)).filter(Boolean);
2377
+ }
2378
+
2379
+ function checklistsForCard(cardId) {
2380
+ return (state.board.checklists || []).filter((checklist) => checklist.idCard === cardId);
2381
+ }
2382
+
2383
+ function openLists() {
2384
+ return [...(state.board?.lists || [])]
2385
+ .filter((list) => !list.closed)
2386
+ .sort((left, right) => left.pos - right.pos);
2387
+ }
2388
+
2389
+ function cardsForList(listId) {
2390
+ return openCardsForList(listId).sort((left, right) => left.pos - right.pos);
2391
+ }
2392
+
2393
+ function openCardsForList(listId) {
2394
+ return [...(state.board?.cards || [])]
2395
+ .filter((card) => !card.closed && card.idList === listId);
2396
+ }
2397
+
2398
+ function allCardsForList(listId) {
2399
+ return [...(state.board?.cards || [])].filter((card) => card.idList === listId);
2400
+ }
2401
+
2402
+ function visibleCardsForList(listId) {
2403
+ const cards = cardsForList(listId);
2404
+ if (!state.searchTerm) return cards;
2405
+ return cards.filter((card) => {
2406
+ const haystack = [card.name, card.desc, ...labelsForCard(card).map((label) => label.name)]
2407
+ .filter(Boolean)
2408
+ .join(" ")
2409
+ .toLowerCase();
2410
+ return haystack.includes(state.searchTerm);
2411
+ });
2412
+ }
2413
+
2414
+ function listById(listId) {
2415
+ return (state.board?.lists || []).find((list) => list.id === listId) || null;
2416
+ }
2417
+
2418
+ function getSelectedCard() {
2419
+ return (state.board?.cards || []).find((card) => card.id === state.selectedCardId) || null;
2420
+ }
2421
+
2422
+ function isCardDone(card) {
2423
+ return Boolean(card?.kanbanQubeDone);
2424
+ }
2425
+
2426
+ function isCardArchived(card) {
2427
+ return Boolean(card?.closed);
2428
+ }
2429
+
2430
+ function archivedCards() {
2431
+ return [...(state.board?.cards || [])]
2432
+ .filter((card) => isCardArchived(card))
2433
+ .sort((left, right) => {
2434
+ const leftDate = Date.parse(left.dateLastActivity || 0);
2435
+ const rightDate = Date.parse(right.dateLastActivity || 0);
2436
+ return rightDate - leftDate;
2437
+ });
2438
+ }
2439
+
2440
+ function nextLanePos() {
2441
+ const lists = openLists();
2442
+ return lists.length ? Math.max(...lists.map((list) => Number(list.pos) || 0)) + 16384 : 16384;
2443
+ }
2444
+
2445
+ function nextCardPos(listId) {
2446
+ const cards = openCardsForList(listId);
2447
+ return cards.length ? Math.max(...cards.map((card) => Number(card.pos) || 0)) + 16384 : 16384;
2448
+ }
2449
+
2450
+ function nextChecklistItemPos(checklist) {
2451
+ const items = checklist.checkItems || [];
2452
+ return items.length ? Math.max(...items.map((item) => Number(item.pos) || 0)) + 16384 : 16384;
2453
+ }
2454
+
2455
+ function enableCardDnD(laneNode, listId) {
2456
+ const cardList = laneNode.querySelector(".card-list");
2457
+ const cards = cardList.querySelectorAll(".card");
2458
+
2459
+ for (const cardNode of cards) {
2460
+ cardNode.addEventListener("dragstart", (event) => {
2461
+ state.drag = { type: "card", cardId: cardNode.dataset.cardId, sourceListId: listId };
2462
+ cardNode.classList.add("dragging");
2463
+ event.dataTransfer.effectAllowed = "move";
2464
+ });
2465
+ cardNode.addEventListener("dragend", () => {
2466
+ cardNode.classList.remove("dragging");
2467
+ });
2468
+ }
2469
+
2470
+ cardList.addEventListener("dragover", (event) => {
2471
+ if (state.drag?.type !== "card") return;
2472
+ event.preventDefault();
2473
+ const after = cardElementAfter(cardList, event.clientY);
2474
+ const dragging = document.querySelector(`.card[data-card-id="${state.drag.cardId}"]`);
2475
+ if (!dragging) return;
2476
+ if (!after) cardList.append(dragging);
2477
+ else if (after !== dragging) after.before(dragging);
2478
+ });
2479
+
2480
+ cardList.addEventListener("drop", (event) => {
2481
+ if (state.drag?.type !== "card") return;
2482
+ event.preventDefault();
2483
+ const cardId = state.drag.cardId;
2484
+ const card = (state.board.cards || []).find((candidate) => candidate.id === cardId);
2485
+ if (!card) return;
2486
+
2487
+ const order = [...cardList.querySelectorAll(".card")].map((node) => node.dataset.cardId);
2488
+ const previousListId = card.idList;
2489
+ const sourceListId = previousListId === listId ? null : previousListId;
2490
+ reorderCardsFromDom(listId, order, sourceListId, cardId);
2491
+ state.drag = null;
2492
+ });
2493
+ }
2494
+
2495
+ function enableLaneDnD(laneNode) {
2496
+ laneNode.addEventListener("dragstart", (event) => {
2497
+ if (event.target.closest(".card")) return;
2498
+ if (event.target.closest("button, input, textarea, select")) return;
2499
+ state.drag = { type: "lane", listId: laneNode.dataset.listId };
2500
+ laneNode.classList.add("dragging");
2501
+ event.dataTransfer.effectAllowed = "move";
2502
+ });
2503
+
2504
+ laneNode.addEventListener("dragend", () => {
2505
+ laneNode.classList.remove("dragging");
2506
+ });
2507
+ }
2508
+
2509
+ function eventHasFiles(event) {
2510
+ return [...(event.dataTransfer?.types || [])].includes("Files");
2511
+ }
2512
+
2513
+ function handleAttachmentDragOver(event) {
2514
+ if (!eventHasFiles(event)) return;
2515
+ event.preventDefault();
2516
+ event.stopPropagation();
2517
+ event.dataTransfer.dropEffect = "copy";
2518
+ attachmentDropZone.classList.add("is-dragging");
2519
+ }
2520
+
2521
+ function handleAttachmentDragLeave(event) {
2522
+ if (!eventHasFiles(event)) return;
2523
+ if (event.currentTarget === cardDialog && cardDialog.contains(event.relatedTarget)) return;
2524
+ attachmentDropZone.classList.remove("is-dragging");
2525
+ }
2526
+
2527
+ function handleAttachmentDrop(event) {
2528
+ if (!eventHasFiles(event)) return;
2529
+ event.preventDefault();
2530
+ event.stopPropagation();
2531
+ attachmentDropZone.classList.remove("is-dragging");
2532
+ uploadFilesToSelectedCard([...event.dataTransfer.files]);
2533
+ }
2534
+
2535
+ function handleLaneDragOver(event) {
2536
+ if (state.drag?.type !== "lane") return;
2537
+ event.preventDefault();
2538
+ const after = laneElementAfter(boardScroller, event.clientX);
2539
+ const dragging = document.querySelector(`.lane[data-list-id="${state.drag.listId}"]`);
2540
+ if (!dragging) return;
2541
+ if (!after) {
2542
+ const anchor = boardScroller.querySelector(".add-lane-card");
2543
+ if (anchor) anchor.before(dragging);
2544
+ else boardScroller.append(dragging);
2545
+ } else if (after !== dragging) {
2546
+ after.before(dragging);
2547
+ }
2548
+ }
2549
+
2550
+ function handleLaneDrop(event) {
2551
+ if (state.drag?.type !== "lane") return;
2552
+ event.preventDefault();
2553
+ const order = [...boardScroller.querySelectorAll(".lane")].map((laneNode) => laneNode.dataset.listId);
2554
+ order.forEach((listId, index) => {
2555
+ const list = listById(listId);
2556
+ if (list) list.pos = (index + 1) * 16384;
2557
+ });
2558
+ pushAction("updateBoard", {
2559
+ board: { id: state.board.id, name: state.board.name }
2560
+ });
2561
+ queueSave("Lane order updated");
2562
+ state.drag = null;
2563
+ renderBoard();
2564
+ }
2565
+
2566
+ function reorderCardsFromDom(listId, order, previousListId = null, movedCardId = null) {
2567
+ order.forEach((cardId, index) => {
2568
+ const card = (state.board.cards || []).find((candidate) => candidate.id === cardId);
2569
+ if (!card) return;
2570
+ card.idList = listId;
2571
+ card.pos = (index + 1) * 16384;
2572
+ touchCard(card);
2573
+ });
2574
+
2575
+ const movedCard = (state.board.cards || []).find((card) => card.id === movedCardId);
2576
+ if (movedCard) {
2577
+ pushAction("updateCard", {
2578
+ idCard: movedCard.id,
2579
+ card: cardActionSnapshot(movedCard),
2580
+ list: { id: listId, name: listById(listId)?.name || "" },
2581
+ listBefore: previousListId ? { id: previousListId, name: listById(previousListId)?.name || "" } : undefined,
2582
+ listAfter: previousListId ? { id: listId, name: listById(listId)?.name || "" } : undefined
2583
+ });
2584
+ }
2585
+ queueSave(previousListId ? "Card moved" : "Card order updated");
2586
+ render();
2587
+ }
2588
+
2589
+ function laneElementAfter(container, x) {
2590
+ const candidates = [...container.querySelectorAll(".lane:not(.dragging)")];
2591
+ return candidates.reduce((closest, child) => {
2592
+ const box = child.getBoundingClientRect();
2593
+ const offset = x - box.left - box.width / 2;
2594
+ if (offset < 0 && offset > closest.offset) {
2595
+ return { offset, element: child };
2596
+ }
2597
+ return closest;
2598
+ }, { offset: Number.NEGATIVE_INFINITY, element: null }).element;
2599
+ }
2600
+
2601
+ function cardElementAfter(container, y) {
2602
+ const candidates = [...container.querySelectorAll(".card:not(.dragging)")];
2603
+ return candidates.reduce((closest, child) => {
2604
+ const box = child.getBoundingClientRect();
2605
+ const offset = y - box.top - box.height / 2;
2606
+ if (offset < 0 && offset > closest.offset) {
2607
+ return { offset, element: child };
2608
+ }
2609
+ return closest;
2610
+ }, { offset: Number.NEGATIVE_INFINITY, element: null }).element;
2611
+ }
2612
+
2613
+ function buildCardBadges(card) {
2614
+ const badges = [];
2615
+ if (card.badges?.description) badges.push({ icon: "description", text: "" });
2616
+ if (card.badges?.comments) badges.push({ icon: "comment", text: String(card.badges.comments) });
2617
+ if (card.badges?.checkItems) badges.push({ symbol: "☑", text: `${card.badges.checkItemsChecked || 0}/${card.badges.checkItems}` });
2618
+ if (card.attachments?.length) badges.push({ icon: "attachment", text: String(card.attachments.length) });
2619
+ return badges;
2620
+ }
2621
+
2622
+ function createIcon(name) {
2623
+ const paths = {
2624
+ description: ["M4 6h16M4 12h12M4 18h9"],
2625
+ comment: ["M5 5.5h14v9H8.5L5 18V5.5Z"],
2626
+ checklist: ["M9 7h11M9 12h11M9 17h11M4 7.2l1.2 1.2L7.5 6M4 12.2l1.2 1.2 2.3-2.4M4 17.2l1.2 1.2 2.3-2.4"],
2627
+ attachment: ["M8.5 12.5 14.8 6.2a3 3 0 0 1 4.2 4.2l-7.8 7.8a5 5 0 0 1-7.1-7.1l7.5-7.5"],
2628
+ trash: ["M3 6h18m-2 0-.9 13.15A2 2 0 0 1 16.1 21H7.9a2 2 0 0 1-2-1.85L5 6m3 0V4.5A1.5 1.5 0 0 1 9.5 3h5A1.5 1.5 0 0 1 16 4.5V6m-6 4v7m4-7v7"],
2629
+ file: ["M7 3h7l4 4v14H7V3Zm7 0v5h5"],
2630
+ image: ["M4 5h16v14H4V5Zm3 10 3.2-3.2 2.3 2.3 2.1-2.1L19 16.4M8.5 9.5h.01"]
2631
+ };
2632
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2633
+ svg.setAttribute("viewBox", "0 0 24 24");
2634
+ svg.setAttribute("aria-hidden", "true");
2635
+ for (const value of paths[name] || paths.description) {
2636
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
2637
+ path.setAttribute("d", value);
2638
+ svg.append(path);
2639
+ }
2640
+ return svg;
2641
+ }
2642
+
2643
+ function coverUrlForCard(card) {
2644
+ const attachmentId = card.cover?.idAttachment;
2645
+ if (!attachmentId || !Array.isArray(card.attachments)) return "";
2646
+ const match = card.attachments.find((attachment) => attachment.id === attachmentId);
2647
+ return match?.url || match?.previewUrl || "";
2648
+ }
2649
+
2650
+ function coverState(idAttachment) {
2651
+ return {
2652
+ idAttachment,
2653
+ color: null,
2654
+ idUploadedBackground: null,
2655
+ size: "normal",
2656
+ brightness: "dark",
2657
+ yPosition: 0.5,
2658
+ idPlugin: null
2659
+ };
2660
+ }
2661
+
2662
+ function isImageAttachment(attachment) {
2663
+ const mimeType = String(attachment?.mimeType || "").toLowerCase();
2664
+ const name = String(attachment?.name || attachment?.url || "").toLowerCase();
2665
+ return mimeType.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg)$/.test(name);
2666
+ }
2667
+
2668
+ function formatAttachmentMeta(attachment) {
2669
+ const parts = [];
2670
+ if (Number.isFinite(attachment.bytes)) {
2671
+ parts.push(formatBytes(attachment.bytes));
2672
+ }
2673
+ if (attachment.mimeType) {
2674
+ parts.push(attachment.mimeType);
2675
+ }
2676
+ return parts.join(" · ") || "Uploaded file";
2677
+ }
2678
+
2679
+ function storedUploadFileName(attachment) {
2680
+ if (!attachment) return "";
2681
+ if (attachment.fileName) return String(attachment.fileName).split(/[\\/]/).pop();
2682
+ if (attachment.url) {
2683
+ try {
2684
+ return decodeURIComponent(new URL(attachment.url, globalThis.location.origin).pathname.split("/").pop() || "");
2685
+ } catch {
2686
+ return "";
2687
+ }
2688
+ }
2689
+ return "";
2690
+ }
2691
+
2692
+ function isLocalUploadAttachment(attachment) {
2693
+ if (!attachment) return false;
2694
+ if (attachment.isUpload) return true;
2695
+ if (typeof attachment.url !== "string") return false;
2696
+ try {
2697
+ return new URL(attachment.url, globalThis.location.origin).pathname.startsWith("/uploads/");
2698
+ } catch {
2699
+ return attachment.url.startsWith("/uploads/");
2700
+ }
2701
+ }
2702
+
2703
+ function formatBytes(bytes) {
2704
+ if (bytes < 1024) return `${bytes} B`;
2705
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 102.4) / 10} KB`;
2706
+ return `${Math.round(bytes / 1024 / 102.4) / 10} MB`;
2707
+ }
2708
+
2709
+ function cardActionSnapshot(card) {
2710
+ return {
2711
+ id: card.id,
2712
+ name: card.name,
2713
+ idShort: card.idShort || null,
2714
+ shortLink: card.shortLink || card.id.slice(-8)
2715
+ };
2716
+ }
2717
+
2718
+ function humanizeAction(action) {
2719
+ const actor = action.memberCreator?.fullName || action.memberCreator?.username || "Someone";
2720
+ const verbs = {
2721
+ createCard: "created this card",
2722
+ deleteCard: "deleted this card",
2723
+ updateCard: "updated this card",
2724
+ updateBoard: "updated the board",
2725
+ createList: "created a lane",
2726
+ updateList: "updated a lane",
2727
+ updateCheckItemStateOnCard: "updated a checklist item",
2728
+ addAttachmentToCard: "attached a file to this card",
2729
+ commentCard: "commented on this card"
2730
+ };
2731
+ return `${actor} ${verbs[action.type] || action.type}`;
2732
+ }
2733
+
2734
+ function migrateLegacyBoardData() {
2735
+ let changed = false;
2736
+ for (const card of state.board?.cards || []) {
2737
+ if (card.kanbanQubeArchived) {
2738
+ if (!card.closed) {
2739
+ card.closed = true;
2740
+ }
2741
+ delete card.kanbanQubeArchived;
2742
+ changed = true;
2743
+ }
2744
+ }
2745
+ return changed;
2746
+ }
2747
+
2748
+ function removeCardCompletely(cardId) {
2749
+ const card = (state.board?.cards || []).find((candidate) => candidate.id === cardId);
2750
+ if (!card) return;
2751
+ touchCard(card);
2752
+ state.board.actions = (state.board.actions || []).filter((action) => {
2753
+ const targetCardId = action?.data?.idCard || action?.data?.card?.id;
2754
+ return targetCardId !== cardId;
2755
+ });
2756
+ pushAction("deleteCard", {
2757
+ idCard: card.id,
2758
+ card: cardActionSnapshot(card),
2759
+ list: { id: card.idList, name: listById(card.idList)?.name || "" }
2760
+ });
2761
+ state.board.cards = (state.board.cards || []).filter((candidate) => candidate.id !== cardId);
2762
+ state.board.checklists = (state.board.checklists || []).filter((checklist) => checklist.idCard !== cardId);
2763
+ }
2764
+
2765
+ function openArchiveDialog() {
2766
+ renderArchiveDialog();
2767
+ if (!archiveDialog.open) {
2768
+ archiveDialog.showModal();
2769
+ }
2770
+ }
2771
+
2772
+ function openArchivedCard(cardId) {
2773
+ archiveDialog.close();
2774
+ openCard(cardId);
2775
+ }
2776
+
2777
+ function hydrateIdentityFromGitConfig() {
2778
+ const gitUserName = storageSafeText(state.config?.gitUserName);
2779
+ const gitUserEmail = storageSafeText(state.config?.gitUserEmail);
2780
+
2781
+ if (!state.currentUserName.trim() && gitUserName) {
2782
+ state.currentUserName = gitUserName;
2783
+ localStorage.setItem(USER_STORAGE_KEY, gitUserName);
2784
+ }
2785
+
2786
+ if (!state.currentUserEmail.trim() && gitUserEmail) {
2787
+ state.currentUserEmail = gitUserEmail;
2788
+ localStorage.setItem(USER_EMAIL_STORAGE_KEY, gitUserEmail);
2789
+ }
2790
+ }
2791
+
2792
+ function colorForLabel(color) {
2793
+ return labelColorMap[color] || labelColorMap.blue;
2794
+ }
2795
+
2796
+ function labelColorOptions() {
2797
+ return Object.keys(labelColorMap);
2798
+ }
2799
+
2800
+ function storageSafeText(value) {
2801
+ return typeof value === "string" ? value.replace(/[\0\r\n]/g, "").trim().slice(0, 200) : "";
2802
+ }
2803
+
2804
+ function createHexId() {
2805
+ const values = new Uint8Array(12);
2806
+ crypto.getRandomValues(values);
2807
+ return [...values].map((value) => value.toString(16).padStart(2, "0")).join("");
2808
+ }
2809
+
2810
+ function initialsFor(name) {
2811
+ return String(name)
2812
+ .trim()
2813
+ .split(/\s+/)
2814
+ .slice(0, 2)
2815
+ .map((part) => part[0]?.toUpperCase() || "")
2816
+ .join("") || "U";
2817
+ }
2818
+
2819
+ function slugify(value) {
2820
+ let result = "";
2821
+ let previousWasDash = true;
2822
+ for (const character of String(value).toLowerCase()) {
2823
+ const isAlphaNumeric = (character >= "a" && character <= "z") || (character >= "0" && character <= "9");
2824
+ if (isAlphaNumeric) {
2825
+ result += character;
2826
+ previousWasDash = false;
2827
+ } else if (!previousWasDash) {
2828
+ result += "-";
2829
+ previousWasDash = true;
2830
+ }
2831
+ }
2832
+ const trimmed = previousWasDash ? result.slice(0, -1) : result;
2833
+ return trimmed || "user";
2834
+ }
2835
+
2836
+ function openPrompt({ label, title, inputLabel, confirmLabel, value }) {
2837
+ promptLabel.textContent = label;
2838
+ promptTitle.textContent = title;
2839
+ promptInputLabel.textContent = inputLabel;
2840
+ promptConfirmButton.textContent = confirmLabel;
2841
+ promptInput.value = value || "";
2842
+ promptDialog.showModal();
2843
+ promptInput.focus();
2844
+
2845
+ return new Promise((resolve) => {
2846
+ const handleClose = () => {
2847
+ promptDialog.removeEventListener("close", handleClose);
2848
+ resolve(promptDialog.returnValue === "confirm" ? promptInput.value.trim() : "");
2849
+ };
2850
+ promptDialog.addEventListener("close", handleClose);
2851
+ });
2852
+ }
2853
+
2854
+ function openMessageDialog({ label = "Notice", title = "Notice", message = "", confirmLabel = "OK" }) {
2855
+ return openAppDialog({
2856
+ label,
2857
+ title,
2858
+ message,
2859
+ confirmLabel,
2860
+ cancelLabel: "",
2861
+ danger: false
2862
+ });
2863
+ }
2864
+
2865
+ function openConfirmDialog({ label = "Confirm", title = "Are you sure?", message = "", confirmLabel = "Confirm", cancelLabel = "Cancel", danger = false }) {
2866
+ return openAppDialog({
2867
+ label,
2868
+ title,
2869
+ message,
2870
+ confirmLabel,
2871
+ cancelLabel,
2872
+ danger
2873
+ });
2874
+ }
2875
+
2876
+ function openAppDialog({ label, title, message, confirmLabel, cancelLabel, danger }) {
2877
+ appDialogLabel.textContent = label;
2878
+ appDialogTitle.textContent = title;
2879
+ appDialogMessage.textContent = message;
2880
+ appDialogCancelButton.hidden = !cancelLabel;
2881
+ appDialogCancelButton.textContent = cancelLabel || "Cancel";
2882
+ appDialogConfirmButton.textContent = confirmLabel;
2883
+ appDialogConfirmButton.className = danger ? "danger-button" : "primary-button";
2884
+ appDialog.returnValue = "";
2885
+
2886
+ if (!appDialog.open) {
2887
+ appDialog.showModal();
2888
+ }
2889
+
2890
+ return new Promise((resolve) => {
2891
+ const handleClose = () => {
2892
+ appDialog.removeEventListener("close", handleClose);
2893
+ resolve(appDialog.returnValue === "confirm");
2894
+ };
2895
+ appDialog.addEventListener("close", handleClose);
2896
+ });
2897
+ }