kanbanqube 1.0.9 → 1.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -11
- package/controllers/configController.js +13 -2
- package/package.json +1 -1
- package/public/app.js +256 -124
- package/public/index.html +26 -12
- package/public/styles.css +130 -8
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ The app provides lanes, cards, labels, checklists, comments, card covers, file a
|
|
|
23
23
|
- [Vaults](#vaults)
|
|
24
24
|
- [Board Workflow](#board-workflow)
|
|
25
25
|
- [Git Sync](#git-sync)
|
|
26
|
-
- [Import](#import)
|
|
26
|
+
- [Trello Import](#trello-import)
|
|
27
27
|
- [Demo Board](#demo-board)
|
|
28
28
|
- [Identity](#identity)
|
|
29
29
|
- [Attachments And Covers](#attachments-and-covers)
|
|
@@ -125,7 +125,7 @@ vault/
|
|
|
125
125
|
uploads/
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
-
If `board/` does not exist, KanbanQube creates it automatically. If
|
|
128
|
+
If `board/` does not exist, KanbanQube creates it automatically. If `uploads/` does not exist, it is created when the first file is uploaded.
|
|
129
129
|
|
|
130
130
|
Uploaded filenames are made unique while preserving the original name in the UI. For example:
|
|
131
131
|
|
|
@@ -180,9 +180,9 @@ git commit -m "Initialize KanbanQube board"
|
|
|
180
180
|
git push -u origin main
|
|
181
181
|
```
|
|
182
182
|
|
|
183
|
-
## Import
|
|
183
|
+
## Trello Import
|
|
184
184
|
|
|
185
|
-
KanbanQube can import a board export JSON from Settings. Import is only enabled when the current board has no cards. This keeps accidental replacement of an active board from happening inside the app.
|
|
185
|
+
KanbanQube can import a Trello board export JSON from Settings. Import is only enabled when the current board has no cards. This keeps accidental replacement of an active board from happening inside the app.
|
|
186
186
|
|
|
187
187
|
Imported data is normalized and written into the split `board/` vault layout.
|
|
188
188
|
|
|
@@ -278,10 +278,4 @@ brew install mathiasconradt/kanbanqube/kanbanqube
|
|
|
278
278
|
|
|
279
279
|
## Star History
|
|
280
280
|
|
|
281
|
-
|
|
282
|
-
<picture>
|
|
283
|
-
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=mathiasconradt/kanbanqube&type=timeline&theme=dark&logscale&legend=top-left" />
|
|
284
|
-
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=mathiasconradt/kanbanqube&type=timeline&logscale&legend=top-left" />
|
|
285
|
-
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=mathiasconradt/kanbanqube&type=timeline&logscale&legend=top-left" />
|
|
286
|
-
</picture>
|
|
287
|
-
</a>
|
|
281
|
+
[](https://www.star-history.com/?repos=mathiasconradt%2Fkanbanqube&type=timeline&logscale=&legend=top-left)
|
|
@@ -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");
|
|
@@ -75,6 +77,7 @@ const syncLogCloseButton = document.getElementById("syncLogCloseButton");
|
|
|
75
77
|
const archiveDialog = document.getElementById("archiveDialog");
|
|
76
78
|
const archiveList = document.getElementById("archiveList");
|
|
77
79
|
const closeArchiveButton = document.getElementById("closeArchiveButton");
|
|
80
|
+
const deleteAllArchivedButton = document.getElementById("deleteAllArchivedButton");
|
|
78
81
|
|
|
79
82
|
const cardDialog = document.getElementById("cardDialog");
|
|
80
83
|
const cardTitleInput = document.getElementById("cardTitleInput");
|
|
@@ -96,6 +99,7 @@ const addLabelButton = document.getElementById("addLabelButton");
|
|
|
96
99
|
const commentInput = document.getElementById("commentInput");
|
|
97
100
|
const addCommentButton = document.getElementById("addCommentButton");
|
|
98
101
|
const addChecklistButton = document.getElementById("addChecklistButton");
|
|
102
|
+
const archiveCardButton = document.getElementById("archiveCardButton");
|
|
99
103
|
const deleteCardButton = document.getElementById("deleteCardButton");
|
|
100
104
|
const closeCardButton = document.getElementById("closeCardButton");
|
|
101
105
|
|
|
@@ -178,8 +182,7 @@ async function bootstrap() {
|
|
|
178
182
|
state.config = configResponse.ok ? await configResponse.json() : { boardFile: "board.json", gitRemote: null, gitUserName: null, gitUserEmail: null };
|
|
179
183
|
hydrateIdentityFromGitConfig();
|
|
180
184
|
applyIconStyle();
|
|
181
|
-
|
|
182
|
-
settingsBoardFile.textContent = `Storage: ${storageLabel}`;
|
|
185
|
+
settingsBoardFile.textContent = `Storage: ${state.config.workspacePath || "current folder"}`;
|
|
183
186
|
settingsRemote.textContent = state.config.gitRemote ? `Remote: ${state.config.gitRemote}` : "Remote: not configured";
|
|
184
187
|
|
|
185
188
|
wireEvents();
|
|
@@ -243,10 +246,15 @@ function wireEvents() {
|
|
|
243
246
|
});
|
|
244
247
|
|
|
245
248
|
settingsButton.addEventListener("click", openSettingsDialog);
|
|
249
|
+
userAvatarImage.addEventListener("error", () => {
|
|
250
|
+
userAvatarImage.hidden = true;
|
|
251
|
+
userAvatarFallback.hidden = false;
|
|
252
|
+
});
|
|
246
253
|
archiveButton.addEventListener("click", openArchiveDialog);
|
|
247
254
|
boardTitle.addEventListener("click", startBoardTitleEdit);
|
|
248
255
|
closeSettingsButton.addEventListener("click", () => settingsDialog.close());
|
|
249
256
|
closeArchiveButton.addEventListener("click", () => archiveDialog.close());
|
|
257
|
+
deleteAllArchivedButton.addEventListener("click", deleteAllArchivedCards);
|
|
250
258
|
saveSettingsButton.addEventListener("click", saveSettings);
|
|
251
259
|
importBoardButton.addEventListener("click", () => importBoardInput.click());
|
|
252
260
|
importBoardInput.addEventListener("change", () => {
|
|
@@ -264,6 +272,7 @@ function wireEvents() {
|
|
|
264
272
|
|
|
265
273
|
closeCardButton.addEventListener("click", () => cardDialog.close());
|
|
266
274
|
removeCoverButton.addEventListener("click", removeCoverFromSelectedCard);
|
|
275
|
+
archiveCardButton.addEventListener("click", archiveSelectedCard);
|
|
267
276
|
deleteCardButton.addEventListener("click", deleteSelectedCard);
|
|
268
277
|
addLabelButton.addEventListener("click", toggleLabelEditor);
|
|
269
278
|
addCommentButton.addEventListener("click", addCommentToSelectedCard);
|
|
@@ -368,7 +377,7 @@ function renderHeader() {
|
|
|
368
377
|
boardTitle.classList.toggle("is-inline-editable", true);
|
|
369
378
|
boardTitleInlineInput.hidden = !state.editingBoardTitle;
|
|
370
379
|
boardTitleInlineInput.value = state.editingBoardTitleValue;
|
|
371
|
-
|
|
380
|
+
renderUserAvatar();
|
|
372
381
|
const archivedCount = archivedCards().length;
|
|
373
382
|
archiveButton.textContent = archivedCount ? `Archive (${archivedCount})` : "Archive";
|
|
374
383
|
const canSync = Boolean(state.config?.gitRemote);
|
|
@@ -418,8 +427,8 @@ function renderBoard() {
|
|
|
418
427
|
laneNode.querySelector(".lane-count").textContent = String(cardsForList(list.id).length);
|
|
419
428
|
|
|
420
429
|
const cardList = laneNode.querySelector(".card-list");
|
|
421
|
-
for (const card of cards) {
|
|
422
|
-
const cardNode = renderCard(card);
|
|
430
|
+
for (const [cardIndex, card] of cards.entries()) {
|
|
431
|
+
const cardNode = renderCard(card, { labelTooltipBelow: cardIndex === 0 });
|
|
423
432
|
cardList.append(cardNode);
|
|
424
433
|
}
|
|
425
434
|
|
|
@@ -471,7 +480,7 @@ async function createLane() {
|
|
|
471
480
|
render();
|
|
472
481
|
}
|
|
473
482
|
|
|
474
|
-
function renderCard(card) {
|
|
483
|
+
function renderCard(card, options = {}) {
|
|
475
484
|
const node = cardTemplate.content.firstElementChild.cloneNode(true);
|
|
476
485
|
node.dataset.cardId = card.id;
|
|
477
486
|
node.classList.toggle("is-done", isCardDone(card));
|
|
@@ -485,6 +494,8 @@ function renderCard(card) {
|
|
|
485
494
|
labelNode.className = "card-label";
|
|
486
495
|
labelNode.style.background = colorForLabel(label.color);
|
|
487
496
|
labelNode.title = label.name || label.color;
|
|
497
|
+
labelNode.dataset.tooltip = label.name || label.color;
|
|
498
|
+
labelNode.classList.toggle("tooltip-below", Boolean(options.labelTooltipBelow));
|
|
488
499
|
cardLabelsStrip.append(labelNode);
|
|
489
500
|
}
|
|
490
501
|
|
|
@@ -506,10 +517,15 @@ function renderCard(card) {
|
|
|
506
517
|
});
|
|
507
518
|
|
|
508
519
|
const archiveButton = node.querySelector(".card-archive-button");
|
|
509
|
-
archiveButton.hidden = !done;
|
|
510
520
|
archiveButton.addEventListener("click", (event) => {
|
|
511
521
|
event.stopPropagation();
|
|
512
|
-
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);
|
|
513
529
|
});
|
|
514
530
|
|
|
515
531
|
const titleNode = node.querySelector(".card-title");
|
|
@@ -610,6 +626,7 @@ function renderCardDialog() {
|
|
|
610
626
|
cardDescriptionDisplay.hidden = state.descriptionEditing;
|
|
611
627
|
cardDescriptionInput.hidden = !state.descriptionEditing;
|
|
612
628
|
editDescriptionButton.textContent = state.descriptionEditing ? "Done" : "Edit";
|
|
629
|
+
archiveCardButton.hidden = isCardArchived(card);
|
|
613
630
|
|
|
614
631
|
renderLabelsEditor(card);
|
|
615
632
|
renderAttachments(card);
|
|
@@ -620,6 +637,8 @@ function renderCardDialog() {
|
|
|
620
637
|
function renderArchiveDialog() {
|
|
621
638
|
archiveList.textContent = "";
|
|
622
639
|
const cards = archivedCards();
|
|
640
|
+
deleteAllArchivedButton.disabled = cards.length === 0;
|
|
641
|
+
deleteAllArchivedButton.hidden = cards.length === 0;
|
|
623
642
|
|
|
624
643
|
if (cards.length === 0) {
|
|
625
644
|
const empty = document.createElement("div");
|
|
@@ -686,30 +705,48 @@ function renderArchiveDialog() {
|
|
|
686
705
|
function renderLabelsEditor(card) {
|
|
687
706
|
cardLabels.textContent = "";
|
|
688
707
|
labelEditorContainer.textContent = "";
|
|
708
|
+
renderLabelSummary(card);
|
|
689
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) {
|
|
690
718
|
const assignedLabels = labelsForCard(card);
|
|
691
719
|
if (assignedLabels.length === 0) {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
empty.textContent = "No labels assigned.";
|
|
695
|
-
empty.addEventListener("click", openLabelEditor);
|
|
696
|
-
cardLabels.append(empty);
|
|
697
|
-
} else {
|
|
698
|
-
for (const label of assignedLabels) {
|
|
699
|
-
const pill = document.createElement("button");
|
|
700
|
-
pill.type = "button";
|
|
701
|
-
pill.className = "label-pill";
|
|
702
|
-
pill.textContent = label.name || label.color;
|
|
703
|
-
pill.style.background = colorForLabel(label.color);
|
|
704
|
-
pill.addEventListener("click", openLabelEditor);
|
|
705
|
-
cardLabels.append(pill);
|
|
720
|
+
if (!state.labelEditorOpen) {
|
|
721
|
+
cardLabels.append(createEmptyLabelSummary());
|
|
706
722
|
}
|
|
723
|
+
return;
|
|
707
724
|
}
|
|
708
725
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
726
|
+
for (const label of assignedLabels) {
|
|
727
|
+
cardLabels.append(createLabelPill(label));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
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
|
+
}
|
|
712
748
|
|
|
749
|
+
function createLabelEditorPanel(card) {
|
|
713
750
|
const labels = sortedBoardLabels();
|
|
714
751
|
const searchTerm = state.labelSearchTerm.trim().toLowerCase();
|
|
715
752
|
const filteredLabels = searchTerm
|
|
@@ -718,115 +755,161 @@ function renderLabelsEditor(card) {
|
|
|
718
755
|
|
|
719
756
|
const panel = document.createElement("section");
|
|
720
757
|
panel.className = "label-editor-panel";
|
|
721
|
-
|
|
722
|
-
const searchInput = document.createElement("input");
|
|
723
|
-
searchInput.className = "label-search-input";
|
|
724
|
-
searchInput.type = "search";
|
|
725
|
-
searchInput.placeholder = "Search labels…";
|
|
726
|
-
searchInput.value = state.labelSearchTerm;
|
|
727
|
-
searchInput.addEventListener("input", () => {
|
|
728
|
-
state.labelSearchTerm = searchInput.value;
|
|
729
|
-
renderCardDialog();
|
|
730
|
-
});
|
|
731
|
-
panel.append(searchInput);
|
|
758
|
+
panel.append(createLabelSearchInput());
|
|
732
759
|
|
|
733
760
|
if (labels.length === 0) {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
empty.textContent = "No labels yet. Add one for this card.";
|
|
737
|
-
panel.append(empty);
|
|
738
|
-
const createButton = document.createElement("button");
|
|
739
|
-
createButton.type = "button";
|
|
740
|
-
createButton.className = "ghost-button";
|
|
741
|
-
createButton.textContent = "Create a new label";
|
|
742
|
-
createButton.addEventListener("click", addLabelToSelectedCard);
|
|
743
|
-
panel.append(createButton);
|
|
744
|
-
appendLabelEditorPanel(panel);
|
|
745
|
-
return;
|
|
761
|
+
panel.append(createEmptyLabelsMessage(), createLabelCreateButton());
|
|
762
|
+
return panel;
|
|
746
763
|
}
|
|
747
764
|
|
|
748
765
|
const list = document.createElement("div");
|
|
749
766
|
list.className = "label-editor-list";
|
|
750
|
-
|
|
751
767
|
for (const label of filteredLabels) {
|
|
752
|
-
|
|
753
|
-
row.className = "label-editor-row";
|
|
754
|
-
|
|
755
|
-
const toggle = document.createElement("input");
|
|
756
|
-
toggle.className = "label-toggle";
|
|
757
|
-
toggle.type = "checkbox";
|
|
758
|
-
toggle.checked = card.idLabels.includes(label.id);
|
|
759
|
-
toggle.addEventListener("change", () => {
|
|
760
|
-
if (toggle.checked) {
|
|
761
|
-
if (!card.idLabels.includes(label.id)) card.idLabels.push(label.id);
|
|
762
|
-
} else {
|
|
763
|
-
card.idLabels = card.idLabels.filter((labelId) => labelId !== label.id);
|
|
764
|
-
}
|
|
765
|
-
touchCard(card);
|
|
766
|
-
queueSave("Labels updated");
|
|
767
|
-
renderCardDialog();
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
const nameInput = document.createElement("input");
|
|
771
|
-
nameInput.className = "label-name-input";
|
|
772
|
-
nameInput.type = "text";
|
|
773
|
-
nameInput.value = label.name || "";
|
|
774
|
-
nameInput.placeholder = "Label name";
|
|
775
|
-
nameInput.style.backgroundColor = colorForLabel(label.color);
|
|
776
|
-
nameInput.addEventListener("input", () => {
|
|
777
|
-
label.name = nameInput.value.trim();
|
|
778
|
-
queueSave("Label updated");
|
|
779
|
-
renderCardDialog();
|
|
780
|
-
renderBoard();
|
|
781
|
-
});
|
|
782
|
-
|
|
783
|
-
const colorSelect = document.createElement("select");
|
|
784
|
-
colorSelect.className = "label-color-select";
|
|
785
|
-
for (const color of labelColorOptions()) {
|
|
786
|
-
const option = document.createElement("option");
|
|
787
|
-
option.value = color;
|
|
788
|
-
option.textContent = color.replaceAll("_", " ");
|
|
789
|
-
option.selected = color === label.color;
|
|
790
|
-
colorSelect.append(option);
|
|
791
|
-
}
|
|
792
|
-
colorSelect.addEventListener("change", () => {
|
|
793
|
-
label.color = colorSelect.value;
|
|
794
|
-
queueSave("Label updated");
|
|
795
|
-
renderCardDialog();
|
|
796
|
-
renderBoard();
|
|
797
|
-
});
|
|
798
|
-
|
|
799
|
-
const removeButton = document.createElement("button");
|
|
800
|
-
removeButton.type = "button";
|
|
801
|
-
removeButton.className = "icon-button";
|
|
802
|
-
removeButton.append(createIcon("trash"));
|
|
803
|
-
removeButton.addEventListener("click", () => {
|
|
804
|
-
deleteLabel(label.id);
|
|
805
|
-
queueSave("Label removed");
|
|
806
|
-
render();
|
|
807
|
-
});
|
|
808
|
-
|
|
809
|
-
row.append(toggle, nameInput, colorSelect, removeButton);
|
|
810
|
-
list.append(row);
|
|
768
|
+
list.append(createLabelEditorRow(card, label));
|
|
811
769
|
}
|
|
812
770
|
|
|
813
771
|
if (filteredLabels.length === 0) {
|
|
814
|
-
|
|
815
|
-
empty.className = "empty-state";
|
|
816
|
-
empty.textContent = "No labels match that search.";
|
|
817
|
-
panel.append(empty);
|
|
772
|
+
panel.append(createNoMatchingLabelsMessage());
|
|
818
773
|
} else {
|
|
819
774
|
panel.append(list);
|
|
820
775
|
}
|
|
821
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() {
|
|
822
796
|
const createButton = document.createElement("button");
|
|
823
797
|
createButton.type = "button";
|
|
824
798
|
createButton.className = "ghost-button";
|
|
825
799
|
createButton.textContent = "Create a new label";
|
|
826
800
|
createButton.addEventListener("click", addLabelToSelectedCard);
|
|
827
|
-
|
|
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
|
+
}
|
|
828
893
|
|
|
829
|
-
|
|
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;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function stopLabelEditorShortcut(event) {
|
|
908
|
+
event.stopPropagation();
|
|
909
|
+
if (event.key === "Enter") {
|
|
910
|
+
event.preventDefault();
|
|
911
|
+
event.currentTarget.blur();
|
|
912
|
+
}
|
|
830
913
|
}
|
|
831
914
|
|
|
832
915
|
function appendLabelEditorPanel(panel) {
|
|
@@ -1429,6 +1512,13 @@ function archiveCard(card, options = {}) {
|
|
|
1429
1512
|
render();
|
|
1430
1513
|
}
|
|
1431
1514
|
|
|
1515
|
+
function archiveSelectedCard() {
|
|
1516
|
+
const card = getSelectedCard();
|
|
1517
|
+
if (!card || isCardArchived(card)) return;
|
|
1518
|
+
archiveCard(card, { force: true });
|
|
1519
|
+
cardDialog.close();
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1432
1522
|
function restoreArchivedCard(cardId) {
|
|
1433
1523
|
const card = (state.board?.cards || []).find((candidate) => candidate.id === cardId);
|
|
1434
1524
|
if (!card || !isCardArchived(card)) return;
|
|
@@ -1461,6 +1551,27 @@ async function deleteArchivedCard(cardId) {
|
|
|
1461
1551
|
render();
|
|
1462
1552
|
}
|
|
1463
1553
|
|
|
1554
|
+
async function deleteAllArchivedCards() {
|
|
1555
|
+
const cards = archivedCards();
|
|
1556
|
+
if (cards.length === 0) return;
|
|
1557
|
+
|
|
1558
|
+
const confirmed = await openConfirmDialog({
|
|
1559
|
+
label: "Delete archive",
|
|
1560
|
+
title: "Delete all archived cards?",
|
|
1561
|
+
message: `Delete ${cards.length} archived card${cards.length === 1 ? "" : "s"} permanently? This cannot be undone.`,
|
|
1562
|
+
confirmLabel: "Delete all",
|
|
1563
|
+
danger: true
|
|
1564
|
+
});
|
|
1565
|
+
if (!confirmed) return;
|
|
1566
|
+
|
|
1567
|
+
for (const card of cards) {
|
|
1568
|
+
removeCardCompletely(card.id);
|
|
1569
|
+
}
|
|
1570
|
+
state.selectedCardId = null;
|
|
1571
|
+
queueSave("Archived cards deleted");
|
|
1572
|
+
render();
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1464
1575
|
async function deleteLane(listId) {
|
|
1465
1576
|
const list = listById(listId);
|
|
1466
1577
|
if (!list) return;
|
|
@@ -1623,11 +1734,7 @@ function toggleKeyboardLabel(card, labelIndex) {
|
|
|
1623
1734
|
}
|
|
1624
1735
|
|
|
1625
1736
|
function sortedBoardLabels() {
|
|
1626
|
-
return [...(state.board?.labels || [])]
|
|
1627
|
-
const leftName = (left.name || left.color || "").toLowerCase();
|
|
1628
|
-
const rightName = (right.name || right.color || "").toLowerCase();
|
|
1629
|
-
return leftName.localeCompare(rightName);
|
|
1630
|
-
});
|
|
1737
|
+
return [...(state.board?.labels || [])];
|
|
1631
1738
|
}
|
|
1632
1739
|
|
|
1633
1740
|
function setKeyboardCard(cardId, shouldRender = true) {
|
|
@@ -1644,6 +1751,14 @@ function setKeyboardCard(cardId, shouldRender = true) {
|
|
|
1644
1751
|
async function deleteSelectedCard() {
|
|
1645
1752
|
const card = getSelectedCard();
|
|
1646
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;
|
|
1647
1762
|
const confirmed = await openConfirmDialog({
|
|
1648
1763
|
label: "Delete card",
|
|
1649
1764
|
title: "Delete card?",
|
|
@@ -1651,11 +1766,11 @@ async function deleteSelectedCard() {
|
|
|
1651
1766
|
confirmLabel: "Delete",
|
|
1652
1767
|
danger: true
|
|
1653
1768
|
});
|
|
1654
|
-
if (!confirmed) return;
|
|
1769
|
+
if (!confirmed) return false;
|
|
1655
1770
|
removeCardCompletely(card.id);
|
|
1656
1771
|
queueSave("Card deleted");
|
|
1657
|
-
cardDialog.close();
|
|
1658
1772
|
render();
|
|
1773
|
+
return true;
|
|
1659
1774
|
}
|
|
1660
1775
|
|
|
1661
1776
|
function addChecklistToSelectedCard() {
|
|
@@ -2781,17 +2896,34 @@ function hydrateIdentityFromGitConfig() {
|
|
|
2781
2896
|
const gitUserName = storageSafeText(state.config?.gitUserName);
|
|
2782
2897
|
const gitUserEmail = storageSafeText(state.config?.gitUserEmail);
|
|
2783
2898
|
|
|
2784
|
-
if (
|
|
2899
|
+
if (gitUserName) {
|
|
2785
2900
|
state.currentUserName = gitUserName;
|
|
2786
2901
|
localStorage.setItem(USER_STORAGE_KEY, gitUserName);
|
|
2787
2902
|
}
|
|
2788
2903
|
|
|
2789
|
-
if (
|
|
2904
|
+
if (gitUserEmail) {
|
|
2790
2905
|
state.currentUserEmail = gitUserEmail;
|
|
2791
2906
|
localStorage.setItem(USER_EMAIL_STORAGE_KEY, gitUserEmail);
|
|
2792
2907
|
}
|
|
2793
2908
|
}
|
|
2794
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
|
+
|
|
2795
2927
|
function colorForLabel(color) {
|
|
2796
2928
|
return labelColorMap[color] || labelColorMap.blue;
|
|
2797
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
|
|
|
@@ -215,7 +219,10 @@
|
|
|
215
219
|
<p class="modal-label">Archive</p>
|
|
216
220
|
<h2>Archived cards</h2>
|
|
217
221
|
</div>
|
|
218
|
-
<
|
|
222
|
+
<div class="archive-header-actions">
|
|
223
|
+
<button id="deleteAllArchivedButton" type="button" class="danger-button">Delete all</button>
|
|
224
|
+
<button id="closeArchiveButton" type="button" class="icon-button" aria-label="Close archive">✕</button>
|
|
225
|
+
</div>
|
|
219
226
|
</header>
|
|
220
227
|
<div id="archiveList" class="archive-list"></div>
|
|
221
228
|
</form>
|
|
@@ -302,11 +309,18 @@
|
|
|
302
309
|
<h3 class="card-title">Task</h3>
|
|
303
310
|
<input class="inline-title-input card-title-inline-input" type="text" maxlength="200" placeholder="Task" hidden />
|
|
304
311
|
</div>
|
|
305
|
-
<
|
|
306
|
-
<
|
|
307
|
-
<
|
|
308
|
-
|
|
309
|
-
|
|
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>
|
|
310
324
|
</div>
|
|
311
325
|
<p class="card-description"></p>
|
|
312
326
|
<footer class="card-footer"></footer>
|
package/public/styles.css
CHANGED
|
@@ -212,8 +212,8 @@ button {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
.topbar-actions,
|
|
215
|
-
.board-meta,
|
|
216
215
|
.lane-actions,
|
|
216
|
+
.archive-header-actions,
|
|
217
217
|
.modal-footer,
|
|
218
218
|
.panel-header,
|
|
219
219
|
.settings-meta {
|
|
@@ -222,6 +222,86 @@ button {
|
|
|
222
222
|
align-items: center;
|
|
223
223
|
}
|
|
224
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
|
+
|
|
292
|
+
.archive-header-actions {
|
|
293
|
+
flex: 0 0 auto;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.card-detail-actions {
|
|
297
|
+
display: grid;
|
|
298
|
+
gap: 0.65rem;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#promptDialog .modal-footer {
|
|
302
|
+
margin-top: 1rem;
|
|
303
|
+
}
|
|
304
|
+
|
|
225
305
|
.primary-button,
|
|
226
306
|
.ghost-button,
|
|
227
307
|
.danger-button,
|
|
@@ -442,11 +522,42 @@ button {
|
|
|
442
522
|
}
|
|
443
523
|
|
|
444
524
|
.card-label {
|
|
525
|
+
position: relative;
|
|
445
526
|
width: calc(42px * var(--ui-scale));
|
|
446
527
|
height: calc(8px * var(--ui-scale));
|
|
447
528
|
border-radius: 999px;
|
|
448
529
|
}
|
|
449
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
|
+
|
|
450
561
|
.card-cover {
|
|
451
562
|
display: none;
|
|
452
563
|
margin: -0.9rem -0.9rem 0.85rem;
|
|
@@ -555,7 +666,14 @@ button {
|
|
|
555
666
|
background: #22c55e;
|
|
556
667
|
}
|
|
557
668
|
|
|
558
|
-
.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 {
|
|
559
677
|
width: calc(30px * var(--ui-scale));
|
|
560
678
|
min-width: calc(30px * var(--ui-scale));
|
|
561
679
|
height: calc(30px * var(--ui-scale));
|
|
@@ -575,7 +693,7 @@ button {
|
|
|
575
693
|
transition: opacity 140ms ease, color 140ms ease, transform 140ms ease;
|
|
576
694
|
}
|
|
577
695
|
|
|
578
|
-
.card-
|
|
696
|
+
.card-hover-action-button::after {
|
|
579
697
|
content: attr(data-tooltip);
|
|
580
698
|
position: absolute;
|
|
581
699
|
right: calc(100% + 0.5rem);
|
|
@@ -596,7 +714,7 @@ button {
|
|
|
596
714
|
transition: opacity 90ms ease;
|
|
597
715
|
}
|
|
598
716
|
|
|
599
|
-
.card-
|
|
717
|
+
.card-hover-action-button svg {
|
|
600
718
|
width: calc(23px * var(--ui-scale));
|
|
601
719
|
height: calc(23px * var(--ui-scale));
|
|
602
720
|
stroke: currentColor;
|
|
@@ -606,18 +724,22 @@ button {
|
|
|
606
724
|
stroke-linejoin: round;
|
|
607
725
|
}
|
|
608
726
|
|
|
609
|
-
.card
|
|
727
|
+
.card:hover .card-hover-action-button {
|
|
610
728
|
opacity: 1;
|
|
611
729
|
pointer-events: auto;
|
|
612
730
|
}
|
|
613
731
|
|
|
614
|
-
.card-
|
|
732
|
+
.card-hover-action-button:hover {
|
|
615
733
|
color: var(--text);
|
|
616
734
|
transform: translateY(-1px);
|
|
617
735
|
}
|
|
618
736
|
|
|
619
|
-
.card-
|
|
620
|
-
|
|
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 {
|
|
621
743
|
opacity: 1;
|
|
622
744
|
}
|
|
623
745
|
|