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 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 an older `board.json` exists, KanbanQube imports it into the split `board/` layout the first time it starts. The original `board.json` is left in place but is no longer the active storage file. If `uploads/` does not exist, it is created when the first file is uploaded.
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
- <a href="https://www.star-history.com/?repos=mathiasconradt%2Fkanbanqube&type=timeline&logscale=&legend=top-left">
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
+ [![Star History Chart](https://api.star-history.com/chart?repos=mathiasconradt/kanbanqube&type=timeline&logscale&legend=top-left&nocache)](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: await gitService.gitUserEmail(config.workspaceDir)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbanqube",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "Local-first Kanban board backed by normal files",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Mathias Conradt",
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
- const storageLabel = state.config.storagePath ? `${state.config.storagePath}/` : (state.config.boardFile || "board.json");
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
- userBadge.textContent = state.currentUserName.trim() || "Guest";
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
- const empty = document.createElement("div");
693
- empty.className = "empty-state label-summary-trigger";
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
- addLabelButton.textContent = state.labelEditorOpen ? "×" : "+";
710
- addLabelButton.setAttribute("aria-label", state.labelEditorOpen ? "Close labels panel" : "Open labels panel");
711
- if (!state.labelEditorOpen) return;
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
- const empty = document.createElement("div");
735
- empty.className = "empty-state";
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
- const row = document.createElement("div");
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
- const empty = document.createElement("div");
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
- panel.append(createButton);
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
- appendLabelEditorPanel(panel);
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 || [])].sort((left, right) => {
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 (!state.currentUserName.trim() && gitUserName) {
2899
+ if (gitUserName) {
2785
2900
  state.currentUserName = gitUserName;
2786
2901
  localStorage.setItem(USER_STORAGE_KEY, gitUserName);
2787
2902
  }
2788
2903
 
2789
- if (!state.currentUserEmail.trim() && gitUserEmail) {
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
- <button id="deleteCardButton" type="button" class="danger-button">Delete card</button>
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
- <button id="closeArchiveButton" type="button" class="icon-button" aria-label="Close archive">✕</button>
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
- <button class="card-archive-button" type="button" aria-label="Archive card" data-tooltip="Archive">
306
- <svg viewBox="0 0 24 24" aria-hidden="true">
307
- <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" />
308
- </svg>
309
- </button>
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-archive-button {
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-archive-button::after {
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-archive-button svg {
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.is-done:hover .card-archive-button {
727
+ .card:hover .card-hover-action-button {
610
728
  opacity: 1;
611
729
  pointer-events: auto;
612
730
  }
613
731
 
614
- .card-archive-button:hover {
732
+ .card-hover-action-button:hover {
615
733
  color: var(--text);
616
734
  transform: translateY(-1px);
617
735
  }
618
736
 
619
- .card-archive-button:hover::after,
620
- .card-archive-button:focus-visible::after {
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