kanbanqube 1.0.10 → 1.0.12
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/controllers/configController.js +13 -2
- package/package.json +1 -1
- package/public/app.js +221 -132
- package/public/index.html +22 -11
- package/public/styles.css +121 -8
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
|
|
3
5
|
function createConfigController(config, gitService) {
|
|
4
6
|
async function getConfig(_request, response) {
|
|
7
|
+
const gitUserEmail = await gitService.gitUserEmail(config.workspaceDir);
|
|
5
8
|
response.json({
|
|
6
9
|
boardFile: config.boardFileName,
|
|
7
10
|
storagePath: config.boardDirName,
|
|
@@ -9,7 +12,8 @@ function createConfigController(config, gitService) {
|
|
|
9
12
|
hasGitRepo: await gitService.hasGitRepository(config.workspaceDir),
|
|
10
13
|
gitRemote: await gitService.gitRemoteOrigin(config.workspaceDir),
|
|
11
14
|
gitUserName: await gitService.gitUserName(config.workspaceDir),
|
|
12
|
-
gitUserEmail
|
|
15
|
+
gitUserEmail,
|
|
16
|
+
gravatarUrl: gravatarUrlForEmail(gitUserEmail)
|
|
13
17
|
});
|
|
14
18
|
}
|
|
15
19
|
|
|
@@ -18,6 +22,13 @@ function createConfigController(config, gitService) {
|
|
|
18
22
|
};
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
function gravatarUrlForEmail(email) {
|
|
26
|
+
if (typeof email !== "string" || !email.trim()) return "";
|
|
27
|
+
const hash = crypto.createHash("md5").update(email.trim().toLowerCase()).digest("hex");
|
|
28
|
+
return `https://www.gravatar.com/avatar/${hash}?s=64&d=404`;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
module.exports = {
|
|
22
|
-
createConfigController
|
|
32
|
+
createConfigController,
|
|
33
|
+
gravatarUrlForEmail
|
|
23
34
|
};
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -61,12 +61,14 @@ const aboutImage = document.getElementById("aboutImage");
|
|
|
61
61
|
const faviconLink = document.getElementById("faviconLink");
|
|
62
62
|
const boardTitle = document.getElementById("boardTitle");
|
|
63
63
|
const boardTitleInlineInput = document.getElementById("boardTitleInlineInput");
|
|
64
|
-
const userBadge = document.getElementById("userBadge");
|
|
65
64
|
const searchInput = document.getElementById("searchInput");
|
|
66
65
|
const saveStatus = document.getElementById("saveStatus");
|
|
67
66
|
const syncButton = document.getElementById("syncButton");
|
|
68
67
|
const archiveButton = document.getElementById("archiveButton");
|
|
69
68
|
const settingsButton = document.getElementById("settingsButton");
|
|
69
|
+
const userAvatarButton = document.getElementById("userAvatarButton");
|
|
70
|
+
const userAvatarImage = document.getElementById("userAvatarImage");
|
|
71
|
+
const userAvatarFallback = document.getElementById("userAvatarFallback");
|
|
70
72
|
const syncLogDialog = document.getElementById("syncLogDialog");
|
|
71
73
|
const syncLogContent = document.getElementById("syncLogContent");
|
|
72
74
|
const syncLogTimestamp = document.getElementById("syncLogTimestamp");
|
|
@@ -97,6 +99,7 @@ const addLabelButton = document.getElementById("addLabelButton");
|
|
|
97
99
|
const commentInput = document.getElementById("commentInput");
|
|
98
100
|
const addCommentButton = document.getElementById("addCommentButton");
|
|
99
101
|
const addChecklistButton = document.getElementById("addChecklistButton");
|
|
102
|
+
const archiveCardButton = document.getElementById("archiveCardButton");
|
|
100
103
|
const deleteCardButton = document.getElementById("deleteCardButton");
|
|
101
104
|
const closeCardButton = document.getElementById("closeCardButton");
|
|
102
105
|
|
|
@@ -179,8 +182,7 @@ async function bootstrap() {
|
|
|
179
182
|
state.config = configResponse.ok ? await configResponse.json() : { boardFile: "board.json", gitRemote: null, gitUserName: null, gitUserEmail: null };
|
|
180
183
|
hydrateIdentityFromGitConfig();
|
|
181
184
|
applyIconStyle();
|
|
182
|
-
|
|
183
|
-
settingsBoardFile.textContent = `Storage: ${storageLabel}`;
|
|
185
|
+
settingsBoardFile.textContent = `Storage: ${state.config.workspacePath || "current folder"}`;
|
|
184
186
|
settingsRemote.textContent = state.config.gitRemote ? `Remote: ${state.config.gitRemote}` : "Remote: not configured";
|
|
185
187
|
|
|
186
188
|
wireEvents();
|
|
@@ -244,6 +246,10 @@ function wireEvents() {
|
|
|
244
246
|
});
|
|
245
247
|
|
|
246
248
|
settingsButton.addEventListener("click", openSettingsDialog);
|
|
249
|
+
userAvatarImage.addEventListener("error", () => {
|
|
250
|
+
userAvatarImage.hidden = true;
|
|
251
|
+
userAvatarFallback.hidden = false;
|
|
252
|
+
});
|
|
247
253
|
archiveButton.addEventListener("click", openArchiveDialog);
|
|
248
254
|
boardTitle.addEventListener("click", startBoardTitleEdit);
|
|
249
255
|
closeSettingsButton.addEventListener("click", () => settingsDialog.close());
|
|
@@ -266,6 +272,7 @@ function wireEvents() {
|
|
|
266
272
|
|
|
267
273
|
closeCardButton.addEventListener("click", () => cardDialog.close());
|
|
268
274
|
removeCoverButton.addEventListener("click", removeCoverFromSelectedCard);
|
|
275
|
+
archiveCardButton.addEventListener("click", archiveSelectedCard);
|
|
269
276
|
deleteCardButton.addEventListener("click", deleteSelectedCard);
|
|
270
277
|
addLabelButton.addEventListener("click", toggleLabelEditor);
|
|
271
278
|
addCommentButton.addEventListener("click", addCommentToSelectedCard);
|
|
@@ -370,7 +377,7 @@ function renderHeader() {
|
|
|
370
377
|
boardTitle.classList.toggle("is-inline-editable", true);
|
|
371
378
|
boardTitleInlineInput.hidden = !state.editingBoardTitle;
|
|
372
379
|
boardTitleInlineInput.value = state.editingBoardTitleValue;
|
|
373
|
-
|
|
380
|
+
renderUserAvatar();
|
|
374
381
|
const archivedCount = archivedCards().length;
|
|
375
382
|
archiveButton.textContent = archivedCount ? `Archive (${archivedCount})` : "Archive";
|
|
376
383
|
const canSync = Boolean(state.config?.gitRemote);
|
|
@@ -420,8 +427,8 @@ function renderBoard() {
|
|
|
420
427
|
laneNode.querySelector(".lane-count").textContent = String(cardsForList(list.id).length);
|
|
421
428
|
|
|
422
429
|
const cardList = laneNode.querySelector(".card-list");
|
|
423
|
-
for (const card of cards) {
|
|
424
|
-
const cardNode = renderCard(card);
|
|
430
|
+
for (const [cardIndex, card] of cards.entries()) {
|
|
431
|
+
const cardNode = renderCard(card, { labelTooltipBelow: cardIndex === 0 });
|
|
425
432
|
cardList.append(cardNode);
|
|
426
433
|
}
|
|
427
434
|
|
|
@@ -473,7 +480,7 @@ async function createLane() {
|
|
|
473
480
|
render();
|
|
474
481
|
}
|
|
475
482
|
|
|
476
|
-
function renderCard(card) {
|
|
483
|
+
function renderCard(card, options = {}) {
|
|
477
484
|
const node = cardTemplate.content.firstElementChild.cloneNode(true);
|
|
478
485
|
node.dataset.cardId = card.id;
|
|
479
486
|
node.classList.toggle("is-done", isCardDone(card));
|
|
@@ -487,6 +494,8 @@ function renderCard(card) {
|
|
|
487
494
|
labelNode.className = "card-label";
|
|
488
495
|
labelNode.style.background = colorForLabel(label.color);
|
|
489
496
|
labelNode.title = label.name || label.color;
|
|
497
|
+
labelNode.dataset.tooltip = label.name || label.color;
|
|
498
|
+
labelNode.classList.toggle("tooltip-below", Boolean(options.labelTooltipBelow));
|
|
490
499
|
cardLabelsStrip.append(labelNode);
|
|
491
500
|
}
|
|
492
501
|
|
|
@@ -508,10 +517,15 @@ function renderCard(card) {
|
|
|
508
517
|
});
|
|
509
518
|
|
|
510
519
|
const archiveButton = node.querySelector(".card-archive-button");
|
|
511
|
-
archiveButton.hidden = !done;
|
|
512
520
|
archiveButton.addEventListener("click", (event) => {
|
|
513
521
|
event.stopPropagation();
|
|
514
|
-
archiveCard(card);
|
|
522
|
+
archiveCard(card, { force: true });
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const cardDeleteButton = node.querySelector(".card-delete-button");
|
|
526
|
+
cardDeleteButton.addEventListener("click", (event) => {
|
|
527
|
+
event.stopPropagation();
|
|
528
|
+
deleteBoardCard(card.id);
|
|
515
529
|
});
|
|
516
530
|
|
|
517
531
|
const titleNode = node.querySelector(".card-title");
|
|
@@ -612,6 +626,7 @@ function renderCardDialog() {
|
|
|
612
626
|
cardDescriptionDisplay.hidden = state.descriptionEditing;
|
|
613
627
|
cardDescriptionInput.hidden = !state.descriptionEditing;
|
|
614
628
|
editDescriptionButton.textContent = state.descriptionEditing ? "Done" : "Edit";
|
|
629
|
+
archiveCardButton.hidden = isCardArchived(card);
|
|
615
630
|
|
|
616
631
|
renderLabelsEditor(card);
|
|
617
632
|
renderAttachments(card);
|
|
@@ -690,32 +705,48 @@ function renderArchiveDialog() {
|
|
|
690
705
|
function renderLabelsEditor(card) {
|
|
691
706
|
cardLabels.textContent = "";
|
|
692
707
|
labelEditorContainer.textContent = "";
|
|
708
|
+
renderLabelSummary(card);
|
|
693
709
|
|
|
710
|
+
addLabelButton.textContent = state.labelEditorOpen ? "×" : "+";
|
|
711
|
+
addLabelButton.setAttribute("aria-label", state.labelEditorOpen ? "Close labels panel" : "Open labels panel");
|
|
712
|
+
if (!state.labelEditorOpen) return;
|
|
713
|
+
|
|
714
|
+
appendLabelEditorPanel(createLabelEditorPanel(card));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function renderLabelSummary(card) {
|
|
694
718
|
const assignedLabels = labelsForCard(card);
|
|
695
719
|
if (assignedLabels.length === 0) {
|
|
696
720
|
if (!state.labelEditorOpen) {
|
|
697
|
-
|
|
698
|
-
empty.className = "empty-state label-summary-trigger";
|
|
699
|
-
empty.textContent = "No labels assigned.";
|
|
700
|
-
empty.addEventListener("click", openLabelEditor);
|
|
701
|
-
cardLabels.append(empty);
|
|
702
|
-
}
|
|
703
|
-
} else {
|
|
704
|
-
for (const label of assignedLabels) {
|
|
705
|
-
const pill = document.createElement("button");
|
|
706
|
-
pill.type = "button";
|
|
707
|
-
pill.className = "label-pill";
|
|
708
|
-
pill.textContent = label.name || label.color;
|
|
709
|
-
pill.style.background = colorForLabel(label.color);
|
|
710
|
-
pill.addEventListener("click", openLabelEditor);
|
|
711
|
-
cardLabels.append(pill);
|
|
721
|
+
cardLabels.append(createEmptyLabelSummary());
|
|
712
722
|
}
|
|
723
|
+
return;
|
|
713
724
|
}
|
|
714
725
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
726
|
+
for (const label of assignedLabels) {
|
|
727
|
+
cardLabels.append(createLabelPill(label));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
718
730
|
|
|
731
|
+
function createEmptyLabelSummary() {
|
|
732
|
+
const empty = document.createElement("div");
|
|
733
|
+
empty.className = "empty-state label-summary-trigger";
|
|
734
|
+
empty.textContent = "No labels assigned.";
|
|
735
|
+
empty.addEventListener("click", openLabelEditor);
|
|
736
|
+
return empty;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function createLabelPill(label) {
|
|
740
|
+
const pill = document.createElement("button");
|
|
741
|
+
pill.type = "button";
|
|
742
|
+
pill.className = "label-pill";
|
|
743
|
+
pill.textContent = label.name || label.color;
|
|
744
|
+
pill.style.background = colorForLabel(label.color);
|
|
745
|
+
pill.addEventListener("click", openLabelEditor);
|
|
746
|
+
return pill;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function createLabelEditorPanel(card) {
|
|
719
750
|
const labels = sortedBoardLabels();
|
|
720
751
|
const searchTerm = state.labelSearchTerm.trim().toLowerCase();
|
|
721
752
|
const filteredLabels = searchTerm
|
|
@@ -724,127 +755,153 @@ function renderLabelsEditor(card) {
|
|
|
724
755
|
|
|
725
756
|
const panel = document.createElement("section");
|
|
726
757
|
panel.className = "label-editor-panel";
|
|
727
|
-
|
|
728
|
-
const searchInput = document.createElement("input");
|
|
729
|
-
searchInput.className = "label-search-input";
|
|
730
|
-
searchInput.type = "search";
|
|
731
|
-
searchInput.placeholder = "Search labels…";
|
|
732
|
-
searchInput.value = state.labelSearchTerm;
|
|
733
|
-
searchInput.addEventListener("keydown", stopLabelEditorShortcut);
|
|
734
|
-
searchInput.addEventListener("input", () => {
|
|
735
|
-
state.labelSearchTerm = searchInput.value;
|
|
736
|
-
renderCardDialog();
|
|
737
|
-
});
|
|
738
|
-
panel.append(searchInput);
|
|
758
|
+
panel.append(createLabelSearchInput());
|
|
739
759
|
|
|
740
760
|
if (labels.length === 0) {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
empty.textContent = "No labels yet. Add one for this card.";
|
|
744
|
-
panel.append(empty);
|
|
745
|
-
const createButton = document.createElement("button");
|
|
746
|
-
createButton.type = "button";
|
|
747
|
-
createButton.className = "ghost-button";
|
|
748
|
-
createButton.textContent = "Create a new label";
|
|
749
|
-
createButton.addEventListener("click", addLabelToSelectedCard);
|
|
750
|
-
panel.append(createButton);
|
|
751
|
-
appendLabelEditorPanel(panel);
|
|
752
|
-
return;
|
|
761
|
+
panel.append(createEmptyLabelsMessage(), createLabelCreateButton());
|
|
762
|
+
return panel;
|
|
753
763
|
}
|
|
754
764
|
|
|
755
765
|
const list = document.createElement("div");
|
|
756
766
|
list.className = "label-editor-list";
|
|
757
|
-
|
|
758
767
|
for (const label of filteredLabels) {
|
|
759
|
-
|
|
760
|
-
row.className = "label-editor-row";
|
|
761
|
-
|
|
762
|
-
const toggle = document.createElement("input");
|
|
763
|
-
toggle.className = "label-toggle";
|
|
764
|
-
toggle.type = "checkbox";
|
|
765
|
-
toggle.checked = card.idLabels.includes(label.id);
|
|
766
|
-
toggle.addEventListener("change", () => {
|
|
767
|
-
if (toggle.checked) {
|
|
768
|
-
if (!card.idLabels.includes(label.id)) card.idLabels.push(label.id);
|
|
769
|
-
} else {
|
|
770
|
-
card.idLabels = card.idLabels.filter((labelId) => labelId !== label.id);
|
|
771
|
-
}
|
|
772
|
-
touchCard(card);
|
|
773
|
-
queueSave("Labels updated");
|
|
774
|
-
renderCardDialog();
|
|
775
|
-
});
|
|
776
|
-
|
|
777
|
-
const nameInput = document.createElement("input");
|
|
778
|
-
nameInput.className = "label-name-input";
|
|
779
|
-
nameInput.type = "text";
|
|
780
|
-
nameInput.value = label.name || "";
|
|
781
|
-
nameInput.placeholder = "Label name";
|
|
782
|
-
nameInput.style.backgroundColor = colorForLabel(label.color);
|
|
783
|
-
let committedName = label.name || "";
|
|
784
|
-
nameInput.addEventListener("keydown", stopLabelEditorShortcut);
|
|
785
|
-
nameInput.addEventListener("input", () => {
|
|
786
|
-
label.name = nameInput.value;
|
|
787
|
-
});
|
|
788
|
-
nameInput.addEventListener("blur", () => {
|
|
789
|
-
const trimmedName = nameInput.value.trim();
|
|
790
|
-
if (label.name !== trimmedName) {
|
|
791
|
-
label.name = trimmedName;
|
|
792
|
-
}
|
|
793
|
-
if (committedName !== label.name) {
|
|
794
|
-
committedName = label.name;
|
|
795
|
-
queueSave("Label updated");
|
|
796
|
-
renderBoard();
|
|
797
|
-
}
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
const colorSelect = document.createElement("select");
|
|
801
|
-
colorSelect.className = "label-color-select";
|
|
802
|
-
for (const color of labelColorOptions()) {
|
|
803
|
-
const option = document.createElement("option");
|
|
804
|
-
option.value = color;
|
|
805
|
-
option.textContent = color.replaceAll("_", " ");
|
|
806
|
-
option.selected = color === label.color;
|
|
807
|
-
colorSelect.append(option);
|
|
808
|
-
}
|
|
809
|
-
colorSelect.addEventListener("keydown", stopLabelEditorShortcut);
|
|
810
|
-
colorSelect.addEventListener("change", () => {
|
|
811
|
-
label.color = colorSelect.value;
|
|
812
|
-
queueSave("Label updated");
|
|
813
|
-
renderCardDialog();
|
|
814
|
-
renderBoard();
|
|
815
|
-
});
|
|
816
|
-
|
|
817
|
-
const removeButton = document.createElement("button");
|
|
818
|
-
removeButton.type = "button";
|
|
819
|
-
removeButton.className = "icon-button";
|
|
820
|
-
removeButton.append(createIcon("trash"));
|
|
821
|
-
removeButton.addEventListener("click", () => {
|
|
822
|
-
deleteLabel(label.id);
|
|
823
|
-
queueSave("Label removed");
|
|
824
|
-
render();
|
|
825
|
-
});
|
|
826
|
-
|
|
827
|
-
row.append(toggle, nameInput, colorSelect, removeButton);
|
|
828
|
-
list.append(row);
|
|
768
|
+
list.append(createLabelEditorRow(card, label));
|
|
829
769
|
}
|
|
830
770
|
|
|
831
771
|
if (filteredLabels.length === 0) {
|
|
832
|
-
|
|
833
|
-
empty.className = "empty-state";
|
|
834
|
-
empty.textContent = "No labels match that search.";
|
|
835
|
-
panel.append(empty);
|
|
772
|
+
panel.append(createNoMatchingLabelsMessage());
|
|
836
773
|
} else {
|
|
837
774
|
panel.append(list);
|
|
838
775
|
}
|
|
839
776
|
|
|
777
|
+
panel.append(createLabelCreateButton());
|
|
778
|
+
return panel;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function createLabelSearchInput() {
|
|
782
|
+
const searchInput = document.createElement("input");
|
|
783
|
+
searchInput.className = "label-search-input";
|
|
784
|
+
searchInput.type = "search";
|
|
785
|
+
searchInput.placeholder = "Search labels…";
|
|
786
|
+
searchInput.value = state.labelSearchTerm;
|
|
787
|
+
searchInput.addEventListener("keydown", stopLabelEditorShortcut);
|
|
788
|
+
searchInput.addEventListener("input", () => {
|
|
789
|
+
state.labelSearchTerm = searchInput.value;
|
|
790
|
+
renderCardDialog();
|
|
791
|
+
});
|
|
792
|
+
return searchInput;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function createLabelCreateButton() {
|
|
840
796
|
const createButton = document.createElement("button");
|
|
841
797
|
createButton.type = "button";
|
|
842
798
|
createButton.className = "ghost-button";
|
|
843
799
|
createButton.textContent = "Create a new label";
|
|
844
800
|
createButton.addEventListener("click", addLabelToSelectedCard);
|
|
845
|
-
|
|
801
|
+
return createButton;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function createEmptyLabelsMessage() {
|
|
805
|
+
const empty = document.createElement("div");
|
|
806
|
+
empty.className = "empty-state";
|
|
807
|
+
empty.textContent = "No labels yet. Add one for this card.";
|
|
808
|
+
return empty;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function createNoMatchingLabelsMessage() {
|
|
812
|
+
const empty = document.createElement("div");
|
|
813
|
+
empty.className = "empty-state";
|
|
814
|
+
empty.textContent = "No labels match that search.";
|
|
815
|
+
return empty;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function createLabelEditorRow(card, label) {
|
|
819
|
+
const row = document.createElement("div");
|
|
820
|
+
row.className = "label-editor-row";
|
|
821
|
+
row.append(
|
|
822
|
+
createLabelToggle(card, label),
|
|
823
|
+
createLabelNameInput(label),
|
|
824
|
+
createLabelColorSelect(label),
|
|
825
|
+
createLabelRemoveButton(label)
|
|
826
|
+
);
|
|
827
|
+
return row;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function createLabelToggle(card, label) {
|
|
831
|
+
const toggle = document.createElement("input");
|
|
832
|
+
toggle.className = "label-toggle";
|
|
833
|
+
toggle.type = "checkbox";
|
|
834
|
+
toggle.checked = card.idLabels.includes(label.id);
|
|
835
|
+
toggle.addEventListener("change", () => {
|
|
836
|
+
if (toggle.checked) {
|
|
837
|
+
if (!card.idLabels.includes(label.id)) card.idLabels.push(label.id);
|
|
838
|
+
} else {
|
|
839
|
+
card.idLabels = card.idLabels.filter((labelId) => labelId !== label.id);
|
|
840
|
+
}
|
|
841
|
+
touchCard(card);
|
|
842
|
+
queueSave("Labels updated");
|
|
843
|
+
renderCardDialog();
|
|
844
|
+
});
|
|
845
|
+
return toggle;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function createLabelNameInput(label) {
|
|
849
|
+
const nameInput = document.createElement("input");
|
|
850
|
+
nameInput.className = "label-name-input";
|
|
851
|
+
nameInput.type = "text";
|
|
852
|
+
nameInput.value = label.name || "";
|
|
853
|
+
nameInput.placeholder = "Label name";
|
|
854
|
+
nameInput.style.backgroundColor = colorForLabel(label.color);
|
|
855
|
+
let committedName = label.name || "";
|
|
856
|
+
nameInput.addEventListener("keydown", stopLabelEditorShortcut);
|
|
857
|
+
nameInput.addEventListener("input", () => {
|
|
858
|
+
label.name = nameInput.value;
|
|
859
|
+
});
|
|
860
|
+
nameInput.addEventListener("blur", () => {
|
|
861
|
+
const trimmedName = nameInput.value.trim();
|
|
862
|
+
if (label.name !== trimmedName) {
|
|
863
|
+
label.name = trimmedName;
|
|
864
|
+
}
|
|
865
|
+
if (committedName !== label.name) {
|
|
866
|
+
committedName = label.name;
|
|
867
|
+
queueSave("Label updated");
|
|
868
|
+
renderBoard();
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
return nameInput;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function createLabelColorSelect(label) {
|
|
875
|
+
const colorSelect = document.createElement("select");
|
|
876
|
+
colorSelect.className = "label-color-select";
|
|
877
|
+
for (const color of labelColorOptions()) {
|
|
878
|
+
const option = document.createElement("option");
|
|
879
|
+
option.value = color;
|
|
880
|
+
option.textContent = color.replaceAll("_", " ");
|
|
881
|
+
option.selected = color === label.color;
|
|
882
|
+
colorSelect.append(option);
|
|
883
|
+
}
|
|
884
|
+
colorSelect.addEventListener("keydown", stopLabelEditorShortcut);
|
|
885
|
+
colorSelect.addEventListener("change", () => {
|
|
886
|
+
label.color = colorSelect.value;
|
|
887
|
+
queueSave("Label updated");
|
|
888
|
+
renderCardDialog();
|
|
889
|
+
renderBoard();
|
|
890
|
+
});
|
|
891
|
+
return colorSelect;
|
|
892
|
+
}
|
|
846
893
|
|
|
847
|
-
|
|
894
|
+
function createLabelRemoveButton(label) {
|
|
895
|
+
const removeButton = document.createElement("button");
|
|
896
|
+
removeButton.type = "button";
|
|
897
|
+
removeButton.className = "icon-button";
|
|
898
|
+
removeButton.append(createIcon("trash"));
|
|
899
|
+
removeButton.addEventListener("click", () => {
|
|
900
|
+
deleteLabel(label.id);
|
|
901
|
+
queueSave("Label removed");
|
|
902
|
+
render();
|
|
903
|
+
});
|
|
904
|
+
return removeButton;
|
|
848
905
|
}
|
|
849
906
|
|
|
850
907
|
function stopLabelEditorShortcut(event) {
|
|
@@ -1455,6 +1512,13 @@ function archiveCard(card, options = {}) {
|
|
|
1455
1512
|
render();
|
|
1456
1513
|
}
|
|
1457
1514
|
|
|
1515
|
+
function archiveSelectedCard() {
|
|
1516
|
+
const card = getSelectedCard();
|
|
1517
|
+
if (!card || isCardArchived(card)) return;
|
|
1518
|
+
archiveCard(card, { force: true });
|
|
1519
|
+
cardDialog.close();
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1458
1522
|
function restoreArchivedCard(cardId) {
|
|
1459
1523
|
const card = (state.board?.cards || []).find((candidate) => candidate.id === cardId);
|
|
1460
1524
|
if (!card || !isCardArchived(card)) return;
|
|
@@ -1687,6 +1751,14 @@ function setKeyboardCard(cardId, shouldRender = true) {
|
|
|
1687
1751
|
async function deleteSelectedCard() {
|
|
1688
1752
|
const card = getSelectedCard();
|
|
1689
1753
|
if (!card) return;
|
|
1754
|
+
if (await deleteBoardCard(card.id)) {
|
|
1755
|
+
cardDialog.close();
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
async function deleteBoardCard(cardId) {
|
|
1760
|
+
const card = (state.board?.cards || []).find((candidate) => candidate.id === cardId);
|
|
1761
|
+
if (!card) return false;
|
|
1690
1762
|
const confirmed = await openConfirmDialog({
|
|
1691
1763
|
label: "Delete card",
|
|
1692
1764
|
title: "Delete card?",
|
|
@@ -1694,11 +1766,11 @@ async function deleteSelectedCard() {
|
|
|
1694
1766
|
confirmLabel: "Delete",
|
|
1695
1767
|
danger: true
|
|
1696
1768
|
});
|
|
1697
|
-
if (!confirmed) return;
|
|
1769
|
+
if (!confirmed) return false;
|
|
1698
1770
|
removeCardCompletely(card.id);
|
|
1699
1771
|
queueSave("Card deleted");
|
|
1700
|
-
cardDialog.close();
|
|
1701
1772
|
render();
|
|
1773
|
+
return true;
|
|
1702
1774
|
}
|
|
1703
1775
|
|
|
1704
1776
|
function addChecklistToSelectedCard() {
|
|
@@ -2824,17 +2896,34 @@ function hydrateIdentityFromGitConfig() {
|
|
|
2824
2896
|
const gitUserName = storageSafeText(state.config?.gitUserName);
|
|
2825
2897
|
const gitUserEmail = storageSafeText(state.config?.gitUserEmail);
|
|
2826
2898
|
|
|
2827
|
-
if (
|
|
2899
|
+
if (gitUserName) {
|
|
2828
2900
|
state.currentUserName = gitUserName;
|
|
2829
2901
|
localStorage.setItem(USER_STORAGE_KEY, gitUserName);
|
|
2830
2902
|
}
|
|
2831
2903
|
|
|
2832
|
-
if (
|
|
2904
|
+
if (gitUserEmail) {
|
|
2833
2905
|
state.currentUserEmail = gitUserEmail;
|
|
2834
2906
|
localStorage.setItem(USER_EMAIL_STORAGE_KEY, gitUserEmail);
|
|
2835
2907
|
}
|
|
2836
2908
|
}
|
|
2837
2909
|
|
|
2910
|
+
function renderUserAvatar() {
|
|
2911
|
+
const userName = state.currentUserName.trim() || "Guest";
|
|
2912
|
+
userAvatarButton.dataset.tooltip = userName;
|
|
2913
|
+
userAvatarButton.setAttribute("aria-label", userName);
|
|
2914
|
+
userAvatarFallback.textContent = initialsFor(userName);
|
|
2915
|
+
|
|
2916
|
+
const avatarUrl = storageSafeText(state.config?.gravatarUrl);
|
|
2917
|
+
if (avatarUrl && userAvatarImage.src !== avatarUrl) {
|
|
2918
|
+
userAvatarImage.hidden = false;
|
|
2919
|
+
userAvatarFallback.hidden = true;
|
|
2920
|
+
userAvatarImage.src = avatarUrl;
|
|
2921
|
+
} else if (!avatarUrl) {
|
|
2922
|
+
userAvatarImage.hidden = true;
|
|
2923
|
+
userAvatarFallback.hidden = false;
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2838
2927
|
function colorForLabel(color) {
|
|
2839
2928
|
return labelColorMap[color] || labelColorMap.blue;
|
|
2840
2929
|
}
|
package/public/index.html
CHANGED
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
<button id="syncButton" class="primary-button" type="button">Sync</button>
|
|
32
32
|
<button id="archiveButton" class="ghost-button" type="button">Archive</button>
|
|
33
33
|
<button id="settingsButton" class="ghost-button" type="button">Settings</button>
|
|
34
|
+
<div id="userAvatarButton" class="user-avatar-button" aria-label="Current user">
|
|
35
|
+
<img id="userAvatarImage" alt="" hidden />
|
|
36
|
+
<span id="userAvatarFallback">?</span>
|
|
37
|
+
</div>
|
|
34
38
|
</div>
|
|
35
39
|
</header>
|
|
36
40
|
|
|
@@ -42,9 +46,6 @@
|
|
|
42
46
|
<input id="boardTitleInlineInput" class="inline-title-input board-title-inline-input" type="text" maxlength="200" hidden />
|
|
43
47
|
</div>
|
|
44
48
|
</div>
|
|
45
|
-
<div class="board-meta">
|
|
46
|
-
<span id="userBadge" class="meta-badge">Guest</span>
|
|
47
|
-
</div>
|
|
48
49
|
</section>
|
|
49
50
|
|
|
50
51
|
<main id="boardScroller" class="board-scroller" aria-label="Kanban board"></main>
|
|
@@ -116,7 +117,10 @@
|
|
|
116
117
|
<div id="activityList" class="activity-list"></div>
|
|
117
118
|
</section>
|
|
118
119
|
|
|
119
|
-
<
|
|
120
|
+
<div class="card-detail-actions">
|
|
121
|
+
<button id="archiveCardButton" type="button" class="ghost-button">Archive card</button>
|
|
122
|
+
<button id="deleteCardButton" type="button" class="danger-button">Delete card</button>
|
|
123
|
+
</div>
|
|
120
124
|
</aside>
|
|
121
125
|
</div>
|
|
122
126
|
</form>
|
|
@@ -133,12 +137,12 @@
|
|
|
133
137
|
</header>
|
|
134
138
|
|
|
135
139
|
<label class="field">
|
|
136
|
-
<span>Your name</span>
|
|
140
|
+
<span>Your name (read-only, from Git config)</span>
|
|
137
141
|
<input id="settingsUserName" type="text" maxlength="120" readonly />
|
|
138
142
|
</label>
|
|
139
143
|
|
|
140
144
|
<label class="field">
|
|
141
|
-
<span>Your email</span>
|
|
145
|
+
<span>Your email (read-only, from Git config)</span>
|
|
142
146
|
<input id="settingsUserEmail" type="email" maxlength="200" readonly />
|
|
143
147
|
</label>
|
|
144
148
|
|
|
@@ -305,11 +309,18 @@
|
|
|
305
309
|
<h3 class="card-title">Task</h3>
|
|
306
310
|
<input class="inline-title-input card-title-inline-input" type="text" maxlength="200" placeholder="Task" hidden />
|
|
307
311
|
</div>
|
|
308
|
-
<
|
|
309
|
-
<
|
|
310
|
-
<
|
|
311
|
-
|
|
312
|
-
|
|
312
|
+
<div class="card-hover-actions">
|
|
313
|
+
<button class="card-archive-button card-hover-action-button" type="button" aria-label="Archive card" data-tooltip="Archive">
|
|
314
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
315
|
+
<path d="M20.25 7.5v10.125c0 1.036-.84 1.875-1.875 1.875H5.625A1.875 1.875 0 0 1 3.75 17.625V7.5m16.5 0V5.625c0-1.036-.84-1.875-1.875-1.875H5.625A1.875 1.875 0 0 0 3.75 5.625V7.5m16.5 0h-16.5m8.25 4.5v3m0 0 1.5-1.5M12 15l-1.5-1.5" />
|
|
316
|
+
</svg>
|
|
317
|
+
</button>
|
|
318
|
+
<button class="card-delete-button card-hover-action-button" type="button" aria-label="Delete card" data-tooltip="Delete">
|
|
319
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
320
|
+
<path d="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" />
|
|
321
|
+
</svg>
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
313
324
|
</div>
|
|
314
325
|
<p class="card-description"></p>
|
|
315
326
|
<footer class="card-footer"></footer>
|
package/public/styles.css
CHANGED
|
@@ -212,7 +212,6 @@ button {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
.topbar-actions,
|
|
215
|
-
.board-meta,
|
|
216
215
|
.lane-actions,
|
|
217
216
|
.archive-header-actions,
|
|
218
217
|
.modal-footer,
|
|
@@ -223,10 +222,82 @@ button {
|
|
|
223
222
|
align-items: center;
|
|
224
223
|
}
|
|
225
224
|
|
|
225
|
+
.user-avatar-button {
|
|
226
|
+
position: relative;
|
|
227
|
+
width: calc(34px * var(--ui-scale));
|
|
228
|
+
min-width: calc(34px * var(--ui-scale));
|
|
229
|
+
height: calc(34px * var(--ui-scale));
|
|
230
|
+
padding: 0;
|
|
231
|
+
border-radius: 999px;
|
|
232
|
+
border: 1px solid var(--border);
|
|
233
|
+
background: rgba(255, 255, 255, 0.08);
|
|
234
|
+
color: var(--text);
|
|
235
|
+
display: inline-grid;
|
|
236
|
+
place-items: center;
|
|
237
|
+
overflow: visible;
|
|
238
|
+
cursor: default;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.user-avatar-button img,
|
|
242
|
+
.user-avatar-button span {
|
|
243
|
+
width: 100%;
|
|
244
|
+
height: 100%;
|
|
245
|
+
border-radius: inherit;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.user-avatar-button img[hidden],
|
|
249
|
+
.user-avatar-button span[hidden] {
|
|
250
|
+
display: none !important;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.user-avatar-button img {
|
|
254
|
+
display: block;
|
|
255
|
+
object-fit: cover;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.user-avatar-button span {
|
|
259
|
+
display: inline-flex;
|
|
260
|
+
align-items: center;
|
|
261
|
+
justify-content: center;
|
|
262
|
+
font-size: 0.78rem;
|
|
263
|
+
font-weight: 800;
|
|
264
|
+
background: rgba(255, 255, 255, 0.1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.user-avatar-button::after {
|
|
268
|
+
content: attr(data-tooltip);
|
|
269
|
+
position: absolute;
|
|
270
|
+
right: 0;
|
|
271
|
+
top: calc(100% + 0.55rem);
|
|
272
|
+
padding: 0.32rem 0.5rem;
|
|
273
|
+
border-radius: calc(10px * var(--ui-scale));
|
|
274
|
+
background: rgba(12, 15, 21, 0.96);
|
|
275
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
276
|
+
color: var(--text);
|
|
277
|
+
font-size: 0.82rem;
|
|
278
|
+
font-weight: 600;
|
|
279
|
+
line-height: 1.2;
|
|
280
|
+
white-space: nowrap;
|
|
281
|
+
opacity: 0;
|
|
282
|
+
pointer-events: none;
|
|
283
|
+
z-index: 30;
|
|
284
|
+
box-shadow: 0 calc(10px * var(--ui-scale)) calc(24px * var(--ui-scale)) rgba(0, 0, 0, 0.32);
|
|
285
|
+
transition: opacity 90ms ease;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.user-avatar-button:hover::after {
|
|
289
|
+
opacity: 1;
|
|
290
|
+
}
|
|
291
|
+
|
|
226
292
|
.archive-header-actions {
|
|
227
293
|
flex: 0 0 auto;
|
|
228
294
|
}
|
|
229
295
|
|
|
296
|
+
.card-detail-actions {
|
|
297
|
+
display: grid;
|
|
298
|
+
gap: 0.65rem;
|
|
299
|
+
}
|
|
300
|
+
|
|
230
301
|
#promptDialog .modal-footer {
|
|
231
302
|
margin-top: 1rem;
|
|
232
303
|
}
|
|
@@ -451,11 +522,42 @@ button {
|
|
|
451
522
|
}
|
|
452
523
|
|
|
453
524
|
.card-label {
|
|
525
|
+
position: relative;
|
|
454
526
|
width: calc(42px * var(--ui-scale));
|
|
455
527
|
height: calc(8px * var(--ui-scale));
|
|
456
528
|
border-radius: 999px;
|
|
457
529
|
}
|
|
458
530
|
|
|
531
|
+
.card-label::after {
|
|
532
|
+
content: attr(data-tooltip);
|
|
533
|
+
position: absolute;
|
|
534
|
+
left: 0;
|
|
535
|
+
bottom: calc(100% + 0.5rem);
|
|
536
|
+
padding: 0.32rem 0.5rem;
|
|
537
|
+
border-radius: calc(10px * var(--ui-scale));
|
|
538
|
+
background: rgba(12, 15, 21, 0.96);
|
|
539
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
540
|
+
color: var(--text);
|
|
541
|
+
font-size: 0.82rem;
|
|
542
|
+
font-weight: 600;
|
|
543
|
+
line-height: 1.2;
|
|
544
|
+
white-space: nowrap;
|
|
545
|
+
opacity: 0;
|
|
546
|
+
pointer-events: none;
|
|
547
|
+
z-index: 10;
|
|
548
|
+
box-shadow: 0 calc(10px * var(--ui-scale)) calc(24px * var(--ui-scale)) rgba(0, 0, 0, 0.32);
|
|
549
|
+
transition: opacity 90ms ease;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
.card-label:hover::after {
|
|
553
|
+
opacity: 1;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
.card-label.tooltip-below::after {
|
|
557
|
+
top: calc(100% + 0.5rem);
|
|
558
|
+
bottom: auto;
|
|
559
|
+
}
|
|
560
|
+
|
|
459
561
|
.card-cover {
|
|
460
562
|
display: none;
|
|
461
563
|
margin: -0.9rem -0.9rem 0.85rem;
|
|
@@ -564,7 +666,14 @@ button {
|
|
|
564
666
|
background: #22c55e;
|
|
565
667
|
}
|
|
566
668
|
|
|
567
|
-
.card-
|
|
669
|
+
.card-hover-actions {
|
|
670
|
+
flex: 0 0 auto;
|
|
671
|
+
display: flex;
|
|
672
|
+
align-items: center;
|
|
673
|
+
gap: 0.1rem;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
.card-hover-action-button {
|
|
568
677
|
width: calc(30px * var(--ui-scale));
|
|
569
678
|
min-width: calc(30px * var(--ui-scale));
|
|
570
679
|
height: calc(30px * var(--ui-scale));
|
|
@@ -584,7 +693,7 @@ button {
|
|
|
584
693
|
transition: opacity 140ms ease, color 140ms ease, transform 140ms ease;
|
|
585
694
|
}
|
|
586
695
|
|
|
587
|
-
.card-
|
|
696
|
+
.card-hover-action-button::after {
|
|
588
697
|
content: attr(data-tooltip);
|
|
589
698
|
position: absolute;
|
|
590
699
|
right: calc(100% + 0.5rem);
|
|
@@ -605,7 +714,7 @@ button {
|
|
|
605
714
|
transition: opacity 90ms ease;
|
|
606
715
|
}
|
|
607
716
|
|
|
608
|
-
.card-
|
|
717
|
+
.card-hover-action-button svg {
|
|
609
718
|
width: calc(23px * var(--ui-scale));
|
|
610
719
|
height: calc(23px * var(--ui-scale));
|
|
611
720
|
stroke: currentColor;
|
|
@@ -615,18 +724,22 @@ button {
|
|
|
615
724
|
stroke-linejoin: round;
|
|
616
725
|
}
|
|
617
726
|
|
|
618
|
-
.card
|
|
727
|
+
.card:hover .card-hover-action-button {
|
|
619
728
|
opacity: 1;
|
|
620
729
|
pointer-events: auto;
|
|
621
730
|
}
|
|
622
731
|
|
|
623
|
-
.card-
|
|
732
|
+
.card-hover-action-button:hover {
|
|
624
733
|
color: var(--text);
|
|
625
734
|
transform: translateY(-1px);
|
|
626
735
|
}
|
|
627
736
|
|
|
628
|
-
.card-
|
|
629
|
-
|
|
737
|
+
.card-delete-button:hover {
|
|
738
|
+
color: #fecaca;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
.card-hover-action-button:hover::after,
|
|
742
|
+
.card-hover-action-button:focus-visible::after {
|
|
630
743
|
opacity: 1;
|
|
631
744
|
}
|
|
632
745
|
|