kanbanqube 1.0.11 → 1.0.13

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
@@ -9,7 +9,7 @@
9
9
 
10
10
  KanbanQube is a local-first Kanban board app for solo users and very small teams. A board lives as normal files in a regular vault folder on your machine and can optionally be placed inside a Git repository, so changes can be versioned and synced with tools you already use.
11
11
 
12
- The app provides lanes, cards, labels, checklists, comments, card covers, file attachments, archived cards, search, and a card-detail view. Uploaded files are stored in an `uploads/` folder, while board data is split into per-object JSON files under `board/` so Git can merge independent card and checklist edits more cleanly.
12
+ The app provides lanes, cards, labels, checklists, comments, due dates, assignees, card covers, file attachments, archived cards, search, keyboard navigation, and a card-detail view. Uploaded files are stored in an `uploads/` folder, while board data is split into per-object JSON files under `board/` so Git can merge independent card and checklist edits more cleanly.
13
13
 
14
14
  ![KanbanQube board screenshot](screenshot.png)
15
15
 
@@ -26,6 +26,7 @@ The app provides lanes, cards, labels, checklists, comments, card covers, file a
26
26
  - [Trello Import](#trello-import)
27
27
  - [Demo Board](#demo-board)
28
28
  - [Identity](#identity)
29
+ - [Assignees And Due Dates](#assignees-and-due-dates)
29
30
  - [Attachments And Covers](#attachments-and-covers)
30
31
  - [Keyboard Shortcuts](#keyboard-shortcuts)
31
32
  - [Development](#development)
@@ -144,6 +145,7 @@ Use the board view for quick work:
144
145
  - Click the board title or lane title to rename it inline.
145
146
  - Click a card to open its details.
146
147
  - Settings can enable inline editing for existing card titles in board lanes.
148
+ - Set due dates and assignees in card details. Cards show due dates and assigned user avatars directly in the board view.
147
149
 
148
150
  ## Git Sync
149
151
 
@@ -210,6 +212,16 @@ git config --global user.email
210
212
 
211
213
  That name is used for comments and activity entries. The settings dialog shows the detected name and email as read-only values.
212
214
 
215
+ KanbanQube also reads commit authors from the vault Git repository and makes them available as assignable users. Git can usually provide the author name and email address from existing commits. If the vault has no Git history, KanbanQube still includes your own detected Git identity.
216
+
217
+ User avatars are loaded from Gravatar based on email address. If no Gravatar image exists, the app falls back to initials.
218
+
219
+ ## Assignees And Due Dates
220
+
221
+ Open a card to assign users and set a due date. Assigned users are shown as small avatars on the card in board view, with their name available on hover.
222
+
223
+ Due dates are stored on the card and shown below the card title in board view. Completed cards keep the due date visible in a completed state, while overdue open cards are highlighted.
224
+
213
225
  ## Attachments And Covers
214
226
 
215
227
  Drop files onto a card or into the card details view to attach them. Files are uploaded into the vault `uploads/` folder.
package/app.js CHANGED
@@ -10,6 +10,7 @@ const { createBoardService } = require("./services/boardService");
10
10
  const { createGitSyncService } = require("./services/gitSyncService");
11
11
  const { createImportService } = require("./services/importService");
12
12
  const { createUploadService } = require("./services/uploadService");
13
+ const { createUserService } = require("./services/userService");
13
14
 
14
15
  function createApp(config) {
15
16
  const app = express();
@@ -17,6 +18,7 @@ function createApp(config) {
17
18
  const gitSyncService = createGitSyncService(config);
18
19
  const importService = createImportService(config);
19
20
  const uploadService = createUploadService(config, boardService);
21
+ const userService = createUserService(config);
20
22
 
21
23
  app.disable("x-powered-by");
22
24
  app.use(express.json({ limit: "5mb", type: "application/json" }));
@@ -27,7 +29,8 @@ function createApp(config) {
27
29
  boardService,
28
30
  gitSyncService,
29
31
  importService,
30
- uploadService
32
+ uploadService,
33
+ userService
31
34
  }));
32
35
 
33
36
  app.get(`/${config.demoBoardFileName}`, (_request, response) => {
@@ -62,7 +65,8 @@ function createApp(config) {
62
65
  boardService,
63
66
  gitSyncService,
64
67
  importService,
65
- uploadService
68
+ uploadService,
69
+ userService
66
70
  }
67
71
  };
68
72
  }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- const crypto = require("node:crypto");
3
+ const { gravatarUrlForEmail } = require("../utils/userUtils");
4
4
 
5
5
  function createConfigController(config, gitService) {
6
6
  async function getConfig(_request, response) {
@@ -22,13 +22,6 @@ function createConfigController(config, gitService) {
22
22
  };
23
23
  }
24
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
-
31
25
  module.exports = {
32
- createConfigController,
33
- gravatarUrlForEmail
26
+ createConfigController
34
27
  };
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+
3
+ function createUserController(userService) {
4
+ async function getUsers(_request, response) {
5
+ response.json({ users: await userService.listUsers() });
6
+ }
7
+
8
+ return {
9
+ getUsers
10
+ };
11
+ }
12
+
13
+ module.exports = {
14
+ createUserController
15
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbanqube",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
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
@@ -24,6 +24,7 @@ const SYNC_TIMESTAMP_FORMAT = {
24
24
  const state = {
25
25
  board: null,
26
26
  config: null,
27
+ users: [],
27
28
  currentUserName: localStorage.getItem(USER_STORAGE_KEY) || "",
28
29
  currentUserEmail: localStorage.getItem(USER_EMAIL_STORAGE_KEY) || "",
29
30
  showCardDescriptions: localStorage.getItem(SHOW_CARD_DESCRIPTIONS_STORAGE_KEY) === "true",
@@ -81,6 +82,7 @@ const deleteAllArchivedButton = document.getElementById("deleteAllArchivedButton
81
82
 
82
83
  const cardDialog = document.getElementById("cardDialog");
83
84
  const cardTitleInput = document.getElementById("cardTitleInput");
85
+ const cardDueInput = document.getElementById("cardDueInput");
84
86
  const archivedCardBanner = document.getElementById("archivedCardBanner");
85
87
  const cardDetailsCover = document.getElementById("cardDetailsCover");
86
88
  const removeCoverButton = document.getElementById("removeCoverButton");
@@ -92,6 +94,7 @@ const attachmentDropZone = document.getElementById("attachmentDropZone");
92
94
  const attachmentsContainer = document.getElementById("attachmentsContainer");
93
95
  const editDescriptionButton = document.getElementById("editDescriptionButton");
94
96
  const checklistsContainer = document.getElementById("checklistsContainer");
97
+ const assigneesContainer = document.getElementById("assigneesContainer");
95
98
  const cardLabels = document.getElementById("cardLabels");
96
99
  const labelEditorContainer = document.getElementById("labelEditorContainer");
97
100
  const activityList = document.getElementById("activityList");
@@ -169,9 +172,10 @@ try {
169
172
  }
170
173
 
171
174
  async function bootstrap() {
172
- const [boardResponse, configResponse] = await Promise.all([
175
+ const [boardResponse, configResponse, usersResponse] = await Promise.all([
173
176
  fetch("/api/board"),
174
- fetch("/api/config")
177
+ fetch("/api/config"),
178
+ fetch("/api/users")
175
179
  ]);
176
180
 
177
181
  if (!boardResponse.ok) {
@@ -180,6 +184,7 @@ async function bootstrap() {
180
184
 
181
185
  state.board = await boardResponse.json();
182
186
  state.config = configResponse.ok ? await configResponse.json() : { boardFile: "board.json", gitRemote: null, gitUserName: null, gitUserEmail: null };
187
+ state.users = usersResponse.ok ? (await usersResponse.json()).users || [] : [];
183
188
  hydrateIdentityFromGitConfig();
184
189
  applyIconStyle();
185
190
  settingsBoardFile.textContent = `Storage: ${state.config.workspacePath || "current folder"}`;
@@ -319,6 +324,7 @@ function wireEvents() {
319
324
  renderBoard();
320
325
  renderCardDialog();
321
326
  });
327
+ cardDueInput.addEventListener("change", updateSelectedCardDueDate);
322
328
 
323
329
  promptCancelButton.addEventListener("click", () => promptDialog.close("cancel"));
324
330
  appDialogCloseButton.addEventListener("click", () => appDialog.close("cancel"));
@@ -570,6 +576,7 @@ function renderCard(card, options = {}) {
570
576
  for (const badge of buildCardBadges(card)) {
571
577
  const badgeNode = document.createElement("span");
572
578
  badgeNode.className = "badge";
579
+ if (badge.className) badgeNode.classList.add(badge.className);
573
580
  if (badge.symbol) {
574
581
  const symbol = document.createElement("span");
575
582
  symbol.className = "badge-symbol";
@@ -585,6 +592,7 @@ function renderCard(card, options = {}) {
585
592
  }
586
593
  footer.append(badgeNode);
587
594
  }
595
+ renderBoardCardAssignees(card, footer);
588
596
 
589
597
  node.addEventListener("click", () => {
590
598
  setKeyboardCard(card.id, true);
@@ -615,6 +623,7 @@ function renderCardDialog() {
615
623
  if (!card) return;
616
624
 
617
625
  cardTitleInput.value = card.name || "";
626
+ cardDueInput.value = dateInputValue(card.due);
618
627
  archivedCardBanner.hidden = !isCardArchived(card);
619
628
  const coverUrl = coverUrlForCard(card);
620
629
  cardDetailsCover.hidden = !coverUrl;
@@ -628,12 +637,121 @@ function renderCardDialog() {
628
637
  editDescriptionButton.textContent = state.descriptionEditing ? "Done" : "Edit";
629
638
  archiveCardButton.hidden = isCardArchived(card);
630
639
 
640
+ renderAssignees(card);
631
641
  renderLabelsEditor(card);
632
642
  renderAttachments(card);
633
643
  renderChecklists(card);
634
644
  renderActivity(card);
635
645
  }
636
646
 
647
+ function renderBoardCardAssignees(card, footer) {
648
+ const users = assignedUsersForCard(card).slice(0, 4);
649
+ if (users.length === 0) return;
650
+
651
+ const list = document.createElement("div");
652
+ list.className = "card-assignee-list";
653
+ for (const user of users) {
654
+ list.append(createUserAvatar(user, "small-user-avatar"));
655
+ }
656
+ footer.append(list);
657
+ }
658
+
659
+ function updateSelectedCardDueDate() {
660
+ const card = getSelectedCard();
661
+ if (!card) return;
662
+
663
+ const previousDue = card.due ?? null;
664
+ card.due = cardDueInput.value ? `${cardDueInput.value}T12:00:00.000Z` : null;
665
+ card.dueComplete = card.due ? Boolean(card.dueComplete) : false;
666
+ if (previousDue === card.due) return;
667
+
668
+ touchCard(card);
669
+ pushAction("updateCard", {
670
+ idCard: card.id,
671
+ card: cardActionSnapshot(card),
672
+ list: { id: card.idList, name: listById(card.idList)?.name || "" },
673
+ old: { due: previousDue },
674
+ due: card.due
675
+ });
676
+ queueSave(card.due ? "Due date updated" : "Due date removed");
677
+ renderBoard();
678
+ }
679
+
680
+ function renderAssignees(card) {
681
+ assigneesContainer.textContent = "";
682
+ if (state.users.length === 0) {
683
+ const empty = document.createElement("div");
684
+ empty.className = "empty-state";
685
+ empty.textContent = "No Git users found yet.";
686
+ assigneesContainer.append(empty);
687
+ return;
688
+ }
689
+
690
+ card.kanbanQubeAssignees = Array.isArray(card.kanbanQubeAssignees) ? card.kanbanQubeAssignees : [];
691
+ for (const user of state.users) {
692
+ assigneesContainer.append(createAssigneeRow(card, user));
693
+ }
694
+ }
695
+
696
+ function createAssigneeRow(card, user) {
697
+ const row = document.createElement("label");
698
+ row.className = "assignee-row";
699
+
700
+ const checkbox = document.createElement("input");
701
+ checkbox.type = "checkbox";
702
+ checkbox.checked = card.kanbanQubeAssignees.includes(user.id);
703
+ checkbox.addEventListener("change", () => {
704
+ toggleCardAssignee(card, user.id, checkbox.checked);
705
+ });
706
+
707
+ const text = document.createElement("span");
708
+ text.className = "assignee-name";
709
+ text.textContent = user.email ? `${user.name || user.email} (${user.email})` : user.name || "Unknown user";
710
+
711
+ row.append(checkbox, createUserAvatar(user, "assignee-avatar"), text);
712
+ return row;
713
+ }
714
+
715
+ function toggleCardAssignee(card, userId, shouldAssign) {
716
+ const previousAssignees = Array.isArray(card.kanbanQubeAssignees) ? card.kanbanQubeAssignees : [];
717
+ card.kanbanQubeAssignees = shouldAssign
718
+ ? [...new Set([...previousAssignees, userId])]
719
+ : previousAssignees.filter((candidate) => candidate !== userId);
720
+ touchCard(card);
721
+ pushAction("updateCard", {
722
+ idCard: card.id,
723
+ card: cardActionSnapshot(card),
724
+ list: { id: card.idList, name: listById(card.idList)?.name || "" },
725
+ old: { kanbanQubeAssignees: previousAssignees },
726
+ kanbanQubeAssignees: card.kanbanQubeAssignees
727
+ });
728
+ queueSave("Assignees updated");
729
+ renderBoard();
730
+ }
731
+
732
+ function createUserAvatar(user, className) {
733
+ const avatar = document.createElement("span");
734
+ avatar.className = className;
735
+ avatar.dataset.tooltip = user.email ? `${user.name} <${user.email}>` : user.name;
736
+
737
+ const image = document.createElement("img");
738
+ image.alt = "";
739
+ image.hidden = !user.avatarUrl;
740
+ if (user.avatarUrl) image.src = user.avatarUrl;
741
+
742
+ const fallback = document.createElement("span");
743
+ fallback.textContent = user.initials || initialsFor(user.name || user.email || "User");
744
+ fallback.hidden = Boolean(user.avatarUrl);
745
+
746
+ image.addEventListener("error", () => {
747
+ image.hidden = true;
748
+ fallback.hidden = false;
749
+ });
750
+
751
+ avatar.append(image, fallback);
752
+ return avatar;
753
+ }
754
+
637
755
  function renderArchiveDialog() {
638
756
  archiveList.textContent = "";
639
757
  const cards = archivedCards();
@@ -2377,6 +2495,7 @@ async function reloadBoardAfterSync() {
2377
2495
  }
2378
2496
 
2379
2497
  state.board = await response.json();
2498
+ state.users = await loadUsers();
2380
2499
  state.editingBoardTitle = false;
2381
2500
  state.editingBoardTitleValue = "";
2382
2501
  state.editingLaneTitleId = null;
@@ -2393,6 +2512,13 @@ async function reloadBoardAfterSync() {
2393
2512
  render();
2394
2513
  }
2395
2514
 
2515
+ async function loadUsers() {
2516
+ const response = await fetch("/api/users");
2517
+ if (!response.ok) return state.users || [];
2518
+ const payload = await response.json();
2519
+ return Array.isArray(payload.users) ? payload.users : [];
2520
+ }
2521
+
2396
2522
  async function saveBoardNow() {
2397
2523
  if (!state.board) return;
2398
2524
  state.isSaving = true;
@@ -2494,6 +2620,13 @@ function labelsForCard(card) {
2494
2620
  return (card.idLabels || []).map((labelId) => labels.get(labelId)).filter(Boolean);
2495
2621
  }
2496
2622
 
2623
+ function assignedUsersForCard(card) {
2624
+ const users = new Map((state.users || []).map((user) => [user.id, user]));
2625
+ return (Array.isArray(card.kanbanQubeAssignees) ? card.kanbanQubeAssignees : [])
2626
+ .map((userId) => users.get(userId))
2627
+ .filter(Boolean);
2628
+ }
2629
+
2497
2630
  function checklistsForCard(cardId) {
2498
2631
  return (state.board.checklists || []).filter((checklist) => checklist.idCard === cardId);
2499
2632
  }
@@ -2545,6 +2678,25 @@ function isCardArchived(card) {
2545
2678
  return Boolean(card?.closed);
2546
2679
  }
2547
2680
 
2681
+ function dateInputValue(value) {
2682
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : "";
2683
+ }
2684
+
2685
+ function formatDueDate(value) {
2686
+ const input = dateInputValue(value);
2687
+ if (!input) return "";
2688
+ const date = new Date(`${input}T12:00:00`);
2689
+ return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
2690
+ }
2691
+
2692
+ function dueBadgeClass(card) {
2693
+ if (card.dueComplete) return "badge-due-complete";
2694
+ const input = dateInputValue(card.due);
2695
+ if (!input) return "";
2696
+ const today = new Date().toISOString().slice(0, 10);
2697
+ return input < today ? "badge-due-overdue" : "";
2698
+ }
2699
+
2548
2700
  function archivedCards() {
2549
2701
  return [...(state.board?.cards || [])]
2550
2702
  .filter((card) => isCardArchived(card))
@@ -2730,6 +2882,7 @@ function cardElementAfter(container, y) {
2730
2882
 
2731
2883
  function buildCardBadges(card) {
2732
2884
  const badges = [];
2885
+ if (card.due) badges.push({ icon: "clock", text: formatDueDate(card.due), className: dueBadgeClass(card) });
2733
2886
  if (card.badges?.description) badges.push({ icon: "description", text: "" });
2734
2887
  if (card.badges?.comments) badges.push({ icon: "comment", text: String(card.badges.comments) });
2735
2888
  if (card.badges?.checkItems) badges.push({ symbol: "☑", text: `${card.badges.checkItemsChecked || 0}/${card.badges.checkItems}` });
@@ -2739,6 +2892,7 @@ function buildCardBadges(card) {
2739
2892
 
2740
2893
  function createIcon(name) {
2741
2894
  const paths = {
2895
+ clock: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13v5l3 1.8"],
2742
2896
  description: ["M4 6h16M4 12h12M4 18h9"],
2743
2897
  comment: ["M5 5.5h14v9H8.5L5 18V5.5Z"],
2744
2898
  checklist: ["M9 7h11M9 12h11M9 17h11M4 7.2l1.2 1.2L7.5 6M4 12.2l1.2 1.2 2.3-2.4M4 17.2l1.2 1.2 2.3-2.4"],
package/public/index.html CHANGED
@@ -40,7 +40,6 @@
40
40
 
41
41
  <section class="board-header">
42
42
  <div>
43
- <p class="eyebrow">Git-synced board</p>
44
43
  <div class="board-title-shell">
45
44
  <h1 id="boardTitle">KanbanQube Board</h1>
46
45
  <input id="boardTitleInlineInput" class="inline-title-input board-title-inline-input" type="text" maxlength="200" hidden />
@@ -57,6 +56,10 @@
57
56
  <div>
58
57
  <p class="modal-label">Card details</p>
59
58
  <input id="cardTitleInput" class="title-input" type="text" maxlength="200" placeholder="Task" />
59
+ <label class="card-due-field">
60
+ <span>Due date</span>
61
+ <input id="cardDueInput" type="date" />
62
+ </label>
60
63
  </div>
61
64
  <button id="closeCardButton" type="button" class="icon-button" aria-label="Close card dialog">✕</button>
62
65
  </header>
@@ -97,6 +100,13 @@
97
100
  </section>
98
101
 
99
102
  <aside class="modal-sidebar">
103
+ <section class="sidebar-panel">
104
+ <div class="panel-header">
105
+ <h2>Assignees</h2>
106
+ </div>
107
+ <div id="assigneesContainer" class="assignees-container"></div>
108
+ </section>
109
+
100
110
  <section class="sidebar-panel">
101
111
  <div class="panel-header">
102
112
  <h2>Labels</h2>
package/public/styles.css CHANGED
@@ -388,16 +388,15 @@ button {
388
388
  }
389
389
 
390
390
  .board-header {
391
- display: flex;
392
- justify-content: space-between;
393
- align-items: end;
394
- gap: 1rem;
395
- padding: 1.1rem 1.5rem 0;
391
+ display: block;
392
+ padding: 0.75rem 1rem;
393
+ background: rgba(14, 18, 26, 0.34);
394
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
395
+ backdrop-filter: blur(8px);
396
396
  }
397
397
 
398
398
  .board-header > div:first-child {
399
399
  min-width: 0;
400
- flex: 1 1 auto;
401
400
  }
402
401
 
403
402
  .eyebrow,
@@ -411,12 +410,29 @@ button {
411
410
 
412
411
  #boardTitle {
413
412
  margin: 0;
414
- font-size: clamp(1.75rem, 3vw, 2.4rem);
413
+ max-width: min(52rem, calc(100vw - 3rem));
414
+ overflow: hidden;
415
+ text-overflow: ellipsis;
416
+ white-space: nowrap;
417
+ font-size: clamp(1.05rem, 1.4vw, 1.35rem);
418
+ line-height: 1.25;
419
+ font-weight: 800;
415
420
  }
416
421
 
417
422
  .board-title-shell {
418
423
  min-width: 0;
419
- width: 100%;
424
+ display: inline-flex;
425
+ align-items: center;
426
+ max-width: 100%;
427
+ padding: 0;
428
+ }
429
+
430
+ .board-title-shell:has(.board-title-inline-input:not([hidden])) {
431
+ padding: 0.45rem 0.65rem;
432
+ border-radius: calc(10px * var(--ui-scale));
433
+ background: rgba(255, 255, 255, 0.12);
434
+ border: 1px solid rgba(255, 255, 255, 0.08);
435
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
420
436
  }
421
437
 
422
438
  .meta-badge {
@@ -595,8 +611,11 @@ button {
595
611
  }
596
612
 
597
613
  .board-title-inline-input {
598
- font-size: clamp(1.75rem, 3vw, 2.4rem);
599
- font-weight: 700;
614
+ width: min(52rem, calc(100vw - 3rem));
615
+ max-width: 100%;
616
+ font-size: clamp(1.05rem, 1.4vw, 1.35rem);
617
+ line-height: 1.25;
618
+ font-weight: 800;
600
619
  }
601
620
 
602
621
  .lane-title-inline-input {
@@ -779,6 +798,97 @@ button {
779
798
  margin-top: 0.9rem;
780
799
  color: var(--muted);
781
800
  font-size: 0.9rem;
801
+ align-items: center;
802
+ }
803
+
804
+ .card-assignee-list {
805
+ margin-left: auto;
806
+ display: flex;
807
+ align-items: center;
808
+ gap: 0.25rem;
809
+ }
810
+
811
+ .small-user-avatar,
812
+ .assignee-avatar {
813
+ position: relative;
814
+ display: inline-grid;
815
+ place-items: center;
816
+ flex: 0 0 auto;
817
+ border-radius: 999px;
818
+ border: 1px solid rgba(255, 255, 255, 0.18);
819
+ background: rgba(255, 255, 255, 0.1);
820
+ color: var(--text);
821
+ overflow: visible;
822
+ }
823
+
824
+ .small-user-avatar {
825
+ width: calc(26px * var(--ui-scale));
826
+ height: calc(26px * var(--ui-scale));
827
+ font-size: 0.68rem;
828
+ }
829
+
830
+ .assignee-avatar {
831
+ width: calc(30px * var(--ui-scale));
832
+ height: calc(30px * var(--ui-scale));
833
+ font-size: 0.74rem;
834
+ }
835
+
836
+ .small-user-avatar img,
837
+ .small-user-avatar > span,
838
+ .assignee-avatar img,
839
+ .assignee-avatar > span {
840
+ width: 100%;
841
+ height: 100%;
842
+ border-radius: inherit;
843
+ }
844
+
845
+ .small-user-avatar img[hidden],
846
+ .small-user-avatar > span[hidden],
847
+ .assignee-avatar img[hidden],
848
+ .assignee-avatar > span[hidden] {
849
+ display: none !important;
850
+ }
851
+
852
+ .small-user-avatar img,
853
+ .assignee-avatar img {
854
+ display: block;
855
+ object-fit: cover;
856
+ }
857
+
858
+ .small-user-avatar > span,
859
+ .assignee-avatar > span {
860
+ display: inline-flex;
861
+ align-items: center;
862
+ justify-content: center;
863
+ font-weight: 800;
864
+ background: rgba(255, 255, 255, 0.1);
865
+ }
866
+
867
+ .small-user-avatar::after,
868
+ .assignee-avatar::after {
869
+ content: attr(data-tooltip);
870
+ position: absolute;
871
+ right: 0;
872
+ bottom: calc(100% + 0.5rem);
873
+ padding: 0.32rem 0.5rem;
874
+ border-radius: calc(10px * var(--ui-scale));
875
+ background: rgba(12, 15, 21, 0.96);
876
+ border: 1px solid rgba(255, 255, 255, 0.12);
877
+ color: var(--text);
878
+ font-size: 0.82rem;
879
+ font-weight: 600;
880
+ line-height: 1.2;
881
+ white-space: nowrap;
882
+ opacity: 0;
883
+ pointer-events: none;
884
+ z-index: 20;
885
+ box-shadow: 0 calc(10px * var(--ui-scale)) calc(24px * var(--ui-scale)) rgba(0, 0, 0, 0.32);
886
+ transition: opacity 90ms ease;
887
+ }
888
+
889
+ .small-user-avatar:hover::after,
890
+ .assignee-avatar:hover::after {
891
+ opacity: 1;
782
892
  }
783
893
 
784
894
  .badge {
@@ -788,6 +898,14 @@ button {
788
898
  line-height: 1;
789
899
  }
790
900
 
901
+ .badge-due-overdue {
902
+ color: #fecaca;
903
+ }
904
+
905
+ .badge-due-complete {
906
+ color: #bbf7d0;
907
+ }
908
+
791
909
  .badge-symbol {
792
910
  font-size: 0.95rem;
793
911
  line-height: 1;
@@ -1011,6 +1129,24 @@ button {
1011
1129
  width: 100%;
1012
1130
  }
1013
1131
 
1132
+ .card-due-field {
1133
+ display: inline-flex;
1134
+ align-items: center;
1135
+ gap: 0.5rem;
1136
+ margin-top: 0.45rem;
1137
+ color: var(--muted);
1138
+ font-size: 0.9rem;
1139
+ }
1140
+
1141
+ .card-due-field input {
1142
+ min-height: calc(32px * var(--ui-scale));
1143
+ padding: 0.25rem 0.45rem;
1144
+ border-radius: calc(10px * var(--ui-scale));
1145
+ border: 1px solid rgba(255, 255, 255, 0.12);
1146
+ background: rgba(255, 255, 255, 0.05);
1147
+ color: var(--text);
1148
+ }
1149
+
1014
1150
  .archived-card-banner {
1015
1151
  margin-bottom: 1rem;
1016
1152
  padding: 0.85rem 1rem;
@@ -1092,7 +1228,10 @@ button {
1092
1228
  }
1093
1229
 
1094
1230
  .modal-large .modal-sidebar {
1095
- grid-template-rows: auto minmax(0, 1fr) auto;
1231
+ grid-auto-rows: max-content;
1232
+ align-content: start;
1233
+ overflow: auto;
1234
+ padding-right: 0.2rem;
1096
1235
  }
1097
1236
 
1098
1237
  .sidebar-panel,
@@ -1110,12 +1249,6 @@ button {
1110
1249
  min-height: 0;
1111
1250
  }
1112
1251
 
1113
- .modal-large .modal-sidebar .sidebar-panel:nth-of-type(2) {
1114
- display: grid;
1115
- overflow: hidden;
1116
- grid-template-rows: auto auto minmax(0, 1fr);
1117
- }
1118
-
1119
1252
  .modal-large .attachments-panel,
1120
1253
  .modal-large .checklists-panel {
1121
1254
  display: grid;
@@ -1303,6 +1436,32 @@ button {
1303
1436
  gap: 0.7rem;
1304
1437
  }
1305
1438
 
1439
+ .assignees-container {
1440
+ display: grid;
1441
+ gap: 0.55rem;
1442
+ }
1443
+
1444
+ .assignee-row {
1445
+ display: grid;
1446
+ grid-template-columns: auto auto minmax(0, 1fr);
1447
+ align-items: center;
1448
+ gap: 0.65rem;
1449
+ padding: 0.45rem 0.5rem;
1450
+ border-radius: calc(12px * var(--ui-scale));
1451
+ background: rgba(255, 255, 255, 0.035);
1452
+ }
1453
+
1454
+ .assignee-row input {
1455
+ margin: 0;
1456
+ }
1457
+
1458
+ .assignee-name {
1459
+ min-width: 0;
1460
+ overflow: hidden;
1461
+ text-overflow: ellipsis;
1462
+ white-space: nowrap;
1463
+ }
1464
+
1306
1465
  .attachment-row {
1307
1466
  display: grid;
1308
1467
  grid-template-columns: auto minmax(0, 1fr) auto auto;
@@ -1657,11 +1816,6 @@ button {
1657
1816
  grid-template-columns: 1fr;
1658
1817
  }
1659
1818
 
1660
- .board-header {
1661
- flex-direction: column;
1662
- align-items: start;
1663
- }
1664
-
1665
1819
  .modal-grid {
1666
1820
  grid-template-columns: 1fr;
1667
1821
  }
@@ -6,6 +6,7 @@ const { createConfigController } = require("../controllers/configController");
6
6
  const { createImportController } = require("../controllers/importController");
7
7
  const { createSyncController } = require("../controllers/syncController");
8
8
  const { createUploadController } = require("../controllers/uploadController");
9
+ const { createUserController } = require("../controllers/userController");
9
10
  const { asyncHandler } = require("../middleware/asyncHandler");
10
11
 
11
12
  function createApiRoutes(services) {
@@ -15,10 +16,12 @@ function createApiRoutes(services) {
15
16
  const importController = createImportController(services.boardService, services.importService);
16
17
  const syncController = createSyncController(services.gitSyncService);
17
18
  const uploadController = createUploadController(services.uploadService);
19
+ const userController = createUserController(services.userService);
18
20
 
19
21
  router.get("/board", asyncHandler(boardController.getBoard));
20
22
  router.put("/board", asyncHandler(boardController.saveBoard));
21
23
  router.get("/config", asyncHandler(configController.getConfig));
24
+ router.get("/users", asyncHandler(userController.getUsers));
22
25
  router.post("/uploads", asyncHandler(uploadController.uploadFiles));
23
26
  router.delete("/uploads/:fileName", asyncHandler(uploadController.deleteUpload));
24
27
  router.post("/import", asyncHandler(importController.importBoard));
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+
3
+ const { createGitService } = require("./gitService");
4
+ const { gravatarUrlForEmail, initialsForUser, userIdForIdentity } = require("../utils/userUtils");
5
+ const { nonEmptyString } = require("../utils/stringUtils");
6
+
7
+ function createUserService(config) {
8
+ const gitService = createGitService(config);
9
+
10
+ return {
11
+ listUsers: () => listUsers(config, gitService)
12
+ };
13
+ }
14
+
15
+ async function listUsers(config, gitService) {
16
+ const usersByKey = new Map();
17
+ await addCurrentGitUser(config, gitService, usersByKey);
18
+ await addCommitAuthors(config, gitService, usersByKey);
19
+ return [...usersByKey.values()].sort((left, right) => left.name.localeCompare(right.name));
20
+ }
21
+
22
+ async function addCurrentGitUser(config, gitService, usersByKey) {
23
+ const [name, email] = await Promise.all([
24
+ gitService.gitUserName(config.workspaceDir),
25
+ gitService.gitUserEmail(config.workspaceDir)
26
+ ]);
27
+ addUser(usersByKey, name, email, true);
28
+ }
29
+
30
+ async function addCommitAuthors(config, gitService, usersByKey) {
31
+ if (!(await gitService.hasGitRepository(config.workspaceDir))) return;
32
+ const result = await gitService.runGit(config.workspaceDir, ["log", "--format=%aN%x00%aE%x00%ct"]);
33
+ if (result.code !== 0 || !result.stdout.trim()) return;
34
+
35
+ for (const line of result.stdout.split("\n")) {
36
+ const [name, email, timestamp] = line.split("\0");
37
+ addUser(usersByKey, name, email, false, Number(timestamp) || 0);
38
+ }
39
+ }
40
+
41
+ function addUser(usersByKey, nameValue, emailValue, isCurrentUser = false, lastCommitTime = 0) {
42
+ const name = nonEmptyString(nameValue);
43
+ const email = nonEmptyString(emailValue).toLowerCase();
44
+ if (!name && !email) return;
45
+
46
+ const key = email || `name:${name.toLowerCase()}`;
47
+ const existing = usersByKey.get(key);
48
+ const next = {
49
+ id: userIdForIdentity(name, email),
50
+ name: name || email,
51
+ email,
52
+ initials: initialsForUser(name, email),
53
+ avatarUrl: gravatarUrlForEmail(email),
54
+ isCurrentUser: Boolean(existing?.isCurrentUser || isCurrentUser),
55
+ lastCommitTime: Math.max(existing?.lastCommitTime || 0, lastCommitTime)
56
+ };
57
+ usersByKey.set(key, next);
58
+ }
59
+
60
+ module.exports = {
61
+ createUserService,
62
+ listUsers
63
+ };
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+
5
+ function gravatarUrlForEmail(email) {
6
+ if (typeof email !== "string" || !email.trim()) return "";
7
+ const hash = crypto.createHash("md5").update(email.trim().toLowerCase()).digest("hex");
8
+ return `https://www.gravatar.com/avatar/${hash}?s=64&d=404`;
9
+ }
10
+
11
+ function initialsForUser(name, email) {
12
+ const source = String(name || email || "User").trim();
13
+ return source
14
+ .split(/\s+/)
15
+ .slice(0, 2)
16
+ .map((part) => part[0]?.toUpperCase() || "")
17
+ .join("") || "U";
18
+ }
19
+
20
+ function userIdForIdentity(name, email) {
21
+ const source = email ? `email:${email.trim().toLowerCase()}` : `name:${String(name || "user").trim().toLowerCase()}`;
22
+ return crypto.createHash("sha1").update(source).digest("hex").slice(0, 16);
23
+ }
24
+
25
+ module.exports = {
26
+ gravatarUrlForEmail,
27
+ initialsForUser,
28
+ userIdForIdentity
29
+ };